@volter/supercode-interchange 0.1.22 → 0.1.24
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.
- package/LICENSE-diffy-MIT +23 -0
- package/README.md +422 -2
- package/channel-records.mjs +71 -0
- package/channel-session-counts.mjs +81 -0
- package/channels-read.mjs +90 -0
- package/corpus-audit-records.mjs +192 -0
- package/corpus-audit-render.mjs +47 -0
- package/corpus-audit-schema.mjs +89 -0
- package/corpus-audit.d.ts +16 -0
- package/corpus-audit.mjs +132 -0
- package/file-io.mjs +21 -2
- package/harness-settings.mjs +19 -0
- package/hermes-fire-links.mjs +135 -0
- package/hermes-fires.mjs +106 -0
- package/hermes-read-home.d.ts +3 -0
- package/hermes-read-home.mjs +235 -0
- package/hermes-read-store.mjs +49 -0
- package/hermes-read-validation.mjs +170 -0
- package/hermes-read-workflow.mjs +164 -0
- package/hermes-read-yaml.mjs +68 -0
- package/hermes-store.mjs +1 -1
- package/homes.mjs +2 -2
- package/index.d.ts +14 -1
- package/index.mjs +1 -1
- package/inspection-builtin-tools.json +261 -0
- package/inspection-config-fields.json +765 -0
- package/inspection-schemas.d.ts +4 -0
- package/inspection-schemas.mjs +117 -0
- package/job-notepad-read.mjs +97 -0
- package/jobs-control.mjs +250 -0
- package/jobs-read.mjs +198 -0
- package/jsonl.mjs +4 -3
- package/memory-show.mjs +187 -0
- package/metadata.mjs +45 -3
- package/native-json.mjs +46 -3
- package/ontology-contracts.d.ts +5 -0
- package/ontology-contracts.mjs +29 -0
- package/ontology-operations.json +198 -0
- package/ontology-schemas.json +7946 -0
- package/openclaw-jobs-read.mjs +117 -0
- package/openclaw-profile-home.mjs +142 -0
- package/orchestrator-read-records.mjs +112 -0
- package/orchestrator-store.mjs +21 -9
- package/package.json +59 -4
- package/parity-ledger.json +6984 -0
- package/parity-orchestration.json +5538 -0
- package/parity-report.d.ts +7 -0
- package/parity-report.mjs +46 -0
- package/preset-profiles.mjs +27 -0
- package/profile-command.mjs +32 -0
- package/profile-orchestrator-door.mjs +104 -0
- package/profile-paths.mjs +25 -0
- package/profiles-control.mjs +98 -0
- package/profiles-read.mjs +119 -0
- package/reduction-attribution.mjs +54 -0
- package/reduction-diff.mjs +165 -0
- package/reduction-log.mjs +177 -0
- package/reduction-view.mjs +145 -0
- package/retained-projection.mjs +9 -0
- package/routes-read.mjs +103 -0
- package/runs-read.mjs +224 -0
- package/session-activity.d.ts +23 -0
- package/session-activity.mjs +124 -0
- package/session-append-scan.mjs +27 -17
- package/session-branch-handoff.mjs +115 -0
- package/session-claude-graph.mjs +29 -7
- package/session-claude-index.mjs +732 -114
- package/session-claude-runtime.d.ts +28 -0
- package/session-claude-runtime.mjs +171 -6
- package/session-claude-scan-store.mjs +164 -0
- package/session-claude.mjs +9 -0
- package/session-codex-activity.d.ts +6 -0
- package/session-codex-activity.mjs +120 -0
- package/session-codex-window.mjs +24 -4
- package/session-config-scan.mjs +141 -0
- package/session-convert.d.ts +33 -0
- package/session-convert.mjs +179 -0
- package/session-emission-version.json +1 -0
- package/session-file.mjs +11 -1
- package/session-follower.mjs +17 -5
- package/session-fresh-id.mjs +15 -0
- package/session-hermes-import.mjs +61 -0
- package/session-hermes.mjs +1 -1
- package/session-import.mjs +37 -0
- package/session-inspect.d.ts +18 -0
- package/session-inspect.mjs +122 -0
- package/session-machine-tree.d.ts +33 -0
- package/session-machine-tree.mjs +274 -0
- package/session-materialize-files.mjs +78 -0
- package/session-materialize-opencode.mjs +137 -0
- package/session-materialize.mjs +186 -0
- package/session-message.mjs +85 -0
- package/session-model.d.ts +9 -0
- package/session-model.mjs +1 -0
- package/session-opencode-store.mjs +39 -3
- package/session-path.mjs +6 -3
- package/session-pi-content.mjs +7 -2
- package/session-read.d.ts +134 -2
- package/session-read.mjs +86 -9
- package/session-recorded-config.mjs +3 -2
- package/session-records.mjs +12 -6
- package/session-residue-store.mjs +90 -0
- package/session-residue.mjs +150 -0
- package/session-resume-instructions.mjs +1 -1
- package/session-saved-read.mjs +105 -0
- package/session-transfer.mjs +141 -0
- package/session-views.mjs +1 -0
- package/session-write-claude.mjs +135 -0
- package/session-write-codex.mjs +146 -0
- package/session-write-gemini.mjs +77 -0
- package/session-write-goose.mjs +99 -0
- package/session-write-grok.mjs +70 -0
- package/session-write-native.mjs +41 -0
- package/session-write-opencode.mjs +197 -0
- package/session-write-pi.mjs +130 -0
- package/session-write-residue.mjs +19 -0
- package/session-write-utils.mjs +77 -0
- package/session-write.d.ts +10 -0
- package/session-write.mjs +37 -0
- package/skill-toml.mjs +80 -0
- package/skills-control.mjs +193 -0
- package/skills-list.mjs +237 -0
- package/source-files.mjs +12 -2
- package/sqlite-reader.mjs +134 -18
- package/support-registry.json +402 -0
- package/support-report.mjs +69 -0
- package/trigger-records.mjs +77 -0
- package/triggers-read.mjs +67 -0
- package/workflow-read.mjs +165 -0
package/session-claude-index.mjs
CHANGED
|
@@ -15,8 +15,21 @@ import { sourceStat } from './store-registry.mjs';
|
|
|
15
15
|
import { isCompressedSessionPath, looksLikeSessionSqlite, sessionTextError } from './session-path.mjs';
|
|
16
16
|
import { detectSessionSource } from './session-source.mjs';
|
|
17
17
|
import { AppendScan, shortHash } from './session-append-scan.mjs';
|
|
18
|
+
import { ScanStore } from './session-claude-scan-store.mjs';
|
|
19
|
+
import { claudeBackgroundAgentFold } from './session-claude-runtime.mjs';
|
|
18
20
|
|
|
19
21
|
const REFERENCE = 'supercode.internal.read_index';
|
|
22
|
+
// A window read takes in at most this much transcript it has not read before: a longer one is read through over
|
|
23
|
+
// several reads, each answering how far it has read (`window.reading`), so that no read takes in a whole long file.
|
|
24
|
+
const SCAN_STEP_BYTES = 32 * 1024 * 1024;
|
|
25
|
+
// The messages a window read replays again as the transcript grows, at least (WindowAnswer's tail), and the prompts
|
|
26
|
+
// tried as the place to settle the replay before it, at most, on each try (#settleFrom, #advance).
|
|
27
|
+
const SETTLE_KEEP = 256, SETTLE_TRIES = 8, TAIL_RECORDS = 4096;
|
|
28
|
+
/** Marks are listed as a chain: the digest of the first n marks is chainMark of the digest of the first n - 1 and the
|
|
29
|
+
* n-th mark ('' for none), and a reader names the digest at every MARK_CHECKPOINT-th mark it holds. A read that names
|
|
30
|
+
* the marks it holds this way is answered the marks after the first place they differ (loadClaudeRecordWindow). */
|
|
31
|
+
export const MARK_CHECKPOINT = 1024;
|
|
32
|
+
export const chainMark = (digest, mark) => shortHash(`${digest}:${mark}`);
|
|
20
33
|
|
|
21
34
|
/** What one record is to the replay, with where it stands in the file. */
|
|
22
35
|
function recordFacts(line, value) {
|
|
@@ -84,7 +97,8 @@ export async function supportsClaudeReadIndex(path, { metrics } = {}) {
|
|
|
84
97
|
|
|
85
98
|
class ClaudeReadIndex {
|
|
86
99
|
#reader;
|
|
87
|
-
#
|
|
100
|
+
#lines = 0;
|
|
101
|
+
#rawRecord;
|
|
88
102
|
#groups = [];
|
|
89
103
|
#skeleton = [];
|
|
90
104
|
#meta = createSession('claude_code').meta;
|
|
@@ -93,50 +107,24 @@ class ClaudeReadIndex {
|
|
|
93
107
|
|
|
94
108
|
constructor(reader) { this.#reader = reader; }
|
|
95
109
|
get length() { return this.#skeleton.length; }
|
|
96
|
-
get rawRecordCount() { return this.#
|
|
97
|
-
|
|
98
|
-
async open(fidelity) {
|
|
99
|
-
const graph = new ClaudeReplayIndex(), residueRecords = [];
|
|
100
|
-
for await (const line of sessionFileLines(this.#reader)) {
|
|
101
|
-
const index = this.#records.length;
|
|
102
|
-
let record = { offset: line.offset, length: line.length, assistant: false, error: false, messageId: null };
|
|
103
|
-
if (nativeTrim(line.text) !== '') {
|
|
104
|
-
let value;
|
|
105
|
-
try { value = parseNativeJson(line.text); } catch { this.#parseErrors++; }
|
|
106
|
-
if (value !== undefined) {
|
|
107
|
-
captureClaudeMeta(value, this.#meta, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text);
|
|
108
|
-
graph.observe(index, value);
|
|
109
|
-
record = recordFacts(line, value);
|
|
110
|
-
if (claudeResidueKind(value) != null) residueRecords.push(index);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
this.#records.push(record);
|
|
114
|
-
}
|
|
115
|
-
const selection = graph.selectLines(fidelity); this.#residue = selection.residue;
|
|
116
|
-
for (const index of residueRecords) {
|
|
117
|
-
const text = await this.#raw(index), raw = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
118
|
-
const value = parseNativeJson(raw);
|
|
119
|
-
captureNativeResidue(this.#meta, 'claude_code', index, raw, value, claudeResidueKind(value));
|
|
120
|
-
}
|
|
121
|
-
for (const group of claudeGroups(selection.lines, (index) => this.#records[index])) await this.#addGroup(group);
|
|
122
|
-
reorderClaudeToolResults(this.#skeleton); ensureToolResultsPaired(this.#skeleton);
|
|
123
|
-
}
|
|
110
|
+
get rawRecordCount() { return this.#lines; }
|
|
124
111
|
|
|
125
|
-
async
|
|
126
|
-
|
|
127
|
-
|
|
112
|
+
async open(fidelity, metrics) {
|
|
113
|
+
let scan;
|
|
114
|
+
try {
|
|
115
|
+
scan = await scanOf(this.#reader.path, this.#reader);
|
|
116
|
+
const view = await scan.project(this.#reader, fidelity);
|
|
117
|
+
if (view.decodeError) throw view.decodeError;
|
|
118
|
+
this.#lines = view.lines; this.#parseErrors = view.parseErrors;
|
|
119
|
+
this.#groups = view.groups; this.#skeleton = view.skeleton; this.#residue = view.residue;
|
|
120
|
+
this.#meta = await scan.indexMeta(this.#reader, view);
|
|
121
|
+
this.#rawRecord = index => view.raw(index, this.#reader);
|
|
122
|
+
retainScan(this.#reader.path, scan, metrics);
|
|
123
|
+
} catch (error) { dropScan(this.#reader.path); throw error; }
|
|
128
124
|
}
|
|
129
125
|
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
async #addGroup(group) {
|
|
133
|
-
const groupId = this.#groups.length, decoded = await this.#decodeGroup(group);
|
|
134
|
-
this.#groups.push(group);
|
|
135
|
-
decoded.forEach((message, ordinal) => {
|
|
136
|
-
skeletonMessage(message).metadata[REFERENCE] = `${groupId}:${ordinal}`;
|
|
137
|
-
this.#skeleton.push(message);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
126
|
+
async #raw(index) { return this.#rawRecord(index); }
|
|
127
|
+
#decodeGroup(group) { return decodeClaudeGroup(index => this.#raw(index), group); }
|
|
140
128
|
|
|
141
129
|
itemCount(from, end) {
|
|
142
130
|
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(end) || from < 0 || end < from || end > this.length) throw new RangeError('invalid Claude message range');
|
|
@@ -189,166 +177,796 @@ class ClaudeReadIndex {
|
|
|
189
177
|
* are verified before the result leaves the scope; any change refuses it. */
|
|
190
178
|
export async function withClaudeReadIndex(path, work, { fidelity = 'byte_lossless', metrics } = {}) {
|
|
191
179
|
if (!['byte_lossless', 'value_lossless', 'semantic'].includes(fidelity)) throw new TypeError(`unknown session fidelity: ${fidelity}`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
try {
|
|
181
|
+
return await withSessionFile(path, async (reader) => {
|
|
182
|
+
const index = new ClaudeReadIndex(reader); await index.open(fidelity, metrics);
|
|
183
|
+
return work(index);
|
|
184
|
+
}, metrics);
|
|
185
|
+
} catch (error) { dropScan(path); throw error; }
|
|
196
186
|
}
|
|
197
187
|
|
|
198
188
|
const REPLAYED = new Set(['user', 'assistant', 'attachment', 'system']);
|
|
199
189
|
|
|
200
190
|
/** One line taken into a scan's state: its place among the file's lines, its header facts, its node in the graph,
|
|
201
191
|
* what it changes of the recorded profile and, for a record the replay can select, where it stands and the hash of
|
|
202
|
-
* its bytes.
|
|
192
|
+
* its bytes. A scan (`state.store`) keeps its records, nodes and residue on disk; a line read for one answer alone
|
|
193
|
+
* keeps them in that answer's state. */
|
|
203
194
|
function observeClaudeLine(state, line) {
|
|
204
195
|
const index = state.lines++;
|
|
205
196
|
if (nativeTrim(line.text) === '') return;
|
|
206
197
|
let value;
|
|
207
198
|
try { value = parseNativeJson(line.text); } catch { state.parseErrors++; return; }
|
|
208
199
|
captureClaudeMeta(value, state.meta, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text);
|
|
200
|
+
const nodes = state.graph.nodes.length;
|
|
209
201
|
state.graph.observe(index, value);
|
|
202
|
+
if (state.store && state.graph.nodes.length > nodes) state.store.addNode(state.graph.nodes.at(-1));
|
|
210
203
|
state.profile.record(index, value);
|
|
204
|
+
state.agents.observe(value, index + 1);
|
|
211
205
|
if (typeof value?.version === 'string') state.version = value.version;
|
|
212
|
-
if (REPLAYED.has(stringField(value, 'type')))
|
|
206
|
+
if (REPLAYED.has(stringField(value, 'type'))) {
|
|
207
|
+
const facts = { ...recordFacts(line, value), hash: shortHash(line.bytes) };
|
|
208
|
+
state.records.set(index, facts); state.store?.addRecord(index, facts);
|
|
209
|
+
}
|
|
210
|
+
const kind = claudeResidueKind(value);
|
|
211
|
+
if (kind != null) {
|
|
212
|
+
const captured = { native_residue_source: null, native_residue: [] };
|
|
213
|
+
captureNativeResidue(captured, 'claude_code', index, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text, value, kind);
|
|
214
|
+
if (captured.native_residue.length) {
|
|
215
|
+
// A scan keeps where each residue record stands, not its text: an options load reads it again (indexMeta).
|
|
216
|
+
const entry = captured.native_residue[0];
|
|
217
|
+
if (state.store) { delete entry.raw; state.store.addResidue(index, line.offset, line.length, entry); }
|
|
218
|
+
else state.nativeResidue.set(index, { entry });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
/** A transcript's complete lines as one pass over them leaves them, kept between reads of the same file: the next
|
|
216
|
-
* read takes only the lines written since (`extend`).
|
|
217
|
-
*
|
|
224
|
+
* read takes only the lines written since (`extend`). What it knows of each record is on disk (ScanStore); in memory
|
|
225
|
+
* it holds the records its next read replays (`graph` and `records`: the settled record and every one after it, or
|
|
226
|
+
* none while nothing is settled) and facts of a size the transcript's length does not set. */
|
|
218
227
|
class ClaudeRecordScan extends AppendScan {
|
|
219
|
-
|
|
228
|
+
// A history scan (loadClaudeHistoryWindow) replays every segment before the latest compaction; any other replays
|
|
229
|
+
// from the latest compaction on.
|
|
230
|
+
history = false;
|
|
231
|
+
store = new ScanStore(); records = new Map(); indexMetaCache = null; lines = 0; parseErrors = 0; version = null;
|
|
220
232
|
graph = new ClaudeReplayIndex(); meta = createSession('claude_code').meta; profile = recordedProfileFold('claude_code');
|
|
221
|
-
|
|
233
|
+
// Every record, before any compaction the replay applies: a background agent launched long before is still known.
|
|
234
|
+
agents = claudeBackgroundAgentFold();
|
|
235
|
+
// Whether the last pass stopped before the file's last whole line (SCAN_STEP_BYTES): the scan is not read through.
|
|
236
|
+
behind = false;
|
|
237
|
+
// The replay's settled prefix (settle), the tail's decoded groups and the last window answer made over it.
|
|
238
|
+
settled = null; regionMade = new Map(); answer = null;
|
|
222
239
|
|
|
223
|
-
/**
|
|
224
|
-
* only advance `lines`: charging them as retained records made two long, mostly-progress transcripts evict one
|
|
225
|
-
* another on every alternating read, though their replay state together was far below the bound. */
|
|
240
|
+
/** What the scan holds in memory, counted as entries: the scans kept between reads are bounded by them. */
|
|
226
241
|
get retainedRecords() {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
+ (this.view?.skeleton.length ?? 0) + (this.view?.groups.length ?? 0);
|
|
242
|
+
return 1 + this.agents.retainedEntries + this.records.size + this.graph.nodes.length + this.graph.segmentAnchors.size + this.profile.history.length
|
|
243
|
+
+ (this.indexMetaCache?.meta.native_residue.length ?? 0) + this.regionMade.size + (this.settled?.checkpoints.length ?? 0) + (this.settled?.dangling.size ?? 0)
|
|
244
|
+
+ (this.answer?.skeleton.length ?? 0) + (this.answer?.groups.length ?? 0);
|
|
231
245
|
}
|
|
232
246
|
|
|
233
|
-
/** The lines completed since the last pass
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
247
|
+
/** The lines completed since the last pass, at most `budget` bytes of them (a window read takes SCAN_STEP_BYTES a
|
|
248
|
+
* pass, so a long transcript is read through over several passes and no one pass reads it all). A last line with
|
|
249
|
+
* no LF yet stays outside the scan: `view` reads it. */
|
|
250
|
+
async extend(reader, budget = Infinity) {
|
|
251
|
+
const from = this.bytes; this.behind = false;
|
|
252
|
+
this.store.begin();
|
|
253
|
+
let failed = true;
|
|
254
|
+
try {
|
|
255
|
+
for await (const line of sessionFileLines(reader, this.bytes)) {
|
|
256
|
+
if (!line.terminated) break;
|
|
257
|
+
if (this.bytes - from >= budget) { this.behind = true; break; }
|
|
258
|
+
observeClaudeLine(this, line); this.took(line.bytes, line.offset + line.length); this.bytes = line.offset + line.length;
|
|
259
|
+
}
|
|
260
|
+
failed = false;
|
|
261
|
+
} finally { this.store.end(failed); }
|
|
262
|
+
await this.settle(reader, from, this.behind);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The whole replay's graph and records, read from disk: what a whole replay (project) and settling read, and let go
|
|
266
|
+
* when it is done. */
|
|
267
|
+
#whole() {
|
|
268
|
+
const graph = new ClaudeReplayIndex();
|
|
269
|
+
for (const node of this.store.allNodes()) { graph.byUuid.set(node.uuid, graph.nodes.length); graph.nodes.push(node); }
|
|
270
|
+
const records = this.store.allRecords();
|
|
271
|
+
graph.linearLines = [...records.keys()];
|
|
272
|
+
graph.segmentAnchors = new Set(this.graph.segmentAnchors); graph.lastPrompt = this.graph.lastPrompt == null ? null : [...this.graph.lastPrompt];
|
|
273
|
+
return { graph, records };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A group's decoded skeletons: the tail's, else those on disk, else decoded from its records (and kept on disk). */
|
|
277
|
+
async #made(mark, group, raw, reader) {
|
|
278
|
+
let decoded = this.regionMade.get(mark) ?? this.store.madeOf(mark);
|
|
279
|
+
if (!decoded) {
|
|
280
|
+
decoded = (await decodeClaudeGroup((index) => raw(index, reader), group)).map((message) => ({ model: Object.hasOwn(message.metadata, 'model') ? message.metadata.model : null, message: skeletonMessage(message) }));
|
|
281
|
+
this.store.addMade(mark, decoded);
|
|
239
282
|
}
|
|
240
|
-
|
|
283
|
+
return decoded;
|
|
241
284
|
}
|
|
242
285
|
|
|
243
|
-
/** The replay of the file as `reader` holds it: its messages in order, each as its skeleton with the mark of
|
|
244
|
-
* records that make it.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
286
|
+
/** The replay of the file as `reader` holds it, whole: its messages in order, each as its skeleton with the mark of
|
|
287
|
+
* the records that make it. Read from disk and let go after use: the scan keeps no whole replay. A scan not read
|
|
288
|
+
* through (`committed`) is replayed as far as it has read. */
|
|
289
|
+
async project(reader, fidelity, committed = false) {
|
|
290
|
+
const key = committed ? `${fidelity}:${this.bytes}:committed` : `${fidelity}:${this.bytes}:${reader.size}:${reader.size > this.bytes ? reader.fingerprint.mtime_ns : "complete"}`;
|
|
291
|
+
const whole = this.#whole();
|
|
292
|
+
const state = { lines: this.lines, parseErrors: this.parseErrors, version: this.version, records: new Map(), nativeResidue: new Map(),
|
|
293
|
+
graph: whole.graph, meta: cloneNativeJson(this.meta), profile: this.profile.clone(), agents: claudeBackgroundAgentFold() };
|
|
250
294
|
// The file's last line while it has no LF yet is read for this answer alone. Cut inside a character, it is a
|
|
251
295
|
// line still being written: counted as one that does not parse, as a cut record is.
|
|
252
|
-
if (reader.size > this.bytes) {
|
|
296
|
+
if (!committed && reader.size > this.bytes) {
|
|
253
297
|
const bytes = await reader.read(this.bytes, reader.size - this.bytes); let text = null;
|
|
254
|
-
try { text = decodeSessionUtf8(bytes); } catch { state.lines++; state.parseErrors++; }
|
|
298
|
+
try { text = decodeSessionUtf8(bytes); } catch (error) { state.lines++; state.parseErrors++; state.decodeError = error; }
|
|
255
299
|
if (text != null) observeClaudeLine(state, { offset: this.bytes, length: bytes.length, text, bytes });
|
|
256
300
|
}
|
|
257
|
-
const recordOf = (index) => state.records.get(index) ??
|
|
301
|
+
const recordOf = (index) => state.records.get(index) ?? whole.records.get(index);
|
|
258
302
|
const raw = async (index, at) => { const record = recordOf(index); return decodeSessionUtf8(await at.read(record.offset, record.length)); };
|
|
259
|
-
const selection = state.graph.selectLines(fidelity), groups = [], sizes = [], skeleton = [],
|
|
303
|
+
const selection = state.graph.selectLines(fidelity, { history: this.history }), groups = [], sizes = [], skeleton = [], marks = new Map(), models = new Map(), made = new Map();
|
|
260
304
|
for (const group of claudeGroups(selection.lines, recordOf)) {
|
|
261
305
|
const id = groups.length, records = group.map(recordOf), mark = shortHash(records.map((record) => record.hash).join(','));
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
made.forEach(({ message, model }, ordinal) => {
|
|
306
|
+
const decoded = made.get(mark) ?? await this.#made(mark, group, raw, reader);
|
|
307
|
+
made.set(mark, decoded); groups.push(group); sizes.push(records.reduce((sum, record) => sum + record.length, 0));
|
|
308
|
+
decoded.forEach(({ message, model }, ordinal) => {
|
|
266
309
|
const placed = { ...message, metadata: Object.create(null) }; placed.metadata[REFERENCE] = `${id}:${ordinal}`;
|
|
267
310
|
marks.set(placed, shortHash(`${mark}:${ordinal}`)); if (model != null) models.set(placed, model);
|
|
268
311
|
skeleton.push(placed);
|
|
269
312
|
});
|
|
270
313
|
}
|
|
271
|
-
this.skeletons = skeletons;
|
|
272
314
|
reorderClaudeToolResults(skeleton); ensureToolResultsPaired(skeleton);
|
|
273
315
|
// A result the pairing pass made up has no record: its mark is of what it says.
|
|
274
|
-
return
|
|
316
|
+
return { key, raw, groups, sizes, skeleton, marks: skeleton.map((message) => marks.get(message) ?? shortHash(JSON.stringify(messageJson(message)))),
|
|
275
317
|
model: models.get(skeleton.findLast((message) => models.has(message))) ?? null, residue: selection.residue, meta: state.meta, lines: state.lines,
|
|
276
|
-
parseErrors: state.parseErrors, version: state.version, history: state.profile.history
|
|
318
|
+
parseErrors: state.parseErrors, version: state.version, history: state.profile.history, nativeResidue: [...state.nativeResidue],
|
|
319
|
+
decodeError: state.decodeError ?? null, agents: this.agents.finish({ also: state.agents }), graph: whole.graph, records: whole.records, made };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Options loads historically include native metadata residue even when they
|
|
323
|
+
// return no messages. Keep its original post-header ordering, rather than
|
|
324
|
+
// folding it before a later provenance envelope restores metadata.
|
|
325
|
+
async indexMeta(reader, view) {
|
|
326
|
+
const key = view.key;
|
|
327
|
+
if (this.indexMetaCache?.key === key) return this.indexMetaCache.meta;
|
|
328
|
+
const meta = cloneNativeJson(view.meta);
|
|
329
|
+
if (meta.native_residue_source == null || meta.native_residue_source === 'claude_code') {
|
|
330
|
+
let any = false;
|
|
331
|
+
for (const [, { entry, offset, length }] of [...this.store.residues(), ...view.nativeResidue]) {
|
|
332
|
+
const placed = cloneNativeJson(entry); any = true;
|
|
333
|
+
if (offset != null) { const text = decodeSessionUtf8(await reader.read(offset, length)); placed.raw = text.endsWith('\n') ? text.slice(0, -1) : text; }
|
|
334
|
+
meta.native_residue.push(placed);
|
|
335
|
+
}
|
|
336
|
+
if (any) meta.native_residue_source = 'claude_code';
|
|
337
|
+
}
|
|
338
|
+
this.indexMetaCache = { key, meta };
|
|
339
|
+
return meta;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The replay a window read answers from, made without replaying the whole transcript on every read. The replay is
|
|
344
|
+
* held as a settled prefix (every message up to and including a user record, its places, marks and groups on disk,
|
|
345
|
+
* the digests of the chain of its marks in memory) and the tail after it, which alone is replayed again as the
|
|
346
|
+
* transcript grows: the tail's records are replayed as a graph of their own, rooted at that record, and the prefix is
|
|
347
|
+
* kept as it was. That is the whole replay's answer whenever nothing the tail adds reaches back past the record
|
|
348
|
+
* (settledAnswer says what is checked); anything that does (a branch from an older message, a compaction, a record
|
|
349
|
+
* whose parent is not in the tail, a tool result for an older call, a newer chunk of an older message) is replayed
|
|
350
|
+
* whole (project) and settled anew. In a history replay a compaction reaches back past nothing: the replay keeps the
|
|
351
|
+
* history before it, and its boundary (a record with no parent) opens a segment after every one before it, so a tail
|
|
352
|
+
* that holds one replays alone as any other does. The settled prefix moves on as the tail grows (advance), so each
|
|
353
|
+
* read replays a bounded tail, and the scan holds in memory only that tail.
|
|
354
|
+
*/
|
|
355
|
+
/** Why this read replays the whole transcript: said in its answer (`window.replay`) and counted. */
|
|
356
|
+
#whole_(reason) { this.wholeReason = reason; return null; }
|
|
357
|
+
|
|
358
|
+
async window(reader, fidelity, committed = false) {
|
|
359
|
+
if (fidelity !== 'semantic') return wholeAnswer(await this.project(reader, fidelity, committed), this.store, 'a fidelity other than semantic');
|
|
360
|
+
const key = committed ? `${fidelity}:${this.bytes}:committed` : `${fidelity}:${this.bytes}:${reader.size}:${reader.size > this.bytes ? reader.fingerprint.mtime_ns : "complete"}`;
|
|
361
|
+
if (this.answer?.key === key) return this.answer;
|
|
362
|
+
this.wholeReason = this.unsettled ? 'no place to settle the replay' : 'nothing settled yet';
|
|
363
|
+
let answer = this.settled == null ? null : await this.#settledAnswer(reader, key, committed);
|
|
364
|
+
if (answer == null) { const reason = this.wholeReason; answer = this.#settleFrom(await this.project(reader, fidelity, committed)); answer.replay = { whole: true, reason }; }
|
|
365
|
+
else { this.#advance(answer); answer.replay = { whole: false, reason: null }; }
|
|
366
|
+
// only an answer over a settled prefix is kept: a whole one is let go with its read
|
|
367
|
+
this.answer = this.settled == null ? null : answer;
|
|
368
|
+
return answer;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The tail after the settled prefix replayed alone, with the transcript's unterminated last line for this answer;
|
|
372
|
+
* null when the whole transcript must be replayed instead. */
|
|
373
|
+
async #settledAnswer(reader, key, committed) {
|
|
374
|
+
const settled = this.settled, zone = this.graph, store = this.store;
|
|
375
|
+
// A fork-context reference written since changes which parents the whole replay finds absent.
|
|
376
|
+
if (zone.segmentAnchors.size !== settled.anchors) return this.#whole_("fork references written since");
|
|
377
|
+
// The tail's graph: the settled record as its root, and every record after it.
|
|
378
|
+
const tail = new ClaudeReplayIndex(), root = zone.nodes[0];
|
|
379
|
+
tail.segmentAnchors = new Set(zone.segmentAnchors);
|
|
380
|
+
tail.lastPrompt = zone.lastPrompt == null ? null : [...zone.lastPrompt];
|
|
381
|
+
tail.byUuid.set(root.uuid, 0); tail.nodes.push({ ...root, parent: null });
|
|
382
|
+
for (let index = 1; index < zone.nodes.length; index++) {
|
|
383
|
+
const node = zone.nodes[index];
|
|
384
|
+
tail.byUuid.set(node.uuid, tail.nodes.length); tail.nodes.push({ ...node });
|
|
385
|
+
}
|
|
386
|
+
tail.linearLines = zone.linearLines.slice(firstAbove(zone.linearLines, settled.lineIndex - 1));
|
|
387
|
+
const state = { lines: this.lines, parseErrors: this.parseErrors, version: this.version, records: new Map(), nativeResidue: new Map(),
|
|
388
|
+
graph: tail, meta: cloneNativeJson(this.meta), profile: this.profile.clone(), agents: claudeBackgroundAgentFold() };
|
|
389
|
+
if (!committed && reader.size > this.bytes) {
|
|
390
|
+
const bytes = await reader.read(this.bytes, reader.size - this.bytes); let text = null;
|
|
391
|
+
try { text = decodeSessionUtf8(bytes); } catch (error) { state.lines++; state.parseErrors++; state.decodeError = error; }
|
|
392
|
+
if (text != null) {
|
|
393
|
+
// A record that repeats a uuid the whole replay holds is refused by it, not by the tail's graph.
|
|
394
|
+
try { const uuid = stringField(parseNativeJson(text), 'uuid'); if (uuid != null && store.hasUuid(uuid)) return this.#whole_("a record repeats a uuid"); } catch { /* counted below */ }
|
|
395
|
+
try { observeClaudeLine(state, { offset: this.bytes, length: bytes.length, text, bytes }); } catch { return this.#whole_("a record repeats a uuid"); }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// What the tail adds must not reach back past the settled record.
|
|
399
|
+
for (let at = 1; at < tail.nodes.length; at++) {
|
|
400
|
+
const node = tail.nodes[at];
|
|
401
|
+
// a record a parent absent from the whole replay was waiting for would join what it anchored as a root
|
|
402
|
+
if (!this.history && (node.compact != null || node.parent == null || settled.dangling.has(node.uuid))) return this.#whole_("a compaction, a new root or an absent parent in the tail");
|
|
403
|
+
if (settled.dangling.has(node.uuid)) return this.#whole_("a record an absent parent was waiting for");
|
|
404
|
+
// in a history replay a compaction's boundary opens the segment after every one before it; any other record with
|
|
405
|
+
// no parent is a root the whole replay stitches in as a severed segment
|
|
406
|
+
if (node.parent == null) { if (node.compact == null) return this.#whole_("a new root in the tail"); continue; }
|
|
407
|
+
if (!tail.byUuid.has(node.parent) && !zone.segmentAnchors.has(node.parent)) return this.#whole_("a record whose parent is older than the settled prefix");
|
|
408
|
+
if (node.messageId != null && (store.firstLineOf(node.messageId) ?? node.lineIndex) <= settled.lineIndex) return this.#whole_("a message begun before the settled prefix");
|
|
409
|
+
}
|
|
410
|
+
if (tail.lastPrompt?.[1] && !tail.byUuid.has(tail.lastPrompt[0]) && store.hasUuid(tail.lastPrompt[0])) return this.#whole_("the last prompt names a record older than the settled prefix");
|
|
411
|
+
const selection = tail.selectLines('semantic', { history: this.history });
|
|
412
|
+
if (selection.residue.length) return this.#whole_("the tail does not replay alone");
|
|
413
|
+
const recordOf = (index) => state.records.get(index) ?? this.records.get(index);
|
|
414
|
+
const raw = async (index, at) => { const record = recordOf(index) ?? store.recordAt(index); return decodeSessionUtf8(await at.read(record.offset, record.length)); };
|
|
415
|
+
const made = new Map(), groups = [], sizes = [], skeleton = [], marks = new Map(), models = new Map(), base = settled.groups;
|
|
416
|
+
// The settled record's group opens the tail's replay, alone: it is the settled prefix's last, already placed.
|
|
417
|
+
const listed = [...claudeGroups(selection.lines, recordOf)];
|
|
418
|
+
if (listed[0]?.length !== 1 || listed[0][0] !== root.lineIndex) return this.#whole_("the tail does not replay alone");
|
|
419
|
+
listed.shift();
|
|
420
|
+
for (const group of listed) {
|
|
421
|
+
const id = base + groups.length, records = group.map(recordOf), mark = shortHash(records.map((record) => record.hash).join(','));
|
|
422
|
+
const decoded = made.get(mark) ?? await this.#made(mark, group, raw, reader);
|
|
423
|
+
made.set(mark, decoded); groups.push(group); sizes.push(records.reduce((sum, record) => sum + record.length, 0));
|
|
424
|
+
decoded.forEach(({ message, model }, ordinal) => {
|
|
425
|
+
const placed = { ...message, metadata: Object.create(null) }; placed.metadata[REFERENCE] = `${id}:${ordinal}`;
|
|
426
|
+
marks.set(placed, shortHash(`${mark}:${ordinal}`)); if (model != null) models.set(placed, model);
|
|
427
|
+
skeleton.push(placed);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// The tail's tool results answer only its own calls, and its calls are none the prefix made or answered: so the
|
|
431
|
+
// whole replay's pairing and ordering of results changes nothing in the prefix, and in the tail does what it does
|
|
432
|
+
// to the tail alone.
|
|
433
|
+
for (const message of skeleton) {
|
|
434
|
+
if (message.role === 'tool' && message.tool_call_id != null && message.tool_call_id !== '' && store.tool(message.tool_call_id)?.call) return this.#whole_("a tool result for an older call");
|
|
435
|
+
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '' && store.tool(call.id) != null) return this.#whole_("a tool call naming an older one");
|
|
436
|
+
}
|
|
437
|
+
reorderClaudeToolResults(skeleton); ensureToolResultsPaired(skeleton);
|
|
438
|
+
this.regionMade = made;
|
|
439
|
+
return new WindowAnswer({ key, raw, settled, store, groups, sizes, skeleton, made, tailNodes: tail.nodes.slice(1), committed: zone.nodes.length - 1,
|
|
440
|
+
marks: skeleton.map((message) => marks.get(message) ?? shortHash(JSON.stringify(messageJson(message)))),
|
|
441
|
+
model: models.get(skeleton.findLast((message) => models.has(message))) ?? settled.model, residue: [...settled.residue], meta: state.meta, lines_read: state.lines,
|
|
442
|
+
parseErrors: state.parseErrors, version: state.version, history: state.profile.history, decodeError: state.decodeError ?? null, agents: this.agents.finish({ also: state.agents }) });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** A whole replay (project's view) as a window answer, its prefix settled where it can be: the prefix is written to
|
|
446
|
+
* disk and the scan keeps in memory only the records after the settled one. */
|
|
447
|
+
#settleFrom(view) {
|
|
448
|
+
const graph = view.graph, store = this.store;
|
|
449
|
+
this.settled = null; this.regionMade = new Map();
|
|
450
|
+
const groupOf = (message) => { const reference = message.metadata[REFERENCE]; return reference === undefined ? null : Number(reference.split(':')[0]); };
|
|
451
|
+
const markOf = (group) => shortHash(group.map((index) => view.records.get(index).hash).join(','));
|
|
452
|
+
// a replay from the latest compaction on settles after it, at no record it keeps; a history replay anywhere
|
|
453
|
+
const boundary = this.history ? null : graph.nodes.findLast((node) => node.compact != null) ?? null;
|
|
454
|
+
const kept = new Set(boundary == null ? [] : [boundary.compact.anchor, ...boundary.compact.preserved, ...(boundary.compact.segment ?? [])]);
|
|
455
|
+
const lastLine = new Map(); view.groups.forEach((group, id) => lastLine.set(id, group.reduce((max, index) => Math.max(max, index), -1)));
|
|
456
|
+
// The parents the whole replay finds absent (anchored as roots, as its residue says): a record that later brings
|
|
457
|
+
// one is replayed whole again. Its residue stands for every tail replayed against this prefix.
|
|
458
|
+
const dangling = new Set();
|
|
459
|
+
for (const node of graph.nodes) if (node.parent != null && !graph.byUuid.has(node.parent) && !graph.segmentAnchors.has(node.parent)) dangling.add(node.parent);
|
|
460
|
+
let cut = null;
|
|
461
|
+
// A replay that fell back to every record in transcript order is not a graph a tail can be replayed against.
|
|
462
|
+
if (!view.decodeError && !view.residue.some((line) => line.startsWith('the Claude record graph could not be projected'))) {
|
|
463
|
+
// the last of the skeleton's groups past which SETTLE_KEEP messages stand (half of a shorter replay's) is where
|
|
464
|
+
// the search starts
|
|
465
|
+
let position = view.skeleton.length - Math.min(SETTLE_KEEP, Math.floor(view.skeleton.length / 2)), tries = 0;
|
|
466
|
+
for (; position > 0 && cut == null && tries < SETTLE_TRIES; position--) {
|
|
467
|
+
const id = groupOf(view.skeleton[position - 1]);
|
|
468
|
+
if (id == null || view.groups[id].length !== 1) continue;
|
|
469
|
+
const nodeIndex = nodeAt(graph.nodes, view.groups[id][0]), node = nodeIndex === undefined ? null : graph.nodes[nodeIndex];
|
|
470
|
+
if (!settles(node, this.lines, boundary, kept)) continue;
|
|
471
|
+
tries++;
|
|
472
|
+
if (groupOf(view.skeleton[position] ?? { metadata: {} }) === id) continue; // the record's group ends here
|
|
473
|
+
if (this.#cutHolds(view.skeleton, view.groups, position, id, node, lastLine, view.records) && this.#rooted(node, graph.nodes.slice(nodeIndex + 1), dangling, graph.segmentAnchors)) cut = { position, id, nodeIndex, node };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
this.unsettled = cut == null;
|
|
477
|
+
if (cut == null) {
|
|
478
|
+
// nothing settles: every read replays the whole transcript from disk, and the scan keeps no record in memory
|
|
479
|
+
this.graph = zoneOf(this.graph, []); this.records = new Map();
|
|
480
|
+
return wholeAnswer(view, store);
|
|
481
|
+
}
|
|
482
|
+
const settled = { lineIndex: cut.node.lineIndex, uuid: cut.node.uuid, length: 0, groups: 0, digest: '', checkpoints: [], model: null,
|
|
483
|
+
residue: [...view.residue], dangling, anchors: graph.segmentAnchors.size };
|
|
484
|
+
store.batch(() => {
|
|
485
|
+
store.clearSettled();
|
|
486
|
+
for (let id = 0; id <= cut.id; id++) {
|
|
487
|
+
store.addGroup(id, view.groups[id], view.sizes[id]); settled.groups++;
|
|
488
|
+
for (const { model } of view.made.get(markOf(view.groups[id])) ?? []) if (model != null) settled.model = model;
|
|
489
|
+
}
|
|
490
|
+
appendSettled(settled, store, view.skeleton.slice(0, cut.position), view.marks.slice(0, cut.position));
|
|
491
|
+
});
|
|
492
|
+
this.settled = settled;
|
|
493
|
+
const groups = view.groups.slice(cut.id + 1), skeleton = view.skeleton.slice(cut.position);
|
|
494
|
+
for (const group of groups) { const mark = markOf(group), decoded = view.made.get(mark); if (decoded) this.regionMade.set(mark, decoded); }
|
|
495
|
+
// in memory, the settled record and every record after it
|
|
496
|
+
this.graph = zoneOf(graph, graph.nodes.slice(cut.nodeIndex));
|
|
497
|
+
this.records = new Map([...view.records].filter(([line]) => line >= cut.node.lineIndex));
|
|
498
|
+
return new WindowAnswer({ key: view.key, raw: view.raw, settled, store, groups, sizes: view.sizes.slice(cut.id + 1), skeleton, made: this.regionMade,
|
|
499
|
+
tailNodes: this.graph.nodes.slice(1), committed: this.graph.nodes.length - 1, marks: view.marks.slice(cut.position), model: view.model, residue: view.residue,
|
|
500
|
+
meta: view.meta, lines_read: view.lines, parseErrors: view.parseErrors, version: view.version, history: view.history, decodeError: view.decodeError, agents: view.agents });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The tail's prefix up to a later user record, settled when the tail has grown past twice SETTLE_KEEP messages or
|
|
504
|
+
* TAIL_RECORDS records (records that make no message, as progress records, are replayed too). */
|
|
505
|
+
#advance(answer) {
|
|
506
|
+
const settled = this.settled, store = this.store;
|
|
507
|
+
if (answer.skeleton.length <= 2 * SETTLE_KEEP && answer.tailNodes.length <= TAIL_RECORDS) return;
|
|
508
|
+
const base = settled.groups, groupOf = (message) => { const reference = message.metadata[REFERENCE]; return reference === undefined ? null : Number(reference.split(':')[0]) - base; };
|
|
509
|
+
const lastLine = new Map(); answer.groups.forEach((group, id) => lastLine.set(id, group.reduce((max, index) => Math.max(max, index), -1)));
|
|
510
|
+
let tries = 0;
|
|
511
|
+
for (let position = answer.skeleton.length - Math.min(SETTLE_KEEP, Math.floor(answer.skeleton.length / 2)); position > 0 && tries < SETTLE_TRIES; position--) {
|
|
512
|
+
const id = groupOf(answer.skeleton[position - 1]);
|
|
513
|
+
if (id == null || answer.groups[id].length !== 1) continue;
|
|
514
|
+
const at = answer.tailNodes.findIndex((node) => node.lineIndex === answer.groups[id][0]);
|
|
515
|
+
if (at === -1 || at >= answer.committed) continue;
|
|
516
|
+
const node = answer.tailNodes[at];
|
|
517
|
+
if (!settles(node, this.lines, null, new Set())) continue;
|
|
518
|
+
tries++;
|
|
519
|
+
if (groupOf(answer.skeleton[position] ?? { metadata: {} }) === id) continue;
|
|
520
|
+
if (!this.#cutHolds(answer.skeleton, answer.groups, position, id, node, lastLine, this.records, base)
|
|
521
|
+
|| !this.#rooted(node, answer.tailNodes.slice(at + 1, answer.committed), settled.dangling, this.graph.segmentAnchors)) continue;
|
|
522
|
+
store.batch(() => {
|
|
523
|
+
for (let group = 0; group <= id; group++) {
|
|
524
|
+
const records = answer.groups[group], mark = shortHash(records.map((index) => this.records.get(index).hash).join(','));
|
|
525
|
+
store.addGroup(settled.groups++, records, answer.sizes[group]);
|
|
526
|
+
const decoded = answer.made.get(mark); this.regionMade.delete(mark);
|
|
527
|
+
for (const { model } of decoded ?? []) if (model != null) settled.model = model;
|
|
528
|
+
}
|
|
529
|
+
appendSettled(settled, store, answer.skeleton.slice(0, position), answer.marks.slice(0, position));
|
|
530
|
+
});
|
|
531
|
+
settled.lineIndex = node.lineIndex; settled.uuid = node.uuid;
|
|
532
|
+
// in memory, the newly settled record and every record after it
|
|
533
|
+
this.graph = zoneOf(this.graph, this.graph.nodes.slice(at + 1));
|
|
534
|
+
for (const line of this.records.keys()) if (line < node.lineIndex) this.records.delete(line);
|
|
535
|
+
// the answer already given stands: it is the same replay, its prefix now settled
|
|
536
|
+
answer.reframe(settled, id + 1, position, at + 1);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether every record after `root` (`after`) is replayed in a tail rooted at it: as the tail replay requires
|
|
542
|
+
* (settledAnswer), none reaches back past it, none is a root (but a compaction's boundary, in a history replay),
|
|
543
|
+
* none brings an absent parent, and no assistant message among them began before it. */
|
|
544
|
+
#rooted(root, after, dangling, anchors) {
|
|
545
|
+
const inside = new Set([root.uuid]);
|
|
546
|
+
for (const node of after) {
|
|
547
|
+
if (!this.history && node.compact != null) return false;
|
|
548
|
+
if (dangling.has(node.uuid) || node.parent == null && node.compact == null) return false;
|
|
549
|
+
if (node.parent != null && !inside.has(node.parent) && !anchors.has(node.parent)) return false;
|
|
550
|
+
if (node.messageId != null && (this.store.firstLineOf(node.messageId) ?? node.lineIndex) <= root.lineIndex) return false;
|
|
551
|
+
inside.add(node.uuid);
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Whether the replay `skeleton` (of `groups`) can be settled through the group `id` of the user record `node`, ending
|
|
557
|
+
* at `position`: every message before it is of a group up to that one and every one after of a later group (or made
|
|
558
|
+
* up by the pairing pass for a call of its own side), every record before it was written before that record and
|
|
559
|
+
* every one after after it, no result after it answers a call before it, no call after it repeats one before it,
|
|
560
|
+
* and no message after it began before it. */
|
|
561
|
+
#cutHolds(skeleton, groups, position, id, node, lastLine, records, base = 0) {
|
|
562
|
+
const groupOf = (message) => { const reference = message.metadata[REFERENCE]; return reference === undefined ? null : Number(reference.split(':')[0]) - base; };
|
|
563
|
+
for (let at = 0; at < skeleton.length; at++) {
|
|
564
|
+
const group = groupOf(skeleton[at]);
|
|
565
|
+
if (group != null && (at < position ? group > id : group <= id)) return false;
|
|
566
|
+
}
|
|
567
|
+
for (let group = 0; group < groups.length; group++) {
|
|
568
|
+
if (group <= id ? lastLine.get(group) > node.lineIndex : groups[group].some((index) => index <= node.lineIndex)) return false;
|
|
569
|
+
}
|
|
570
|
+
const before = { calls: new Set(), results: new Set() };
|
|
571
|
+
for (let at = 0; at < position; at++) {
|
|
572
|
+
const message = skeleton[at];
|
|
573
|
+
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '') before.calls.add(call.id);
|
|
574
|
+
if (message.role === 'tool' && message.tool_call_id != null) before.results.add(message.tool_call_id);
|
|
575
|
+
}
|
|
576
|
+
for (let at = position; at < skeleton.length; at++) {
|
|
577
|
+
const message = skeleton[at];
|
|
578
|
+
if (message.role === 'tool' && message.tool_call_id != null && message.tool_call_id !== '' && before.calls.has(message.tool_call_id)) return false;
|
|
579
|
+
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '' && (before.calls.has(call.id) || before.results.has(call.id))) return false;
|
|
580
|
+
}
|
|
581
|
+
for (let group = id + 1; group < groups.length; group++) {
|
|
582
|
+
for (const index of groups[group]) {
|
|
583
|
+
const record = records.get(index);
|
|
584
|
+
if (record?.messageId != null && record.assistant && (this.store.firstLineOf(record.messageId) ?? index) <= node.lineIndex) return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return true;
|
|
277
588
|
}
|
|
278
589
|
}
|
|
279
590
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
591
|
+
/** The scan's in-memory graph holding `nodes` (the settled record first), with the scan's lastPrompt and fork anchors. */
|
|
592
|
+
function zoneOf(graph, nodes) {
|
|
593
|
+
const zone = new ClaudeReplayIndex();
|
|
594
|
+
for (const node of nodes) { zone.byUuid.set(node.uuid, zone.nodes.length); zone.nodes.push(node); }
|
|
595
|
+
zone.linearLines = graph.linearLines.slice(firstAbove(graph.linearLines, (nodes[0]?.lineIndex ?? Infinity) - 1));
|
|
596
|
+
zone.segmentAnchors = new Set(graph.segmentAnchors); zone.lastPrompt = graph.lastPrompt == null ? null : [...graph.lastPrompt];
|
|
597
|
+
return zone;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The index of the node of the record on line `line`, or undefined. */
|
|
601
|
+
function nodeAt(nodes, line) {
|
|
602
|
+
let low = 0, high = nodes.length;
|
|
603
|
+
while (low < high) { const middle = (low + high) >> 1; if (nodes[middle].lineIndex < line) low = middle + 1; else high = middle; }
|
|
604
|
+
return nodes[low]?.lineIndex === line ? low : undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Whether a replayed record can be where the replay is settled: a user record on the main line (a person's prompt
|
|
608
|
+
* or a tool's result, a group of its own), a whole line already read, after the latest compaction (`boundary`, null
|
|
609
|
+
* for a history replay, which prunes nothing at a compaction) and no record it keeps. */
|
|
610
|
+
function settles(node, lines, boundary, kept) {
|
|
611
|
+
return node != null && node.kind === 'user' && !node.sidechain && node.compact == null && node.lineIndex < lines
|
|
612
|
+
&& (boundary == null || node.lineIndex > boundary.lineIndex) && !kept.has(node.uuid);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The first index of a sorted array whose value is above `value`. */
|
|
616
|
+
function firstAbove(sorted, value) {
|
|
617
|
+
let low = 0, high = sorted.length;
|
|
618
|
+
while (low < high) { const middle = (low + high) >> 1; if (sorted[middle] <= value) low = middle + 1; else high = middle; }
|
|
619
|
+
return low;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Settled messages appended on disk: their places, their marks and the tool calls they make and answer; and in
|
|
623
|
+
* memory, the chain of digests over the marks (a checkpoint each MARK_CHECKPOINT messages). */
|
|
624
|
+
function appendSettled(settled, store, messages, marks) {
|
|
625
|
+
for (let at = 0; at < messages.length; at++) {
|
|
626
|
+
const message = messages[at], reference = message.metadata[REFERENCE];
|
|
627
|
+
if (reference === undefined) store.addPosition(settled.length, -1, -1, marks[at], message);
|
|
628
|
+
else { const [group, ordinal] = reference.split(':').map(Number); store.addPosition(settled.length, group, ordinal, marks[at], null); }
|
|
629
|
+
settled.length++;
|
|
630
|
+
settled.digest = chainMark(settled.digest, marks[at]);
|
|
631
|
+
if (settled.length % MARK_CHECKPOINT === 0) settled.checkpoints.push(settled.digest);
|
|
632
|
+
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) if (call.id !== '') store.addTool(call.id, true, false);
|
|
633
|
+
if (message.role === 'tool' && message.tool_call_id != null) store.addTool(message.tool_call_id, false, true);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A replay as a window read answers it: the settled prefix (read from disk) and the tail, read by place. */
|
|
638
|
+
class WindowAnswer {
|
|
639
|
+
constructor(parts) { Object.assign(this, parts); this.base = this.settled.length; this.baseGroups = this.settled.groups; }
|
|
640
|
+
get total() { return this.base + this.skeleton.length; }
|
|
641
|
+
at(position) {
|
|
642
|
+
if (position >= this.base) return this.skeleton[position - this.base];
|
|
643
|
+
const { group, ordinal, synthetic } = this.store.positionAt(position);
|
|
644
|
+
if (synthetic) return synthetic;
|
|
645
|
+
const metadata = Object.create(null); metadata[REFERENCE] = `${group}:${ordinal}`;
|
|
646
|
+
return { metadata };
|
|
647
|
+
}
|
|
648
|
+
/** The marks from `from` to `to`, in order. */
|
|
649
|
+
*markRange(from, to) {
|
|
650
|
+
if (from < this.base) yield* this.store.marksFrom(from, Math.min(to, this.base));
|
|
651
|
+
for (let at = Math.max(from, this.base); at < to; at++) yield this.marks[at - this.base];
|
|
652
|
+
}
|
|
653
|
+
group(id) { return id < this.baseGroups ? this.store.groupAt(id).lines : this.groups[id - this.baseGroups]; }
|
|
654
|
+
size(id) { return id < this.baseGroups ? this.store.groupAt(id).size : this.sizes[id - this.baseGroups]; }
|
|
655
|
+
/** The digest of the chain of the first `count` marks. */
|
|
656
|
+
digestAt(count) {
|
|
657
|
+
let at, digest;
|
|
658
|
+
if (count <= this.base) { const checkpoint = Math.floor(count / MARK_CHECKPOINT); at = checkpoint * MARK_CHECKPOINT; digest = checkpoint ? this.settled.checkpoints[checkpoint - 1] : ''; }
|
|
659
|
+
else { at = this.base; digest = this.settled.digest; }
|
|
660
|
+
for (const mark of this.markRange(at, count)) digest = chainMark(digest, mark);
|
|
661
|
+
return digest;
|
|
662
|
+
}
|
|
663
|
+
/** The tail's first groups and messages settled since this answer was made: the same replay, read by place. */
|
|
664
|
+
reframe(settled, groups, messages, nodes) {
|
|
665
|
+
this.groups = this.groups.slice(groups); this.sizes = this.sizes.slice(groups); this.skeleton = this.skeleton.slice(messages); this.marks = this.marks.slice(messages);
|
|
666
|
+
this.tailNodes = this.tailNodes.slice(nodes); this.committed -= nodes; this.base = settled.length; this.baseGroups = settled.groups;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A whole replay answered with nothing settled. */
|
|
671
|
+
function wholeAnswer(view, store, reason = null) {
|
|
672
|
+
return new WindowAnswer({ key: view.key, raw: view.raw, store, replay: { whole: true, reason }, settled: { length: 0, groups: 0, digest: '', checkpoints: [], residue: [], dangling: new Set(), model: null },
|
|
673
|
+
groups: view.groups, sizes: view.sizes, skeleton: view.skeleton, marks: view.marks, made: new Map(), tailNodes: [], committed: 0, model: view.model,
|
|
674
|
+
residue: view.residue, meta: view.meta, lines_read: view.lines, parseErrors: view.parseErrors, version: view.version, history: view.history,
|
|
675
|
+
decodeError: view.decodeError, agents: view.agents });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// The scans of the files read most recently, at most KEPT_SCANS of them and at most this many retained in-memory entries
|
|
679
|
+
// between them (beyond the scans still reading their transcripts through, which are kept): a transcript read again is
|
|
680
|
+
// read from where its scan stopped, and one read long ago is scanned again. A scan keeps on disk what it knows of each
|
|
681
|
+
// record, so what it holds in memory is its tail, not its transcript.
|
|
682
|
+
const SCANNED_RECORDS = 400_000, KEPT_SCANS = 32;
|
|
285
683
|
const scans = new Map();
|
|
286
684
|
|
|
287
|
-
|
|
685
|
+
/** A scan let go: its file on disk with it. */
|
|
686
|
+
function dropScan(path) { const scan = scans.get(path); scans.delete(path); scan?.store.close(); }
|
|
687
|
+
|
|
688
|
+
async function scanOf(path, reader, budget = Infinity) {
|
|
288
689
|
let scan = scans.get(path); scans.delete(path);
|
|
289
|
-
if (
|
|
290
|
-
|
|
690
|
+
if (scan && !await scan.holds(reader)) { scan.store.close(); scan = null; }
|
|
691
|
+
scan ??= new ClaudeRecordScan(reader);
|
|
692
|
+
try { await scan.extend(reader, budget); }
|
|
693
|
+
catch (error) { scan.store.close(); throw error; }
|
|
291
694
|
return scan;
|
|
292
695
|
}
|
|
293
696
|
|
|
294
|
-
// Project before admitting the scan: the projection adds skeletons and groups that the same finite bound charges.
|
|
295
|
-
// As before, the current scan is kept even when it alone exceeds the bound; every older scan then goes.
|
|
296
697
|
function retainScan(path, scan, metrics = {}) {
|
|
297
698
|
metrics.claude_scan_physical_lines = scan.lines;
|
|
298
699
|
metrics.claude_scan_record_positions = scan.records.size;
|
|
299
700
|
metrics.claude_scan_graph_nodes = scan.graph.nodes.length;
|
|
300
701
|
metrics.claude_scan_graph_lines = scan.graph.linearLines.length;
|
|
301
702
|
metrics.claude_scan_profile_changes = scan.profile.history.length;
|
|
302
|
-
metrics.claude_scan_skeletons = scan.
|
|
303
|
-
if (scan.bytes < SCAN_KEPT_BYTES) for (const [other, item] of scans) if (item.bytes < SCAN_KEPT_BYTES) scans.delete(other);
|
|
703
|
+
metrics.claude_scan_skeletons = scan.regionMade.size;
|
|
304
704
|
scans.set(path, scan);
|
|
305
705
|
let held = 0; for (const item of scans.values()) held += item.retainedRecords;
|
|
306
|
-
for (const [other, item] of scans) {
|
|
307
|
-
if (held <= SCANNED_RECORDS
|
|
308
|
-
|
|
706
|
+
for (const [other, item] of [...scans]) {
|
|
707
|
+
if (held <= SCANNED_RECORDS && scans.size <= KEPT_SCANS) break;
|
|
708
|
+
// the scan just read is kept, and so is any still reading its transcript through: let go, it would read it from
|
|
709
|
+
// its first byte again, and a long transcript read in turn with many others might never be read through
|
|
710
|
+
if (item === scan || item.behind) continue;
|
|
711
|
+
dropScan(other); held -= item.retainedRecords;
|
|
309
712
|
metrics.claude_scan_evictions = (metrics.claude_scan_evictions ?? 0) + 1;
|
|
310
713
|
}
|
|
311
714
|
metrics.claude_scan_retained_entries = held;
|
|
312
715
|
}
|
|
313
716
|
|
|
717
|
+
/** A transcript's background-agent fold alone, kept between reads of the same file as ClaudeRecordScan is, but holding
|
|
718
|
+
* no record positions: a follower of a long session keeps its agents without the replay index a window read needs. */
|
|
719
|
+
class ClaudeAgentScan extends AppendScan {
|
|
720
|
+
lines = 0; agents = claudeBackgroundAgentFold();
|
|
721
|
+
async extend(reader) {
|
|
722
|
+
const from = this.bytes;
|
|
723
|
+
for await (const line of sessionFileLines(reader, this.bytes)) {
|
|
724
|
+
if (!line.terminated) break;
|
|
725
|
+
const index = this.lines++;
|
|
726
|
+
if (nativeTrim(line.text) !== '') { try { this.agents.observe(parseNativeJson(line.text), index + 1); } catch { /* a malformed line holds no agent */ } }
|
|
727
|
+
this.took(line.bytes, line.offset + line.length); this.bytes = line.offset + line.length;
|
|
728
|
+
}
|
|
729
|
+
await this.settle(reader, from);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Agent scans of the files read most recently. A 343 MB, 95,000-line Claude Code transcript that launched 132 background
|
|
733
|
+
// agents holds about 3,000 records this fold keeps (its Agent and SendMessage calls and results, and some 2,000 task
|
|
734
|
+
// notification records); counted from the file's records with grep, not from a run of the fold. These bounds keep
|
|
735
|
+
// dozens of such followed sessions without one evicting another; a scan above AGENT_SCAN_ENTRIES alone is not kept
|
|
736
|
+
// (its next read scans the file again) rather than evicting every other.
|
|
737
|
+
const AGENT_SCANS = 64, AGENT_SCANNED_ENTRIES = 1_000_000, AGENT_SCAN_ENTRIES = 250_000;
|
|
738
|
+
const agentScans = new Map();
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The background agents of a Claude Code transcript (claudeBackgroundAgentFold over every record of the file, kept
|
|
742
|
+
* between reads: a read takes only what was written since, and a last line still being written is folded for this
|
|
743
|
+
* answer alone). Null when the locator names anything this index does not read, as loadClaudeRecordWindow.
|
|
744
|
+
*/
|
|
745
|
+
export async function loadClaudeBackgroundAgents(locator, { metrics } = {}) {
|
|
746
|
+
const { path, kind } = locator.storage;
|
|
747
|
+
if (locator.harness !== 'claude-code' || kind !== 'file' || isCompressedSessionPath(path)) return null;
|
|
748
|
+
if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
|
|
749
|
+
if (!await supportsClaudeReadIndex(path, { metrics })) return null;
|
|
750
|
+
return (await withSessionFile(path, async (reader) => {
|
|
751
|
+
let scan = agentScans.get(path); agentScans.delete(path);
|
|
752
|
+
if (!scan || !await scan.holds(reader)) scan = new ClaudeAgentScan(reader);
|
|
753
|
+
await scan.extend(reader);
|
|
754
|
+
if (scan.agents.retainedEntries <= AGENT_SCAN_ENTRIES) {
|
|
755
|
+
agentScans.set(path, scan);
|
|
756
|
+
let held = 0; for (const item of agentScans.values()) held += item.agents.retainedEntries;
|
|
757
|
+
for (const [other, item] of agentScans) {
|
|
758
|
+
if (agentScans.size <= AGENT_SCANS && held <= AGENT_SCANNED_ENTRIES || item === scan) break;
|
|
759
|
+
agentScans.delete(other); held -= item.agents.retainedEntries;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
let tail = null;
|
|
763
|
+
if (reader.size > scan.bytes) {
|
|
764
|
+
try { const value = parseNativeJson(decodeSessionUtf8(await reader.read(scan.bytes, reader.size - scan.bytes))); tail = claudeBackgroundAgentFold(); tail.observe(value, scan.lines + 1); }
|
|
765
|
+
catch { /* a line still being written */ }
|
|
766
|
+
}
|
|
767
|
+
return scan.agents.finish({ also: tail });
|
|
768
|
+
}, metrics)).result;
|
|
769
|
+
}
|
|
770
|
+
|
|
314
771
|
/**
|
|
315
772
|
* A window of a Claude Code transcript's messages from the place `from`, read record by record: at most `messages` of
|
|
316
|
-
* them and `bytes` of transcript (one message at least), with each one's mark and, when `marks` is asked, the
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
773
|
+
* them and `bytes` of transcript (one message at least), with each one's mark and, when `marks` is asked, the marks
|
|
774
|
+
* after the first place where those the reader holds (`known`: their count, the digest of their chain and its
|
|
775
|
+
* checkpoints, chainMark) differ from these: `marks_from` and `tail_marks` (from the first when nothing is named). A
|
|
776
|
+
* mark is of the bytes of the records that make a message: while it stands, so does what a reader took of that message
|
|
777
|
+
* before. A transcript not yet read through answers how far it is read (`reading`) and no messages: each read takes in
|
|
778
|
+
* at most SCAN_STEP_BYTES more. Each answer says whether its read replayed the whole transcript and why
|
|
779
|
+
* (`replay: { whole, reason }`, counted in `metrics.claude_whole_replays`): a read over a settled prefix replays its
|
|
780
|
+
* tail alone. Null when the locator names anything else, which is loaded as its document.
|
|
320
781
|
*/
|
|
321
|
-
export async function loadClaudeRecordWindow(locator, { from, bytes = null, messages = null, marks = false }, { fidelity = 'semantic', metrics } = {}) {
|
|
782
|
+
export async function loadClaudeRecordWindow(locator, { from, bytes = null, messages = null, marks = false, known = null }, { fidelity = 'semantic', metrics } = {}) {
|
|
322
783
|
const { path, kind } = locator.storage;
|
|
323
784
|
if (kind !== 'file' || locator.harness === 'hermes' || isCompressedSessionPath(path)) return null;
|
|
324
785
|
if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
|
|
325
786
|
if (!await supportsClaudeReadIndex(path, { metrics })) return null;
|
|
787
|
+
metrics ??= {};
|
|
326
788
|
return (await withSessionFile(path, async (reader) => {
|
|
327
|
-
let view;
|
|
789
|
+
let view, scan, replay;
|
|
328
790
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
791
|
+
scan = await scanOf(path, reader, SCAN_STEP_BYTES);
|
|
792
|
+
// A transcript not read through is replayed as far as it is read, so that its replay is settled as it is read
|
|
793
|
+
// and the read that reaches its end replays only its last step.
|
|
794
|
+
const answer = await scan.window(reader, fidelity, scan.behind);
|
|
795
|
+
replay = answer.replay;
|
|
796
|
+
if (replay.whole) metrics.claude_whole_replays = (metrics.claude_whole_replays ?? 0) + 1;
|
|
797
|
+
if (!scan.behind) view = answer;
|
|
331
798
|
retainScan(path, scan, metrics);
|
|
332
799
|
}
|
|
333
800
|
catch (error) {
|
|
334
|
-
|
|
801
|
+
dropScan(path);
|
|
335
802
|
throw ['ERR_ENCODING_INVALID_ENCODED_DATA', 'ERR_STRING_TOO_LONG'].includes(error?.code) ? sessionTextError(path, error) : error;
|
|
336
803
|
}
|
|
337
|
-
|
|
804
|
+
if (view == null) {
|
|
805
|
+
const session = createSession('claude_code'); session.meta = cloneNativeJson(scan.meta);
|
|
806
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: scan.lines,
|
|
807
|
+
window: { offset: 0, returned: 0, total_messages: null, marks: [], reading: { bytes: scan.bytes, size: reader.size }, replay } } };
|
|
808
|
+
}
|
|
809
|
+
const total = view.total, offset = Math.min(from, total), session = createSession('claude_code'), counted = new Set();
|
|
338
810
|
let end = offset, used = 0, decoded = null, decodedGroup = null;
|
|
339
811
|
for (; end < total && (messages == null || end - offset < messages); end++) {
|
|
340
|
-
const message = view.
|
|
812
|
+
const message = view.at(end), reference = message.metadata[REFERENCE];
|
|
341
813
|
if (reference === undefined) { session.messages.push(cloneNativeJson(message)); continue; }
|
|
342
|
-
const [group, ordinal] = reference.split(':').map(Number), cost = counted.has(group) ? 0 : view.
|
|
814
|
+
const [group, ordinal] = reference.split(':').map(Number), cost = counted.has(group) ? 0 : view.size(group);
|
|
343
815
|
if (bytes != null && end > offset && used + cost > bytes) break;
|
|
344
816
|
counted.add(group); used += cost;
|
|
345
|
-
if (decodedGroup !== group) { decoded = await decodeClaudeGroup((index) => view.raw(index, reader), view.
|
|
817
|
+
if (decodedGroup !== group) { decoded = await decodeClaudeGroup((index) => view.raw(index, reader), view.group(group)); decodedGroup = group; }
|
|
346
818
|
session.messages.push(cloneNativeJson(decoded[ordinal]));
|
|
347
819
|
}
|
|
348
820
|
session.meta = cloneNativeJson(view.meta); session.imported_message_count = total;
|
|
349
821
|
session.parse_error_lines = view.parseErrors; session.load_residue = [...view.residue];
|
|
350
|
-
|
|
822
|
+
const window = { offset, returned: end - offset, total_messages: total, marks: [...view.markRange(offset, end)], replay };
|
|
823
|
+
if (marks) { const listed = markedFrom(view, known); window.marks_from = listed; window.tail_marks = [...view.markRange(listed, total)]; }
|
|
824
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: view.lines_read, background_agents: view.agents,
|
|
351
825
|
resolved_profile: recordedSessionProfile(session, { model: view.model, version: view.version }), resolved_profile_history: view.history.map(cloneNativeJson),
|
|
352
|
-
window
|
|
826
|
+
window } };
|
|
353
827
|
}, metrics)).result;
|
|
354
828
|
}
|
|
829
|
+
|
|
830
|
+
// The history scans of the files paged most recently (loadClaudeHistoryWindow), kept apart from the window reads'
|
|
831
|
+
// scans: paging a session's history never evicts the scan its capture reads, nor reads its transcript through again.
|
|
832
|
+
const HISTORY_SCANS = 4, HISTORY_SCANNED_RECORDS = 200_000;
|
|
833
|
+
const historyScans = new Map(), historyReads = new Map();
|
|
834
|
+
/** A page of history is read in steps of SCAN_STEP_BYTES for at most this long before answering how far it has read. */
|
|
835
|
+
const HISTORY_READ_MS = 2000;
|
|
836
|
+
|
|
837
|
+
/** The lines of the records a compact boundary keeps (its preserved messages, or the chain of its preserved segment). */
|
|
838
|
+
function keptLines(store, compact) {
|
|
839
|
+
const lines = new Set();
|
|
840
|
+
for (const uuid of compact.preserved) { const node = store.nodeOf(uuid); if (node) lines.add(node.line); }
|
|
841
|
+
if (compact.segment != null) {
|
|
842
|
+
const [head, tail] = compact.segment;
|
|
843
|
+
for (let uuid = tail, steps = 0; uuid != null && steps < 100_000; steps++) {
|
|
844
|
+
const node = store.nodeOf(uuid); if (!node) break;
|
|
845
|
+
lines.add(node.line); if (uuid === head) break; uuid = node.parent;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return lines;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The first line of the records that make the message at `position` of a window answer (one the pairing pass made up
|
|
852
|
+
* is read at the message after it), or Infinity. */
|
|
853
|
+
function firstLineAt(view, position) {
|
|
854
|
+
for (let at = position; at < Math.min(view.total, position + 8); at++) {
|
|
855
|
+
const reference = view.at(at).metadata[REFERENCE];
|
|
856
|
+
if (reference !== undefined) return Math.min(...view.group(Number(reference.split(':')[0])));
|
|
857
|
+
}
|
|
858
|
+
return Infinity;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* A page of a Claude Code transcript's history, its segments before every compaction kept (a history read: the graph's
|
|
863
|
+
* `history` projection, replayed by a scan of its own): the messages of that read before the place `before`, or, with
|
|
864
|
+
* `before` null, before the first message of its latest segment, which is where a read from the latest compaction on
|
|
865
|
+
* (the Teams connector's capture, whose first message is its ordinal 0) begins. At most `messages` of them and `bytes`
|
|
866
|
+
* of transcript (one message at least), the newest of them first to be kept: `window.offset` is where the page starts,
|
|
867
|
+
* the `before` of the next older page. A transcript not read through is read on, SCAN_STEP_BYTES at a time, for at most
|
|
868
|
+
* HISTORY_READ_MS; one still not read through answers how far it has read (`window.reading`) and no messages, and the
|
|
869
|
+
* next read reads on from there. Null when the locator names anything this index does not read, as
|
|
870
|
+
* loadClaudeRecordWindow.
|
|
871
|
+
*/
|
|
872
|
+
export async function loadClaudeHistoryWindow(locator, page = {}, options = {}) {
|
|
873
|
+
const { path, kind } = locator.storage;
|
|
874
|
+
if (locator.harness !== 'claude-code' || kind !== 'file' || isCompressedSessionPath(path)) return null;
|
|
875
|
+
if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
|
|
876
|
+
if (!await supportsClaudeReadIndex(path, { metrics: options.metrics })) return null;
|
|
877
|
+
// one read of a file's history scan at a time: a page asked while another reads waits for it, and takes the scan it leaves
|
|
878
|
+
const read = (historyReads.get(path) ?? Promise.resolve()).catch(() => {}).then(() => historyWindow(path, page, options));
|
|
879
|
+
historyReads.set(path, read);
|
|
880
|
+
try { return await read; } finally { if (historyReads.get(path) === read) historyReads.delete(path); }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function historyWindow(path, { before = null, messages = 50, bytes = null }, { metrics } = {}) {
|
|
884
|
+
metrics ??= {};
|
|
885
|
+
return (await withSessionFile(path, async (reader) => {
|
|
886
|
+
let scan = historyScans.get(path), view; historyScans.delete(path);
|
|
887
|
+
if (scan && !await scan.holds(reader)) { scan.store.close(); scan = null; }
|
|
888
|
+
if (!scan) { scan = new ClaudeRecordScan(reader); scan.history = true; scan.boundary = null; }
|
|
889
|
+
try {
|
|
890
|
+
const started = Date.now();
|
|
891
|
+
for (;;) {
|
|
892
|
+
// the newest compact boundary among the records this step takes in: where the latest segment begins
|
|
893
|
+
const known = scan.graph.nodes.length;
|
|
894
|
+
await scan.extend(reader, SCAN_STEP_BYTES);
|
|
895
|
+
for (let at = scan.graph.nodes.length - 1; at >= known; at--) if (scan.graph.nodes[at].compact != null) { scan.boundary = { line: scan.graph.nodes[at].lineIndex, compact: scan.graph.nodes[at].compact, kept: null }; break; }
|
|
896
|
+
view = await scan.window(reader, 'semantic', scan.behind);
|
|
897
|
+
if (!scan.behind || Date.now() - started >= HISTORY_READ_MS) break;
|
|
898
|
+
}
|
|
899
|
+
if (view.replay.whole) metrics.claude_whole_replays = (metrics.claude_whole_replays ?? 0) + 1;
|
|
900
|
+
} catch (error) {
|
|
901
|
+
scan.store.close();
|
|
902
|
+
throw ['ERR_ENCODING_INVALID_ENCODED_DATA', 'ERR_STRING_TOO_LONG'].includes(error?.code) ? sessionTextError(path, error) : error;
|
|
903
|
+
}
|
|
904
|
+
const replaced = historyScans.get(path);
|
|
905
|
+
if (replaced && replaced !== scan) replaced.store.close();
|
|
906
|
+
historyScans.set(path, scan);
|
|
907
|
+
// at most HISTORY_SCANS scans and HISTORY_SCANNED_RECORDS in-memory entries between them (ClaudeRecordScan's
|
|
908
|
+
// retainedRecords), the oldest let go first; the scan just read is kept
|
|
909
|
+
let held = 0; for (const item of historyScans.values()) held += item.retainedRecords;
|
|
910
|
+
for (const [other, item] of historyScans) {
|
|
911
|
+
if (historyScans.size <= HISTORY_SCANS && held <= HISTORY_SCANNED_RECORDS) break;
|
|
912
|
+
if (item !== scan) { historyScans.delete(other); held -= item.retainedRecords; item.store.close(); }
|
|
913
|
+
}
|
|
914
|
+
if (scan.behind) {
|
|
915
|
+
const session = createSession('claude_code'); session.meta = cloneNativeJson(scan.meta);
|
|
916
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: scan.lines,
|
|
917
|
+
window: { offset: 0, returned: 0, total_messages: null, segment_start: null, marks: [], reading: { bytes: scan.bytes, size: reader.size }, replay: view.replay } } };
|
|
918
|
+
}
|
|
919
|
+
const total = view.total;
|
|
920
|
+
// the first message of the latest segment: the first whose records begin at or after its boundary, and before it
|
|
921
|
+
// those whose records the boundary keeps (a read from the latest compaction on replays them after its summary)
|
|
922
|
+
const boundary = scan.boundary;
|
|
923
|
+
let low = 0, high = boundary == null ? 0 : total;
|
|
924
|
+
while (low < high) { const middle = (low + high) >> 1; if (firstLineAt(view, middle) < boundary.line) low = middle + 1; else high = middle; }
|
|
925
|
+
if (boundary != null && low > 0) {
|
|
926
|
+
boundary.kept ??= keptLines(scan.store, boundary.compact);
|
|
927
|
+
for (; low > 0; low--) {
|
|
928
|
+
const reference = view.at(low - 1).metadata[REFERENCE];
|
|
929
|
+
if (reference === undefined || !view.group(Number(reference.split(':')[0])).every((line) => boundary.kept.has(line))) break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const segmentStart = low, end = Math.min(before ?? segmentStart, total);
|
|
933
|
+
// the page's first place: back from `end`, as many messages and bytes of their records as it may hold
|
|
934
|
+
let offset = end, used = 0;
|
|
935
|
+
const counted = new Set();
|
|
936
|
+
while (offset > 0 && (messages == null || end - offset < messages)) {
|
|
937
|
+
const reference = view.at(offset - 1).metadata[REFERENCE];
|
|
938
|
+
if (reference !== undefined) {
|
|
939
|
+
const group = Number(reference.split(':')[0]), cost = counted.has(group) ? 0 : view.size(group);
|
|
940
|
+
if (bytes != null && offset < end && used + cost > bytes) break;
|
|
941
|
+
counted.add(group); used += cost;
|
|
942
|
+
}
|
|
943
|
+
offset--;
|
|
944
|
+
}
|
|
945
|
+
const session = createSession('claude_code');
|
|
946
|
+
let decoded = null, decodedGroup = null;
|
|
947
|
+
for (let at = offset; at < end; at++) {
|
|
948
|
+
const message = view.at(at), reference = message.metadata[REFERENCE];
|
|
949
|
+
if (reference === undefined) { session.messages.push(cloneNativeJson(message)); continue; }
|
|
950
|
+
const [group, ordinal] = reference.split(':').map(Number);
|
|
951
|
+
if (decodedGroup !== group) { decoded = await decodeClaudeGroup((index) => view.raw(index, reader), view.group(group)); decodedGroup = group; }
|
|
952
|
+
session.messages.push(cloneNativeJson(decoded[ordinal]));
|
|
953
|
+
}
|
|
954
|
+
session.meta = cloneNativeJson(view.meta); session.imported_message_count = total;
|
|
955
|
+
session.parse_error_lines = view.parseErrors; session.load_residue = [...view.residue];
|
|
956
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: view.lines_read,
|
|
957
|
+
window: { offset, returned: end - offset, total_messages: total, segment_start: segmentStart, marks: [...view.markRange(offset, end)], replay: view.replay } } };
|
|
958
|
+
}, metrics)).result;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** The first place whose mark the reader may not hold: the count it names when the chain of its marks there is this
|
|
962
|
+
* read's (`view.digestAt`), else the last checkpoint both chains share. */
|
|
963
|
+
export function markedFrom(view, known) {
|
|
964
|
+
if (known == null) return 0;
|
|
965
|
+
if (known.count <= view.total && view.digestAt(known.count) === known.digest) return known.count;
|
|
966
|
+
let low = 0, high = Math.min(known.checkpoints.length, Math.floor(view.total / MARK_CHECKPOINT));
|
|
967
|
+
while (low < high) {
|
|
968
|
+
const middle = (low + high + 1) >> 1;
|
|
969
|
+
if (view.digestAt(middle * MARK_CHECKPOINT) === known.checkpoints[middle - 1]) low = middle; else high = middle - 1;
|
|
970
|
+
}
|
|
971
|
+
return low * MARK_CHECKPOINT;
|
|
972
|
+
}
|