@volter/supercode-interchange 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +88 -0
  2. package/hermes-store.mjs +5 -2
  3. package/message.mjs +85 -0
  4. package/native-json.mjs +94 -7
  5. package/package.json +16 -3
  6. package/session-claude-append.mjs +107 -0
  7. package/session-claude-content.mjs +209 -0
  8. package/session-claude-files.mjs +78 -0
  9. package/session-claude-graph.mjs +285 -0
  10. package/session-claude-index.mjs +173 -0
  11. package/session-claude.mjs +73 -0
  12. package/session-codec-utils.mjs +115 -0
  13. package/session-codecs.d.ts +64 -0
  14. package/session-codecs.mjs +19 -0
  15. package/session-codex-context.mjs +68 -0
  16. package/session-codex-display-items.mjs +89 -0
  17. package/session-codex-display.mjs +81 -0
  18. package/session-codex-items.mjs +117 -0
  19. package/session-codex-user-history.mjs +62 -0
  20. package/session-codex.mjs +135 -0
  21. package/session-display.mjs +135 -0
  22. package/session-document.mjs +19 -0
  23. package/session-file.mjs +27 -0
  24. package/session-follower.mjs +181 -0
  25. package/session-gemini.mjs +93 -0
  26. package/session-goose.mjs +139 -0
  27. package/session-grok.mjs +72 -0
  28. package/session-hermes.mjs +116 -0
  29. package/session-lines.mjs +20 -0
  30. package/session-model.d.ts +114 -0
  31. package/session-model.mjs +70 -0
  32. package/session-native-residue.mjs +109 -0
  33. package/session-native.mjs +62 -0
  34. package/session-opencode-content.mjs +115 -0
  35. package/session-opencode-store.mjs +101 -0
  36. package/session-opencode.mjs +115 -0
  37. package/session-path.mjs +129 -0
  38. package/session-pi-content.mjs +129 -0
  39. package/session-pi.mjs +86 -0
  40. package/session-read.d.ts +12 -0
  41. package/session-read.mjs +104 -0
  42. package/session-source.mjs +30 -0
  43. package/session-time.mjs +46 -0
  44. package/session-views.mjs +171 -0
@@ -0,0 +1,209 @@
1
+ import { createMessage, markToolError, markToolOutcomeUnknown } from './message.mjs';
2
+ import { cloneNativeJson, nativeInteger, stringifyNativeJson, stringifyNativeJsonMember } from './native-json.mjs';
3
+ import { field, stringField, functionCall, toolMessage, restoreGrokMessageExtension } from './session-codec-utils.mjs';
4
+ import { nativeTrim } from './text.mjs';
5
+
6
+ export const UNCONVERTIBLE_IMAGE_MARKER = '[image: source not captured — unsupported/unconvertible image reference]';
7
+ const appendText = (text, part) => typeof part === 'string' ? text + (text === '' ? '' : '\n') + part : text;
8
+
9
+ export function claudeImagePart(block) {
10
+ const source = field(block, 'source'), kind = stringField(source, 'type');
11
+ if (kind === 'base64') {
12
+ const mime = stringField(source, 'media_type'), data = stringField(source, 'data');
13
+ return mime != null && mime !== '' && data != null && data !== '' ? { type: 'image_url', image_url: { url: `data:${mime};base64,${data}` } } : null;
14
+ }
15
+ const url = stringField(source, 'url');
16
+ return kind === 'url' && url != null && url !== '' ? { type: 'image_url', image_url: { url } } : null;
17
+ }
18
+
19
+ function toolResultContent(block, record) {
20
+ const content = field(block, 'content'), parts = [], images = [];
21
+ if (typeof content === 'string') { if (content !== '') parts.push(content); }
22
+ else if (Array.isArray(content)) {
23
+ for (const item of content) {
24
+ const kind = stringField(item, 'type');
25
+ if (kind === 'text') { const text = stringField(item, 'text'); if (text != null) parts.push(text); }
26
+ else if (kind === 'image') { const image = claudeImagePart(item); if (image != null) images.push(image); else parts.push(UNCONVERTIBLE_IMAGE_MARKER); }
27
+ else if (kind === 'tool_reference') parts.push(`[tool_reference: ${stringField(item, 'tool_name') ?? '?'}]`);
28
+ else if (typeof item === 'string') parts.push(item);
29
+ }
30
+ } else if (content !== undefined) parts.push(stringifyNativeJsonMember(block, 'content'));
31
+ const joined = parts.join('\n');
32
+ if (nativeTrim(joined) !== '' || images.length) return { text: joined, images };
33
+ const fallback = field(record, 'toolUseResult');
34
+ return { text: fallback === undefined ? joined : typeof fallback === 'string' ? fallback : stringifyNativeJsonMember(record, 'toolUseResult'), images };
35
+ }
36
+
37
+ export function claudeUserProvenance(value) {
38
+ const result = Object.create(null);
39
+ for (const key of ['promptSource', 'interruptedMessageId', 'sourceToolUseID']) {
40
+ const text = stringField(value, key); if (text != null) result[key] = text;
41
+ }
42
+ for (const key of ['isMeta', 'isCompactSummary', 'isVisibleInTranscriptOnly']) if (field(value, key) === true) result[key] = 'true';
43
+ const priority = nativeInteger(value, 'queuePriority', { signed: true });
44
+ if (priority != null) result.queuePriority = String(priority);
45
+ const origin = stringField(field(value, 'origin'), 'kind'); if (origin != null) result.origin = origin;
46
+ return result;
47
+ }
48
+
49
+ export function pushClaudeUser(value, out) {
50
+ const content = field(field(value, 'message'), 'content'), provenance = claudeUserProvenance(value);
51
+ if (typeof content === 'string') {
52
+ if (nativeTrim(content) !== '') { const message = createMessage('user', content); Object.assign(message.metadata, provenance); out.push(message); }
53
+ return;
54
+ }
55
+ if (!Array.isArray(content)) return;
56
+ let text = '', unconvertible = false;
57
+ const images = [];
58
+ for (const block of content) {
59
+ const kind = stringField(block, 'type');
60
+ if (kind === 'text') text = appendText(text, stringField(block, 'text'));
61
+ else if (kind === 'image') {
62
+ const image = claudeImagePart(block); if (image == null) unconvertible = true; else images.push(image);
63
+ } else if (kind === 'tool_result') {
64
+ const result = toolResultContent(block, value), message = toolMessage(stringField(block, 'tool_use_id') ?? '', result.text);
65
+ if (result.images.length) {
66
+ message.content_parts = message.content === '' ? [] : [{ type: 'text', text: message.content }];
67
+ for (const image of result.images) message.content_parts.push(image);
68
+ }
69
+ const assistant = stringField(value, 'sourceToolAssistantUUID');
70
+ if (assistant != null) message.metadata.sourceToolAssistantUUID = assistant;
71
+ if (field(block, 'is_error') === true) markToolError(message);
72
+ else if (stringField(value, '_supercode_tool_outcome') === 'unknown') markToolOutcomeUnknown(message);
73
+ out.push(message);
74
+ }
75
+ }
76
+ if (!images.length && nativeTrim(text) === '' && unconvertible) text = appendText(text, UNCONVERTIBLE_IMAGE_MARKER);
77
+ if (!images.length && nativeTrim(text) === '') return;
78
+ const message = createMessage('user', images.length ? null : text);
79
+ if (images.length) {
80
+ message.content_parts = nativeTrim(text) === '' ? [] : [{ type: 'text', text }];
81
+ for (const image of images) message.content_parts.push(image);
82
+ }
83
+ Object.assign(message.metadata, provenance);
84
+ if (unconvertible) message.metadata.image_source_unconvertible = 'true';
85
+ out.push(message);
86
+ }
87
+
88
+ export function pushClaudeAssistant(value, out) {
89
+ const content = field(field(value, 'message'), 'content'), calls = [], images = [], thinkingBlocks = [];
90
+ let text = '', thinking = '', signature = null, redacted = null, thinkingSeen = false, redactedSeen = false, unconvertible = false;
91
+ if (typeof content === 'string') text = appendText(text, content);
92
+ else if (Array.isArray(content)) for (const block of content) {
93
+ const kind = stringField(block, 'type');
94
+ if (kind === 'text') text = appendText(text, stringField(block, 'text'));
95
+ else if (kind === 'tool_use') calls.push(functionCall(stringField(block, 'id') ?? '', stringField(block, 'name') ?? '', field(block, 'input') === undefined ? '{}' : stringifyNativeJsonMember(block, 'input')));
96
+ else if (kind === 'thinking') {
97
+ thinkingSeen = true;
98
+ const part = stringField(block, 'thinking') ?? '', sig = stringField(block, 'signature');
99
+ if (part !== '') thinking = appendText(thinking, part);
100
+ const retained = { type: 'thinking', thinking: part };
101
+ if (sig != null) { signature = sig; retained.signature = sig; }
102
+ thinkingBlocks.push(retained);
103
+ } else if (kind === 'redacted_thinking') {
104
+ redactedSeen = true;
105
+ const data = stringField(block, 'data'), retained = { type: 'redacted_thinking' };
106
+ if (data != null) { redacted = data; retained.data = data; }
107
+ thinkingBlocks.push(retained);
108
+ } else if (kind === 'image') {
109
+ const image = claudeImagePart(block); if (image == null) unconvertible = true; else images.push(image);
110
+ } else if (kind === 'fallback') {
111
+ text = appendText(text, `[model fallback: ${stringField(field(block, 'from'), 'model') ?? '?'} -> ${stringField(field(block, 'to'), 'model') ?? '?'}]`);
112
+ }
113
+ }
114
+ if (!images.length && nativeTrim(text) === '' && unconvertible) text = appendText(text, UNCONVERTIBLE_IMAGE_MARKER);
115
+ const hasText = nativeTrim(text) !== '', message = createMessage('assistant', images.length || !hasText ? null : text);
116
+ message.tool_calls = calls.length ? calls : null;
117
+ if (images.length) {
118
+ message.content_parts = hasText ? [{ type: 'text', text }] : [];
119
+ for (const image of images) message.content_parts.push(image);
120
+ } else if (!hasText && !calls.length && !thinkingSeen && !redactedSeen) message.metadata.empty_assistant_record = 'true';
121
+ if (thinkingSeen) message.metadata.thinking = thinking;
122
+ if (signature != null) message.metadata.thinking_signature = signature;
123
+ if (redacted != null) message.metadata.redacted_thinking = redacted;
124
+ if (thinkingBlocks.length) message.metadata.thinking_blocks = stringifyNativeJson(thinkingBlocks);
125
+ if (unconvertible) message.metadata.image_source_unconvertible = 'true';
126
+ for (const key of ['attributionSkill', 'attributionAgent', 'attributionMcpServer', 'attributionMcpTool', 'slug']) {
127
+ const text = stringField(value, key); if (text != null) message.metadata[key] = text;
128
+ }
129
+ out.push(message);
130
+ }
131
+
132
+ export function pushClaudeSystem(value, out) {
133
+ const subtype = stringField(value, 'subtype'), content = stringField(value, 'content');
134
+ if (!['scheduled_task_fire', 'local_command', 'away_summary'].includes(subtype) || content == null || nativeTrim(content) === '') return;
135
+ const message = createMessage('system', content); message.metadata.systemSubtype = subtype; out.push(message);
136
+ }
137
+
138
+ export function pushClaudeAttachment(value, out) {
139
+ const attachment = field(value, 'attachment'), kind = stringField(attachment, 'type');
140
+ let text = null;
141
+ if (kind === 'queued_command') text = stringField(attachment, 'prompt');
142
+ else {
143
+ const spec = { file: ['attached file', 'filename', 'content'], edited_text_file: ['edited file', 'filename', 'snippet'], nested_memory: ['project memory', 'path', 'content'] };
144
+ const selected = Object.hasOwn(spec, kind ?? '') ? spec[kind] : null;
145
+ if (selected != null) {
146
+ const [label, pathKey, bodyKey] = selected, body = stringField(attachment, bodyKey);
147
+ const path = field(attachment, pathKey) === undefined ? stringField(attachment, 'displayPath') : stringField(attachment, pathKey);
148
+ if (body != null) text = `[${label}: ${path ?? ''}]\n${body}`;
149
+ }
150
+ }
151
+ if (text == null || nativeTrim(text) === '') return;
152
+ const message = createMessage('user', text); message.metadata.attachmentType = kind;
153
+ const mode = stringField(attachment, 'commandMode'); if (mode != null) message.metadata.commandMode = mode;
154
+ out.push(message);
155
+ }
156
+
157
+ export function captureClaudeProvenance(value, messages, from = 0) {
158
+ const facts = [['timestamp', stringField(value, 'timestamp')], ['claude_uuid', stringField(value, 'uuid')], ['model', stringField(field(value, 'message'), 'model')]];
159
+ for (let index = from; index < messages.length; index++) for (const [key, fact] of facts) {
160
+ if (fact != null && !Object.hasOwn(messages[index].metadata, key)) messages[index].metadata[key] = fact;
161
+ }
162
+ if (messages.length - from === 1) restoreGrokMessageExtension(value, messages[from]);
163
+ }
164
+
165
+ export function mergeClaudeAssistantChunk(target, chunk) {
166
+ if (field(target, 'message') === undefined || field(chunk, 'message') === undefined) return;
167
+ const previous = field(target.message, 'content'), next = field(chunk.message, 'content');
168
+ const content = Array.isArray(previous) ? cloneNativeJson(previous) : [];
169
+ if (Array.isArray(next)) for (const block of next) content.push(cloneNativeJson(block));
170
+ target.message = cloneNativeJson(chunk.message);
171
+ target.message.content = content;
172
+ }
173
+
174
+ export function flushClaudeAssistant(value, messages) {
175
+ if (value == null) return;
176
+ const content = field(field(value, 'message'), 'content');
177
+ if (stringField(field(value, 'message'), 'id') != null && Array.isArray(content) && content.length > 0 && content.every((block) => ['thinking', 'redacted_thinking'].includes(stringField(block, 'type')))) return;
178
+ const from = messages.length; pushClaudeAssistant(value, messages); captureClaudeProvenance(value, messages, from);
179
+ }
180
+
181
+ /** Preserve every result, relocating only those with a recorded assistant
182
+ * owner; orphan results keep their position relative to the remaining spine. */
183
+ export function reorderClaudeToolResults(messages) {
184
+ const owners = new Map();
185
+ messages.forEach((message, index) => {
186
+ if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '' && !owners.has(call.id)) owners.set(call.id, index);
187
+ });
188
+ let contiguous = null;
189
+ const needed = messages.some((message, index) => {
190
+ if (message.role === 'assistant') { contiguous = index; return false; }
191
+ const owner = message.role === 'tool' ? owners.get(message.tool_call_id) : undefined;
192
+ if (owner !== undefined) return owner !== contiguous;
193
+ contiguous = null; return false;
194
+ });
195
+ if (!needed) return;
196
+ const spine = [], results = [], positions = new Map(), buckets = new Map();
197
+ for (const message of messages) {
198
+ if (message.role === 'tool' && message.tool_call_id != null && message.tool_call_id !== '' && owners.has(message.tool_call_id)) { results.push(message); continue; }
199
+ if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '' && !positions.has(call.id)) positions.set(call.id, spine.length);
200
+ spine.push(message);
201
+ }
202
+ for (const message of results) {
203
+ const position = positions.get(message.tool_call_id);
204
+ if (!buckets.has(position)) buckets.set(position, []);
205
+ buckets.get(position).push(message);
206
+ }
207
+ messages.length = 0;
208
+ spine.forEach((message, index) => { messages.push(message); for (const result of buckets.get(index) ?? []) messages.push(result); });
209
+ }
@@ -0,0 +1,78 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+ import { withSessionFile } from './file-io.mjs';
4
+ import { sourceStat } from './store-registry.mjs';
5
+ import { decodeSessionUtf8, readSessionText } from './session-file.mjs';
6
+ import { parseNativeJson, stringifyNativeJson } from './native-json.mjs';
7
+ import { fromClaudeCodeStr } from './session-claude.mjs';
8
+ import { nonEmptyLines } from './session-lines.mjs';
9
+ import { field, stringField } from './session-codec-utils.mjs';
10
+
11
+ function firstAgentId(text) {
12
+ for (const line of nonEmptyLines(text)) {
13
+ let value; try { value = parseNativeJson(line); } catch { continue; }
14
+ const id = stringField(value, 'agentId'); if (id != null) return id;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function parentToolUseIndex(text, ids) {
20
+ const result = new Map();
21
+ if (!ids.length) return result;
22
+ for (const line of nonEmptyLines(text)) {
23
+ if (result.size === ids.length) break;
24
+ if (!line.includes('tool_result')) continue;
25
+ const missing = ids.filter((id) => !result.has(id));
26
+ if (!missing.length) break;
27
+ let value; try { value = parseNativeJson(line); } catch { continue; }
28
+ const blocks = field(field(value, 'message'), 'content');
29
+ if (!Array.isArray(blocks)) continue;
30
+ for (const block of blocks) {
31
+ if (stringField(block, 'type') !== 'tool_result') continue;
32
+ const tool = stringField(block, 'tool_use_id'); if (tool == null) continue;
33
+ const encoded = stringifyNativeJson(block);
34
+ for (const id of missing) if (!result.has(id) && line.includes(id) && encoded.includes(id)) result.set(id, tool);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ export async function attachClaudeSubagents(session, mainPath, mainText, fidelity, metrics) {
41
+ const name = basename(mainPath), extension = extname(name), stem = name.slice(0, name.length - extension.length);
42
+ const directory = join(dirname(mainPath), stem, 'subagents');
43
+ if (!(await sourceStat(directory))?.isDirectory()) return;
44
+ let entries;
45
+ try { entries = await readdir(directory); }
46
+ catch (error) { throw new Error(`failed to enumerate Claude subagents at ${directory}: ${error.message}`, { cause: error }); }
47
+ const files = entries.filter((name) => extname(name) === '.jsonl').map((name) => join(directory, name)).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
48
+ const collected = [];
49
+ for (const file of files) {
50
+ let text;
51
+ try { text = await readSessionText(file, metrics); }
52
+ catch (error) { throw new Error(`failed to read Claude subagent ${file}: ${error.message}`, { cause: error }); }
53
+ let child;
54
+ try { child = fromClaudeCodeStr(text, { fidelity }); }
55
+ catch (error) {
56
+ if (fidelity === 'semantic') { session.load_residue.push(`Claude subagent ${file} could not be reconstructed (${error.message}); it was omitted from this view`); continue; }
57
+ throw new Error(`failed to reconstruct Claude subagent ${file}: ${error.message}`, { cause: error });
58
+ }
59
+ const id = firstAgentId(text) ?? basename(file, '.jsonl').replace(/^(?:agent-)+/, '');
60
+ collected.push({ child, id });
61
+ }
62
+ const parents = parentToolUseIndex(mainText, collected.map(({ id }) => id));
63
+ for (const { child, id } of collected) {
64
+ child.meta.agent_id = id; child.meta.parent_tool_use_id = parents.get(id) ?? null;
65
+ session.subagents.push(child);
66
+ }
67
+ }
68
+
69
+ /** Main file and each child are read through verified identities. Child
70
+ * reconstruction can degrade only in a semantic view, never an export. */
71
+ export async function readClaudeCodeFile(path, { fidelity = 'byte_lossless', metrics } = {}) {
72
+ return withSessionFile(path, async (reader) => {
73
+ const text = decodeSessionUtf8(await reader.read(0, reader.size));
74
+ const session = fromClaudeCodeStr(text, { fidelity });
75
+ await attachClaudeSubagents(session, path, text, fidelity, metrics);
76
+ return session;
77
+ }, metrics);
78
+ }
@@ -0,0 +1,285 @@
1
+ import { field, stringField } from './session-codec-utils.mjs';
2
+
3
+ const conversation = (node) => node.kind === 'user' || node.kind === 'assistant';
4
+ const kinds = new Set(['user', 'assistant', 'attachment', 'system']);
5
+ export function claudeReplayError(message) {
6
+ return Object.assign(new Error(`cannot reconstruct lossless Claude continuation: ${message}`), { code: 'invalid_session' });
7
+ }
8
+
9
+ function compactBoundary(value) {
10
+ const metadata = field(value, 'compactMetadata');
11
+ const messages = field(metadata, 'preservedMessages'), segment = field(metadata, 'preservedSegment');
12
+ const head = stringField(segment, 'headUuid'), tail = stringField(segment, 'tailUuid');
13
+ const uuids = field(messages, 'uuids');
14
+ return {
15
+ anchor: stringField(messages, 'anchorUuid') ?? stringField(segment, 'anchorUuid'),
16
+ preserved: Array.isArray(uuids) ? uuids.filter((uuid) => typeof uuid === 'string') : [],
17
+ segment: head != null && tail != null ? [head, tail] : null,
18
+ };
19
+ }
20
+
21
+ /** Claude's active branch and compaction projection. Semantic reads may name
22
+ * degraded segments; stricter continuation/export reads never guess a graph. */
23
+ export class ClaudeReplayIndex {
24
+ nodes = [];
25
+ byUuid = new Map();
26
+ segmentAnchors = new Set();
27
+ lastPrompt = null;
28
+ linearLines = [];
29
+
30
+ clone() {
31
+ const graph = new ClaudeReplayIndex();
32
+ graph.nodes = this.nodes.map((node) => ({ ...node, compact: node.compact == null ? null : { ...node.compact,
33
+ preserved: [...node.compact.preserved], segment: node.compact.segment == null ? null : [...node.compact.segment] } }));
34
+ graph.byUuid = new Map(this.byUuid); graph.segmentAnchors = new Set(this.segmentAnchors);
35
+ graph.lastPrompt = this.lastPrompt == null ? null : [...this.lastPrompt]; graph.linearLines = [...this.linearLines];
36
+ return graph;
37
+ }
38
+
39
+ observe(lineIndex, value) {
40
+ const kind = stringField(value, 'type');
41
+ if (kind === 'last-prompt') {
42
+ const leaf = stringField(value, 'leafUuid');
43
+ if (leaf != null) this.lastPrompt = [leaf, field(value, 'explicit') === true];
44
+ return;
45
+ }
46
+ if (kind === 'fork-context-ref') {
47
+ const uuid = stringField(value, 'uuid');
48
+ if (uuid != null) this.segmentAnchors.add(uuid);
49
+ return;
50
+ }
51
+ if (!kinds.has(kind)) return;
52
+ this.linearLines.push(lineIndex);
53
+ const uuid = stringField(value, 'uuid');
54
+ if (uuid == null) return;
55
+ if (this.byUuid.has(uuid)) throw claudeReplayError(`duplicate uuid \`${uuid}\` in Claude transcript`);
56
+ const content = field(field(value, 'message'), 'content');
57
+ this.byUuid.set(uuid, this.nodes.length);
58
+ this.nodes.push({
59
+ lineIndex, uuid, kind, parent: stringField(value, 'parentUuid'),
60
+ sidechain: field(value, 'isSidechain') === true,
61
+ messageId: kind === 'assistant' ? stringField(field(value, 'message'), 'id') : null,
62
+ toolResult: kind === 'user' && Array.isArray(content) && content.some((block) => stringField(block, 'type') === 'tool_result'),
63
+ compact: kind === 'system' && stringField(value, 'subtype') === 'compact_boundary' ? compactBoundary(value) : null,
64
+ });
65
+ }
66
+
67
+ selectLines(fidelity) {
68
+ const lenient = fidelity === 'semantic', residue = [];
69
+ if (this.nodes.every((node) => !conversation(node))) return { lines: this.linearLines, residue };
70
+ if (lenient) this.#anchorDanglingParents(residue);
71
+ try { return { lines: this.#project(lenient, residue), residue }; }
72
+ catch (error) {
73
+ if (!lenient) throw error;
74
+ residue.push(`the Claude record graph could not be projected (${error.message}); every record was stitched in transcript order instead`);
75
+ return { lines: this.linearLines, residue };
76
+ }
77
+ }
78
+
79
+ #anchorDanglingParents(residue) {
80
+ const dangling = [];
81
+ for (const node of this.nodes) {
82
+ if (node.parent == null || this.byUuid.has(node.parent) || this.segmentAnchors.has(node.parent)) continue;
83
+ dangling.push(`\`${node.uuid}\` → \`${node.parent}\``);
84
+ node.parent = null;
85
+ }
86
+ if (dangling.length === 0) return;
87
+ const listed = dangling.slice(0, 8).join(', ') + (dangling.length > 8 ? `, and ${dangling.length - 8} more` : '');
88
+ residue.push(`${dangling.length} Claude record(s) reference a parentUuid absent from the transcript and were anchored as segment roots: ${listed}`);
89
+ }
90
+
91
+ #project(lenient, residue) {
92
+ const retained = this.nodes.map(() => true);
93
+ const boundary = this.nodes.findLastIndex((node) => node.compact != null);
94
+ if (boundary !== -1) {
95
+ const parents = lenient ? this.nodes.map((node) => node.parent) : null;
96
+ try { this.#applyCompaction(boundary, retained); }
97
+ catch (error) {
98
+ if (!lenient) throw error;
99
+ this.nodes.forEach((node, index) => { node.parent = parents[index]; });
100
+ retained.fill(true);
101
+ residue.push(`the latest Claude compact boundary could not be projected (${error.message}); no pre-compaction record was pruned from this view`);
102
+ }
103
+ }
104
+ const sidechainOnly = this.nodes.every((node, index) => !retained[index] || !conversation(node) || node.sidechain);
105
+ const explicit = this.lastPrompt?.[1] ? this.byUuid.get(this.lastPrompt[0]) : undefined;
106
+ const nonSidechain = this.nodes.findLastIndex((node, index) => retained[index] && !node.sidechain);
107
+ const sidechain = this.nodes.findLastIndex((node, index) => retained[index] && node.sidechain && conversation(node));
108
+ let active = explicit !== undefined && retained[explicit] ? explicit : nonSidechain !== -1 ? nonSidechain : sidechain;
109
+ if (active === -1) throw claudeReplayError('no Claude record remains after compaction');
110
+ const seeking = new Set();
111
+ while (!conversation(this.nodes[active])) {
112
+ if (seeking.has(active)) throw claudeReplayError('cycle while resolving active Claude leaf');
113
+ seeking.add(active);
114
+ active = this.#parentIndex(active, retained);
115
+ }
116
+ const children = new Map();
117
+ this.nodes.forEach((node, index) => {
118
+ if (node.parent == null) return;
119
+ if (!children.has(node.parent)) children.set(node.parent, []);
120
+ children.get(node.parent).push(index);
121
+ });
122
+ const segments = [this.#projectSegment(active, retained, sidechainOnly, lenient, children)];
123
+ if (lenient) {
124
+ for (const leaf of this.#severedLeaves(active, retained)) segments.push(this.#projectSegment(leaf, retained, sidechainOnly, lenient, children));
125
+ if (segments.length > 1) residue.push(`${segments.length} conversation segments were stitched in transcript order because the Claude record graph is severed`);
126
+ }
127
+ const firstLine = (segment) => segment.reduce((first, index) => Math.min(first, this.nodes[index].lineIndex), Infinity);
128
+ const ordered = [], placed = new Set();
129
+ for (const segment of segments.filter((part) => part.length > 0).sort((a, b) => firstLine(a) - firstLine(b))) {
130
+ for (const index of segment) {
131
+ if (placed.has(index)) continue;
132
+ placed.add(index);
133
+ ordered.push(index);
134
+ }
135
+ }
136
+ return this.#recoverChunks(ordered, retained).map((index) => this.nodes[index].lineIndex);
137
+ }
138
+
139
+ #projectSegment(leaf, retained, sidechainOnly, lenient, children) {
140
+ const reversed = [], seen = new Set();
141
+ let cursor = leaf;
142
+ while (cursor != null) {
143
+ if (seen.has(cursor)) throw claudeReplayError(`cycle in active Claude parentUuid chain at \`${this.nodes[cursor].uuid}\``);
144
+ seen.add(cursor);
145
+ reversed.push(cursor);
146
+ const node = this.nodes[cursor];
147
+ if (node.parent == null) cursor = null;
148
+ else if (this.byUuid.has(node.parent)) cursor = this.byUuid.get(node.parent);
149
+ else if (this.segmentAnchors.has(node.parent) || sidechainOnly) cursor = null;
150
+ else throw claudeReplayError(`active Claude record \`${node.uuid}\` has missing parentUuid \`${node.parent}\``);
151
+ if (cursor != null && !retained[cursor]) {
152
+ if (lenient) break;
153
+ throw claudeReplayError(`active Claude chain crosses an excluded compaction record from \`${node.uuid}\``);
154
+ }
155
+ }
156
+ reversed.reverse();
157
+ const descendants = [], frontier = [leaf];
158
+ for (let head = 0; head < frontier.length; head++) {
159
+ const parent = this.nodes[frontier[head]].uuid;
160
+ for (const index of children.get(parent) ?? []) {
161
+ const node = this.nodes[index];
162
+ if (!retained[index] || conversation(node) || seen.has(index) || node.parent !== parent) continue;
163
+ seen.add(index);
164
+ descendants.push(index);
165
+ frontier.push(index);
166
+ }
167
+ }
168
+ descendants.sort((a, b) => this.nodes[a].lineIndex - this.nodes[b].lineIndex);
169
+ for (const index of descendants) reversed.push(index);
170
+ return reversed;
171
+ }
172
+
173
+ #componentRoot(index, retained, roots) {
174
+ const seen = new Set(), path = [];
175
+ let cursor = index, root;
176
+ for (;;) {
177
+ if (roots.has(cursor)) { root = roots.get(cursor); break; }
178
+ if (seen.has(cursor)) { root = null; break; }
179
+ seen.add(cursor);
180
+ path.push(cursor);
181
+ const parent = this.byUuid.get(this.nodes[cursor].parent);
182
+ if (parent === undefined || !retained[parent]) { root = cursor; break; }
183
+ cursor = parent;
184
+ }
185
+ for (const member of path) roots.set(member, root);
186
+ return root;
187
+ }
188
+
189
+ #severedLeaves(active, retained) {
190
+ const roots = new Map(), activeRoot = this.#componentRoot(active, retained, roots), newest = new Map();
191
+ for (let index = 0; index < this.nodes.length; index++) {
192
+ if (!retained[index] || !conversation(this.nodes[index])) continue;
193
+ const root = this.#componentRoot(index, retained, roots);
194
+ if (root == null || root === activeRoot) continue;
195
+ const previous = newest.get(root);
196
+ if (previous === undefined || this.nodes[index].lineIndex > this.nodes[previous].lineIndex) newest.set(root, index);
197
+ }
198
+ return [...newest].sort(([a], [b]) => a - b).map(([, index]) => index);
199
+ }
200
+
201
+ #applyCompaction(boundary, retained) {
202
+ const compact = this.nodes[boundary].compact;
203
+ const preserved = compact.preserved.length > 0 ? compact.preserved : compact.segment != null ? this.#preservedSegment(...compact.segment) : [];
204
+ const preservedSet = new Set(preserved), removed = new Set();
205
+ for (const uuid of preserved) {
206
+ if (!this.byUuid.has(uuid)) throw claudeReplayError(`latest compact boundary references missing preserved uuid \`${uuid}\``);
207
+ }
208
+ for (let index = 0; index < boundary; index++) {
209
+ if (preservedSet.has(this.nodes[index].uuid)) continue;
210
+ removed.add(this.nodes[index].uuid);
211
+ retained[index] = false;
212
+ }
213
+ if (preserved.length === 0) return;
214
+ if (compact.anchor == null) throw claudeReplayError('preserved compact boundary is missing anchorUuid');
215
+ if (!this.byUuid.has(compact.anchor)) throw claudeReplayError(`latest compact boundary references missing anchor uuid \`${compact.anchor}\``);
216
+ const tail = preserved.at(-1);
217
+ let parent = compact.anchor;
218
+ for (const uuid of preserved) {
219
+ this.nodes[this.byUuid.get(uuid)].parent = parent;
220
+ parent = uuid;
221
+ }
222
+ for (const node of this.nodes) {
223
+ if (node.parent === compact.anchor && node.uuid !== preserved[0]) node.parent = tail;
224
+ }
225
+ for (const node of this.nodes) {
226
+ if (conversation(node) && removed.has(node.parent)) node.parent = tail;
227
+ }
228
+ }
229
+
230
+ #preservedSegment(head, tail) {
231
+ const reversed = [], seen = new Set();
232
+ let cursor = tail;
233
+ for (;;) {
234
+ if (seen.has(cursor)) throw claudeReplayError('cycle in compact preservedSegment');
235
+ seen.add(cursor);
236
+ const index = this.byUuid.get(cursor);
237
+ if (index === undefined) throw claudeReplayError(`compact preservedSegment references missing uuid \`${cursor}\``);
238
+ reversed.push(cursor);
239
+ if (cursor === head) return reversed.reverse();
240
+ const parent = this.nodes[index].parent;
241
+ if (parent == null) throw claudeReplayError(`compact preservedSegment tail \`${tail}\` does not reach head \`${head}\``);
242
+ cursor = parent;
243
+ }
244
+ }
245
+
246
+ #parentIndex(index, retained) {
247
+ const node = this.nodes[index];
248
+ if (node.parent == null) throw claudeReplayError(`Claude record \`${node.uuid}\` has no conversational ancestor`);
249
+ const parent = this.byUuid.get(node.parent);
250
+ if (parent === undefined) throw claudeReplayError(`Claude record \`${node.uuid}\` has missing parentUuid \`${node.parent}\``);
251
+ if (!retained[parent]) throw claudeReplayError(`Claude record \`${node.uuid}\` points into compacted-out history`);
252
+ return parent;
253
+ }
254
+
255
+ #recoverChunks(base, retained) {
256
+ const selected = new Set(base), replacements = new Map(), skipped = new Set(), groups = new Map();
257
+ base.forEach((index, position) => {
258
+ const id = this.nodes[index].messageId;
259
+ if (id == null) return;
260
+ if (!groups.has(id)) groups.set(id, { positions: [], chunks: [], results: [] });
261
+ groups.get(id).positions.push(position);
262
+ });
263
+ this.nodes.forEach((node, index) => {
264
+ const group = groups.get(node.messageId);
265
+ if (group && retained[index]) group.chunks.push(index);
266
+ if (!retained[index] || selected.has(index) || !node.toolResult) return;
267
+ const parent = this.byUuid.get(node.parent);
268
+ if (parent === undefined) return;
269
+ groups.get(this.nodes[parent].messageId)?.results.push(index);
270
+ });
271
+ for (const { positions, chunks, results } of groups.values()) {
272
+ for (const offset of positions.slice(1)) skipped.add(offset);
273
+ const byLine = (a, b) => this.nodes[a].lineIndex - this.nodes[b].lineIndex;
274
+ chunks.sort(byLine);
275
+ for (const result of results.sort(byLine)) chunks.push(result);
276
+ replacements.set(positions[0], chunks);
277
+ }
278
+ const result = [];
279
+ base.forEach((index, position) => {
280
+ if (replacements.has(position)) { for (const replacement of replacements.get(position)) result.push(replacement); }
281
+ else if (!skipped.has(position)) result.push(index);
282
+ });
283
+ return result;
284
+ }
285
+ }