Skip to content

Commit e8fe273

Browse files
author
SqlRush
committed
Unwrap transcript metadata records
1 parent e205f87 commit e8fe273

8 files changed

Lines changed: 549 additions & 272 deletions

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ M7 补充:prompt history `LogEntry` 读取现在接受 `sessionID`/`session`/`
528528

529529
本轮补充:microcompact disk cache loader 现在会把 `value` 字段中的 text content-block object 识别为 direct summary payload,同时继续从同一 `value` object 中补 digest、version、timestamp 等 cache metadata,避免 `value` 作为 summary/cache wrapper 双义字段时丢摘要或 sidecar 信息。
530530

531+
本轮补充:transcript metadata loader 现在先递归解包 JSON:API/resource、GraphQL edge/node、`included`、collection/list/values 等 wrapper,再做 metadata type 分类;full transcript、lightweight metadata 和 transcript index 都能恢复 wrapped title/task/tag/worktree/content-replacement/context-collapse metadata。
532+
531533
本轮补充:contract content block 解码现在接受文本块字段别名 `body`/`message`/`value`/`output`/`contentText`/`content_text`,并在 `text`/`thinking` block 中从字符串 `content` 回填文本,transcript resume 可恢复这些嵌套文本块格式。
532534

533535
本轮补充:contract image source 解码现在接受 `mediaType`/`mimeType`/`contentType``base64`/`payload` 等 source 字段别名,并支持顶层 image block 直接携带媒体类型和 base64 数据,transcript resume 会保留为规范 `ImageSource`

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ test/parity/ # golden tests against TS/official behavior
224224
- 本轮补充:context-collapse commit metadata 的 collapse/summary/archived ID 字段现在也走 metadata ID helper,支持 JSON number ID 并保持 full/lightweight loader 一致。
225225
- 本轮补充:content-replacement metadata 的 `agentId`、record `toolUseId``blockId` 现在也接受 JSON number,并在 full/lightweight loader 中保留为字符串 ID。
226226
- 本轮补充:context-collapse snapshot metadata 接受 `isArmed`/`enabled` bool 别名、`spawnTokens`/`tokenCount` 计数字段别名,以及 `stagedMessages`/`items` staged payload wrapper,full loader 和 metadata loader 保持一致。
227+
- 本轮补充:transcript metadata 读取现在会先解包 JSON:API/resource、GraphQL edge/node、`included` 以及 collection/list/values wrapper,再做 metadata type 分类;full loader、lightweight metadata loader 和 transcript index 都可恢复 wrapped title/task/tag/worktree/content-replacement/context-collapse 条目。
227228
- 本轮补充:transcript message 和嵌套 contract message 接受顶层 `sessionID` 作为 session id 别名,`LoadTranscript``LoadTranscriptIndex` 和 indexed resume 会保留该 session id(覆盖测试:`TestLoadTranscriptAcceptsSessionIDUpperAlias`)。
228229
- 本轮补充:嵌套 contract message 接受 `parentUUID``parentId`/`parentID`/`parent_id``parentMessageId`/`parentMessageID`/`parent_message_id` 和 parent-message UUID 别名,transcript/remote history payload 自带 parent alias 时不会丢失嵌套 parent。
229230
- 本轮补充:indexed resume chain 现在区分 byte budget 截断掉的 parent 和 transcript 里真实缺失的 parent,bounded resume 会分别暴露 `TruncatedParent``MissingParent`

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ M6 progress now includes:
203203
- `internal/session`: context-collapse commit metadata now routes collapse, summary, and archived ID fields through metadata ID decoding so JSON number IDs are preserved in full and lightweight metadata loaders.
204204
- `internal/session`: content-replacement metadata now preserves JSON number agent, tool-use, and block IDs as strings in full and lightweight metadata loaders.
205205
- `internal/session`: context-collapse snapshot metadata now accepts alternate armed, token-count, and staged payload field names in both full and lightweight metadata loaders.
206+
- `internal/session`: transcript metadata loading now unwraps JSON:API/resource, GraphQL edge/node, `included`, and collection/list/value wrappers before metadata classification, so full transcripts, lightweight metadata, and transcript indexes recover wrapped title/task/tag/worktree/content-replacement/context-collapse entries consistently.
206207
- `internal/session` and `internal/contracts`: transcript records and nested contract messages now accept top-level `sessionID` as a session id alias, preserving it through `LoadTranscript`, `LoadTranscriptIndex`, and indexed resume (`TestLoadTranscriptAcceptsSessionIDUpperAlias`).
207208
- `internal/contracts`/`internal/session`: remote-history `SDKEvent` decoding now accepts top-level `sessionID` as an event session id alias and preserves it during transcript materialization (`TestRemoteHistoryTranscriptMessagesAcceptsSessionIDUpperAlias`).
208209
- `internal/contracts`/`internal/session`: remote-history `SDKEvent` decoding now accepts parent aliases such as `parentUUID`, `parentId`/`parentID`/`parent_id`, and `parentMessageId`/`parentMessageID`/`parent_message_id`, preserving parent chains during transcript materialization (`TestRemoteHistoryTranscriptMessagesAcceptsParentIDAliases`).

internal/session/transcript.go

Lines changed: 109 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -177,119 +177,121 @@ func LoadTranscript(path string) (Transcript, error) {
177177

178178
scanner := newTranscriptScanner(f)
179179
for scanner.Scan() {
180-
line := bytes.TrimSpace(scanner.Bytes())
181-
if len(line) == 0 {
182-
continue
183-
}
184-
var envelope transcriptEnvelope
185-
if err := json.Unmarshal(line, &envelope); err != nil {
186-
continue
187-
}
188-
if envelope.Type == "progress" && envelope.UUID != "" {
189-
progressBridge[envelope.UUID] = resolveProgressParent(progressBridge, envelope.ParentUUID)
180+
physicalLine := bytes.TrimSpace(scanner.Bytes())
181+
if len(physicalLine) == 0 {
190182
continue
191183
}
192-
metadataType := normalizeTranscriptMetadataType(envelope.Type)
193-
switch {
194-
case isTranscriptType(envelope.Type):
195-
var msg TranscriptMessage
196-
if err := json.Unmarshal(line, &msg); err != nil || msg.UUID == "" {
184+
for _, line := range transcriptRecordLines(physicalLine) {
185+
var envelope transcriptEnvelope
186+
if err := json.Unmarshal(line, &envelope); err != nil {
197187
continue
198188
}
199-
if msg.ParentUUID != nil {
200-
if bridged, ok := progressBridge[*msg.ParentUUID]; ok {
201-
msg.ParentUUID = cloneIDPtr(bridged)
202-
}
203-
}
204-
msg.Raw = append(json.RawMessage(nil), line...)
205-
transcript.addMessage(&msg)
206-
if msg.IsCompactBoundary() {
207-
transcript.ContextCollapseCommits = nil
208-
transcript.ContextCollapseSnapshot = nil
209-
}
210-
case metadataType == "summary":
211-
if leafUUID, summary, ok := parseSummaryMetadata(line); ok && leafUUID != "" {
212-
transcript.Summaries[leafUUID] = summary
213-
}
214-
case metadataType == "custom-title":
215-
if sessionID, title, ok := parseSessionStringMetadata(line, "customTitle", "custom_title", "title", "name"); ok && sessionID != "" {
216-
transcript.CustomTitles[sessionID] = title
217-
}
218-
case metadataType == "ai-title":
219-
if sessionID, title, ok := parseSessionStringMetadata(line, "aiTitle", "ai_title", "title", "name"); ok && sessionID != "" {
220-
transcript.AITitles[sessionID] = title
221-
}
222-
case metadataType == "last-prompt":
223-
if sessionID, prompt, ok := parseSessionStringMetadata(line, "lastPrompt", "last_prompt", "prompt", "input", "text", "content", "message"); ok && sessionID != "" {
224-
transcript.LastPrompts[sessionID] = prompt
225-
}
226-
case metadataType == "task-summary":
227-
if entry, ok := parseTaskSummaryMetadata(line); ok && entry.SessionID != "" {
228-
transcript.TaskSummaries[entry.SessionID] = entry
229-
}
230-
case metadataType == "tag":
231-
if sessionID, tag, ok := parseSessionStringMetadata(line, "tag", "value", "name", "label"); ok && sessionID != "" {
232-
transcript.Tags[sessionID] = tag
233-
}
234-
case metadataType == "agent-name":
235-
if sessionID, name, ok := parseSessionStringMetadata(line, "agentName", "agent_name", "name", "agent", "title"); ok && sessionID != "" {
236-
transcript.AgentNames[sessionID] = name
237-
}
238-
case metadataType == "agent-color":
239-
if sessionID, color, ok := parseSessionStringMetadata(line, "agentColor", "agent_color", "color", "colour", "value"); ok && sessionID != "" {
240-
transcript.AgentColors[sessionID] = color
241-
}
242-
case metadataType == "agent-setting":
243-
if sessionID, setting, ok := parseSessionStringMetadata(line, "agentSetting", "agent_setting", "setting", "value", "mode"); ok && sessionID != "" {
244-
transcript.AgentSettings[sessionID] = setting
245-
}
246-
case metadataType == "pr-link":
247-
if entry, ok := parsePRLinkMetadata(line); ok && entry.SessionID != "" {
248-
transcript.PRLinks[entry.SessionID] = entry
249-
}
250-
case metadataType == "mode":
251-
if sessionID, mode, ok := parseSessionStringMetadata(line, "mode", "value", "name", "status"); ok && sessionID != "" {
252-
transcript.Modes[sessionID] = mode
253-
}
254-
case metadataType == "worktree-state":
255-
if entry, ok := parseWorktreeStateMetadata(line); ok && entry.SessionID != "" {
256-
transcript.WorktreeStates[entry.SessionID] = entry
189+
if envelope.Type == "progress" && envelope.UUID != "" {
190+
progressBridge[envelope.UUID] = resolveProgressParent(progressBridge, envelope.ParentUUID)
191+
continue
257192
}
258-
case metadataType == "content-replacement":
259-
if entry, ok := parseContentReplacementMetadata(line); ok && entry.SessionID != "" {
260-
key := entry.SessionID
261-
if entry.AgentID != "" {
262-
key = contracts.ID(entry.AgentID)
193+
metadataType := normalizeTranscriptMetadataType(envelope.Type)
194+
switch {
195+
case isTranscriptType(envelope.Type):
196+
var msg TranscriptMessage
197+
if err := json.Unmarshal(line, &msg); err != nil || msg.UUID == "" {
198+
continue
199+
}
200+
if msg.ParentUUID != nil {
201+
if bridged, ok := progressBridge[*msg.ParentUUID]; ok {
202+
msg.ParentUUID = cloneIDPtr(bridged)
203+
}
204+
}
205+
msg.Raw = append(json.RawMessage(nil), line...)
206+
transcript.addMessage(&msg)
207+
if msg.IsCompactBoundary() {
208+
transcript.ContextCollapseCommits = nil
209+
transcript.ContextCollapseSnapshot = nil
210+
}
211+
case metadataType == "summary":
212+
if leafUUID, summary, ok := parseSummaryMetadata(line); ok && leafUUID != "" {
213+
transcript.Summaries[leafUUID] = summary
214+
}
215+
case metadataType == "custom-title":
216+
if sessionID, title, ok := parseSessionStringMetadata(line, "customTitle", "custom_title", "title", "name"); ok && sessionID != "" {
217+
transcript.CustomTitles[sessionID] = title
218+
}
219+
case metadataType == "ai-title":
220+
if sessionID, title, ok := parseSessionStringMetadata(line, "aiTitle", "ai_title", "title", "name"); ok && sessionID != "" {
221+
transcript.AITitles[sessionID] = title
222+
}
223+
case metadataType == "last-prompt":
224+
if sessionID, prompt, ok := parseSessionStringMetadata(line, "lastPrompt", "last_prompt", "prompt", "input", "text", "content", "message"); ok && sessionID != "" {
225+
transcript.LastPrompts[sessionID] = prompt
226+
}
227+
case metadataType == "task-summary":
228+
if entry, ok := parseTaskSummaryMetadata(line); ok && entry.SessionID != "" {
229+
transcript.TaskSummaries[entry.SessionID] = entry
230+
}
231+
case metadataType == "tag":
232+
if sessionID, tag, ok := parseSessionStringMetadata(line, "tag", "value", "name", "label"); ok && sessionID != "" {
233+
transcript.Tags[sessionID] = tag
234+
}
235+
case metadataType == "agent-name":
236+
if sessionID, name, ok := parseSessionStringMetadata(line, "agentName", "agent_name", "name", "agent", "title"); ok && sessionID != "" {
237+
transcript.AgentNames[sessionID] = name
238+
}
239+
case metadataType == "agent-color":
240+
if sessionID, color, ok := parseSessionStringMetadata(line, "agentColor", "agent_color", "color", "colour", "value"); ok && sessionID != "" {
241+
transcript.AgentColors[sessionID] = color
242+
}
243+
case metadataType == "agent-setting":
244+
if sessionID, setting, ok := parseSessionStringMetadata(line, "agentSetting", "agent_setting", "setting", "value", "mode"); ok && sessionID != "" {
245+
transcript.AgentSettings[sessionID] = setting
246+
}
247+
case metadataType == "pr-link":
248+
if entry, ok := parsePRLinkMetadata(line); ok && entry.SessionID != "" {
249+
transcript.PRLinks[entry.SessionID] = entry
250+
}
251+
case metadataType == "mode":
252+
if sessionID, mode, ok := parseSessionStringMetadata(line, "mode", "value", "name", "status"); ok && sessionID != "" {
253+
transcript.Modes[sessionID] = mode
254+
}
255+
case metadataType == "worktree-state":
256+
if entry, ok := parseWorktreeStateMetadata(line); ok && entry.SessionID != "" {
257+
transcript.WorktreeStates[entry.SessionID] = entry
258+
}
259+
case metadataType == "content-replacement":
260+
if entry, ok := parseContentReplacementMetadata(line); ok && entry.SessionID != "" {
261+
key := entry.SessionID
262+
if entry.AgentID != "" {
263+
key = contracts.ID(entry.AgentID)
264+
}
265+
transcript.ContentReplacements[key] = append(transcript.ContentReplacements[key], entry.Replacements...)
266+
}
267+
case metadataType == "tombstone":
268+
if entry, ok := parseTombstoneMetadata(line); ok && entry.TargetUUID != "" {
269+
transcript.Tombstones[entry.TargetUUID] = entry
270+
}
271+
case metadataType == "file-history-snapshot":
272+
snapshot := append(json.RawMessage(nil), line...)
273+
transcript.FileHistorySnapshots = append(transcript.FileHistorySnapshots, snapshot)
274+
if messageID := parseSnapshotMessageID(line); messageID != "" {
275+
transcript.FileHistoryByMessageID[messageID] = snapshot
276+
}
277+
case metadataType == "attribution-snapshot":
278+
snapshot := append(json.RawMessage(nil), line...)
279+
transcript.AttributionSnapshots = append(transcript.AttributionSnapshots, snapshot)
280+
if messageID := parseSnapshotMessageID(line); messageID != "" {
281+
transcript.AttributionByMessageID[messageID] = snapshot
282+
}
283+
case metadataType == "speculation-accept":
284+
if entry, ok := parseSpeculationAcceptMetadata(line); ok {
285+
transcript.SpeculationAccepts = append(transcript.SpeculationAccepts, entry)
286+
}
287+
case metadataType == "marble-origami-commit":
288+
if entry, ok := parseContextCollapseCommitMetadata(line); ok {
289+
transcript.ContextCollapseCommits = append(transcript.ContextCollapseCommits, entry)
290+
}
291+
case metadataType == "marble-origami-snapshot":
292+
if entry, ok := parseContextCollapseSnapshotMetadata(line); ok {
293+
transcript.ContextCollapseSnapshot = &entry
263294
}
264-
transcript.ContentReplacements[key] = append(transcript.ContentReplacements[key], entry.Replacements...)
265-
}
266-
case metadataType == "tombstone":
267-
if entry, ok := parseTombstoneMetadata(line); ok && entry.TargetUUID != "" {
268-
transcript.Tombstones[entry.TargetUUID] = entry
269-
}
270-
case metadataType == "file-history-snapshot":
271-
snapshot := append(json.RawMessage(nil), line...)
272-
transcript.FileHistorySnapshots = append(transcript.FileHistorySnapshots, snapshot)
273-
if messageID := parseSnapshotMessageID(line); messageID != "" {
274-
transcript.FileHistoryByMessageID[messageID] = snapshot
275-
}
276-
case metadataType == "attribution-snapshot":
277-
snapshot := append(json.RawMessage(nil), line...)
278-
transcript.AttributionSnapshots = append(transcript.AttributionSnapshots, snapshot)
279-
if messageID := parseSnapshotMessageID(line); messageID != "" {
280-
transcript.AttributionByMessageID[messageID] = snapshot
281-
}
282-
case metadataType == "speculation-accept":
283-
if entry, ok := parseSpeculationAcceptMetadata(line); ok {
284-
transcript.SpeculationAccepts = append(transcript.SpeculationAccepts, entry)
285-
}
286-
case metadataType == "marble-origami-commit":
287-
if entry, ok := parseContextCollapseCommitMetadata(line); ok {
288-
transcript.ContextCollapseCommits = append(transcript.ContextCollapseCommits, entry)
289-
}
290-
case metadataType == "marble-origami-snapshot":
291-
if entry, ok := parseContextCollapseSnapshotMetadata(line); ok {
292-
transcript.ContextCollapseSnapshot = &entry
293295
}
294296
}
295297
}

0 commit comments

Comments
 (0)