@volter/supercode-interchange 0.1.15 → 0.1.17
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/package.json +1 -1
- package/session-claude-index.mjs +219 -41
- package/session-codex-display.mjs +8 -4
- package/session-file.mjs +20 -13
- package/session-model.d.ts +1 -1
- package/session-read.d.ts +2 -2
- package/session-read.mjs +8 -2
- package/session-records.mjs +2 -1
- package/session-views.mjs +35 -25
package/package.json
CHANGED
package/session-claude-index.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
1
3
|
import { withSessionFile } from './file-io.mjs';
|
|
2
4
|
import { sessionFileLines, decodeSessionUtf8 } from './session-file.mjs';
|
|
3
5
|
import { parseNativeJson, cloneNativeJson } from './native-json.mjs';
|
|
4
|
-
import { createSession } from './session-model.mjs';
|
|
5
|
-
import {
|
|
6
|
+
import { createSession, normalizedSessionJson } from './session-model.mjs';
|
|
7
|
+
import { messageJson } from './message.mjs';
|
|
8
|
+
import { messageHasContent, recordedProfileFold, recordedSessionProfile } from './session-views.mjs';
|
|
6
9
|
import { nativeTrim } from './text.mjs';
|
|
7
10
|
import { field, stringField, ensureToolResultsPaired } from './session-codec-utils.mjs';
|
|
8
11
|
import { captureNativeResidue, claudeResidueKind } from './session-native-residue.mjs';
|
|
@@ -10,11 +13,64 @@ import { captureClaudeMeta } from './session-claude.mjs';
|
|
|
10
13
|
import { ClaudeReplayIndex } from './session-claude-graph.mjs';
|
|
11
14
|
import { captureClaudeProvenance, pushClaudeUser, pushClaudeAttachment, pushClaudeSystem, mergeClaudeAssistantChunk, flushClaudeAssistant, reorderClaudeToolResults } from './session-claude-content.mjs';
|
|
12
15
|
import { sourceStat } from './store-registry.mjs';
|
|
13
|
-
import { looksLikeSessionSqlite } from './session-path.mjs';
|
|
16
|
+
import { isCompressedSessionPath, looksLikeSessionSqlite, sessionTextError } from './session-path.mjs';
|
|
14
17
|
import { detectSessionSource } from './session-source.mjs';
|
|
15
18
|
|
|
16
19
|
const REFERENCE = 'supercode.internal.read_index';
|
|
17
20
|
|
|
21
|
+
/** What one record is to the replay, with where it stands in the file. */
|
|
22
|
+
function recordFacts(line, value) {
|
|
23
|
+
return { offset: line.offset, length: line.length, assistant: stringField(value, 'type') === 'assistant',
|
|
24
|
+
error: field(value, 'isApiErrorMessage') === true, messageId: stringField(field(value, 'message'), 'id') };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The selected records as the groups that each make messages: the chunks of one assistant message together, any
|
|
28
|
+
* other record alone. An assistant record that is an API error makes none. */
|
|
29
|
+
function* claudeGroups(lines, recordOf) {
|
|
30
|
+
let pending = [], pendingId = null;
|
|
31
|
+
for (const index of lines) {
|
|
32
|
+
const record = recordOf(index);
|
|
33
|
+
if (record.assistant && !record.error) {
|
|
34
|
+
if (pending.length && !(pendingId != null && pendingId === record.messageId)) { yield pending; pending = []; }
|
|
35
|
+
pendingId = record.messageId; pending.push(index);
|
|
36
|
+
} else {
|
|
37
|
+
if (pending.length) yield pending;
|
|
38
|
+
pending = []; pendingId = null;
|
|
39
|
+
if (!record.error || !record.assistant) yield [index];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (pending.length) yield pending;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The messages one group's records make, each record read from where it stands (`raw`). */
|
|
46
|
+
async function decodeClaudeGroup(raw, group) {
|
|
47
|
+
const messages = []; let pending = null;
|
|
48
|
+
for (const index of group) {
|
|
49
|
+
const value = parseNativeJson(await raw(index)), kind = stringField(value, 'type');
|
|
50
|
+
if (kind === 'assistant') {
|
|
51
|
+
if (pending == null) pending = value; else mergeClaudeAssistantChunk(pending, value);
|
|
52
|
+
} else {
|
|
53
|
+
const before = messages.length;
|
|
54
|
+
if (kind === 'user') pushClaudeUser(value, messages);
|
|
55
|
+
else if (kind === 'attachment') pushClaudeAttachment(value, messages);
|
|
56
|
+
else if (kind === 'system') pushClaudeSystem(value, messages);
|
|
57
|
+
captureClaudeProvenance(value, messages, before);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
flushClaudeAssistant(pending, messages);
|
|
61
|
+
return messages;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A decoded message with what it says left out: what the passes over the whole transcript read (a tool result
|
|
65
|
+
* beside its call, a call with no result), and nothing of its size. */
|
|
66
|
+
function skeletonMessage(message) {
|
|
67
|
+
if (message.content != null) message.content = nativeTrim(message.content) === '' ? '' : 'x';
|
|
68
|
+
if (message.content_parts != null) message.content_parts = message.content_parts.length ? [null] : [];
|
|
69
|
+
for (const call of message.tool_calls ?? []) call.function.arguments = '';
|
|
70
|
+
message.metadata = Object.create(null);
|
|
71
|
+
return message;
|
|
72
|
+
}
|
|
73
|
+
|
|
18
74
|
export async function supportsClaudeReadIndex(path, { metrics } = {}) {
|
|
19
75
|
if (!(await sourceStat(path))?.isFile() || await looksLikeSessionSqlite(path, metrics)) return false;
|
|
20
76
|
return (await withSessionFile(path, async (reader) => {
|
|
@@ -43,16 +99,14 @@ class ClaudeReadIndex {
|
|
|
43
99
|
const graph = new ClaudeReplayIndex(), residueRecords = [];
|
|
44
100
|
for await (const line of sessionFileLines(this.#reader)) {
|
|
45
101
|
const index = this.#records.length;
|
|
46
|
-
|
|
102
|
+
let record = { offset: line.offset, length: line.length, assistant: false, error: false, messageId: null };
|
|
47
103
|
if (nativeTrim(line.text) !== '') {
|
|
48
104
|
let value;
|
|
49
105
|
try { value = parseNativeJson(line.text); } catch { this.#parseErrors++; }
|
|
50
106
|
if (value !== undefined) {
|
|
51
107
|
captureClaudeMeta(value, this.#meta, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text);
|
|
52
108
|
graph.observe(index, value);
|
|
53
|
-
record
|
|
54
|
-
record.error = field(value, 'isApiErrorMessage') === true;
|
|
55
|
-
record.messageId = stringField(field(value, 'message'), 'id');
|
|
109
|
+
record = recordFacts(line, value);
|
|
56
110
|
if (claudeResidueKind(value) != null) residueRecords.push(index);
|
|
57
111
|
}
|
|
58
112
|
}
|
|
@@ -64,18 +118,7 @@ class ClaudeReadIndex {
|
|
|
64
118
|
const value = parseNativeJson(raw);
|
|
65
119
|
captureNativeResidue(this.#meta, 'claude_code', index, raw, value, claudeResidueKind(value));
|
|
66
120
|
}
|
|
67
|
-
|
|
68
|
-
for (const index of selection.lines) {
|
|
69
|
-
const record = this.#records[index];
|
|
70
|
-
if (record.assistant && !record.error) {
|
|
71
|
-
if (pending.length && !(pendingId != null && pendingId === record.messageId)) { await this.#addGroup(pending); pending = []; }
|
|
72
|
-
pendingId = record.messageId; pending.push(index);
|
|
73
|
-
} else {
|
|
74
|
-
await this.#addGroup(pending); pending = []; pendingId = null;
|
|
75
|
-
if (!record.error || !record.assistant) await this.#addGroup([index]);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
await this.#addGroup(pending);
|
|
121
|
+
for (const group of claudeGroups(selection.lines, (index) => this.#records[index])) await this.#addGroup(group);
|
|
79
122
|
reorderClaudeToolResults(this.#skeleton); ensureToolResultsPaired(this.#skeleton);
|
|
80
123
|
}
|
|
81
124
|
|
|
@@ -84,33 +127,13 @@ class ClaudeReadIndex {
|
|
|
84
127
|
return decodeSessionUtf8(await this.#reader.read(record.offset, record.length));
|
|
85
128
|
}
|
|
86
129
|
|
|
87
|
-
|
|
88
|
-
const messages = []; let pending = null;
|
|
89
|
-
for (const index of group) {
|
|
90
|
-
const value = parseNativeJson(await this.#raw(index)), kind = stringField(value, 'type');
|
|
91
|
-
if (kind === 'assistant') {
|
|
92
|
-
if (pending == null) pending = value; else mergeClaudeAssistantChunk(pending, value);
|
|
93
|
-
} else {
|
|
94
|
-
const before = messages.length;
|
|
95
|
-
if (kind === 'user') pushClaudeUser(value, messages);
|
|
96
|
-
else if (kind === 'attachment') pushClaudeAttachment(value, messages);
|
|
97
|
-
else if (kind === 'system') pushClaudeSystem(value, messages);
|
|
98
|
-
captureClaudeProvenance(value, messages, before);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
flushClaudeAssistant(pending, messages);
|
|
102
|
-
return messages;
|
|
103
|
-
}
|
|
130
|
+
#decodeGroup(group) { return decodeClaudeGroup((index) => this.#raw(index), group); }
|
|
104
131
|
|
|
105
132
|
async #addGroup(group) {
|
|
106
|
-
if (!group.length) return;
|
|
107
133
|
const groupId = this.#groups.length, decoded = await this.#decodeGroup(group);
|
|
108
134
|
this.#groups.push(group);
|
|
109
135
|
decoded.forEach((message, ordinal) => {
|
|
110
|
-
|
|
111
|
-
if (message.content_parts != null) message.content_parts = message.content_parts.length ? [null] : [];
|
|
112
|
-
for (const call of message.tool_calls ?? []) call.function.arguments = '';
|
|
113
|
-
message.metadata = Object.create(null); message.metadata[REFERENCE] = `${groupId}:${ordinal}`;
|
|
136
|
+
skeletonMessage(message).metadata[REFERENCE] = `${groupId}:${ordinal}`;
|
|
114
137
|
this.#skeleton.push(message);
|
|
115
138
|
});
|
|
116
139
|
}
|
|
@@ -171,3 +194,158 @@ export async function withClaudeReadIndex(path, work, { fidelity = 'byte_lossles
|
|
|
171
194
|
return work(index);
|
|
172
195
|
}, metrics);
|
|
173
196
|
}
|
|
197
|
+
|
|
198
|
+
const REPLAYED = new Set(['user', 'assistant', 'attachment', 'system']);
|
|
199
|
+
const EDGE_BYTES = 64 * 1024, VERIFY_MS = 10 * 60_000;
|
|
200
|
+
const shortHash = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 24);
|
|
201
|
+
|
|
202
|
+
/** One line taken into a scan's state: its place among the file's lines, its header facts, its node in the graph,
|
|
203
|
+
* what it changes of the recorded profile and, for a record the replay can select, where it stands and the hash of
|
|
204
|
+
* its bytes. */
|
|
205
|
+
function observeClaudeLine(state, line) {
|
|
206
|
+
const index = state.lines++;
|
|
207
|
+
if (nativeTrim(line.text) === '') return;
|
|
208
|
+
let value;
|
|
209
|
+
try { value = parseNativeJson(line.text); } catch { state.parseErrors++; return; }
|
|
210
|
+
captureClaudeMeta(value, state.meta, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text);
|
|
211
|
+
state.graph.observe(index, value);
|
|
212
|
+
state.profile.record(index, value);
|
|
213
|
+
if (typeof value?.version === 'string') state.version = value.version;
|
|
214
|
+
if (REPLAYED.has(stringField(value, 'type'))) state.records.set(index, { ...recordFacts(line, value), hash: shortHash(line.bytes) });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A transcript's complete lines as one pass over them leaves them, kept between reads of the same file: the next
|
|
218
|
+
* read takes only the lines written since (`extend`). Nothing a record says is kept, only where it is and what it is
|
|
219
|
+
* to the replay. */
|
|
220
|
+
class ClaudeRecordScan {
|
|
221
|
+
records = new Map(); lines = 0; parseErrors = 0; version = null; bytes = 0;
|
|
222
|
+
graph = new ClaudeReplayIndex(); meta = createSession('claude_code').meta; profile = recordedProfileFold('claude_code');
|
|
223
|
+
skeletons = new Map(); view = null; hashed = createHash('sha256'); stamp = null; edge = null; size = 0; verified = Date.now();
|
|
224
|
+
|
|
225
|
+
constructor(reader) { this.device = reader.fingerprint.device; this.inode = reader.fingerprint.inode; }
|
|
226
|
+
|
|
227
|
+
/** Whether this is the scan of `reader`'s file: the same file, no shorter, its scanned bytes as they were. A file
|
|
228
|
+
* whose size and modification time are those last read is as it was. One that grew is an append while the last
|
|
229
|
+
* EDGE_BYTES the scan read are as they were (a bounded read), and is taken as one for at most VERIFY_MS after its
|
|
230
|
+
* bytes were last read through. Any other (written without growing, an edge that moved, or a growing file not read
|
|
231
|
+
* through for that long) is read through to where the scan stopped and compared by the hash of those bytes, no
|
|
232
|
+
* line decoded: a record rewritten in place, whatever its length, ends the scan and every mark made from it, at
|
|
233
|
+
* once when the file did not also grow, and within VERIFY_MS when it did. */
|
|
234
|
+
async holds(reader) {
|
|
235
|
+
if (reader.fingerprint.device !== this.device || reader.fingerprint.inode !== this.inode || reader.size < this.bytes) return false;
|
|
236
|
+
if (this.stamp === `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`) return true;
|
|
237
|
+
if (reader.size > this.size && Date.now() - this.verified < VERIFY_MS) return this.edge === await this.#edge(reader);
|
|
238
|
+
const hash = createHash('sha256');
|
|
239
|
+
for (let at = 0; at < this.bytes;) { const block = await reader.read(at, Math.min(1024 * 1024, this.bytes - at)); hash.update(block); at += block.length; }
|
|
240
|
+
this.verified = Date.now();
|
|
241
|
+
return hash.digest('hex') === this.hashed.copy().digest('hex');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #edge(reader) {
|
|
245
|
+
const from = Math.max(0, this.bytes - EDGE_BYTES);
|
|
246
|
+
return shortHash(await reader.read(from, this.bytes - from));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The lines completed since the last pass. A last line with no LF yet stays outside the scan: `view` reads it. */
|
|
250
|
+
async extend(reader) {
|
|
251
|
+
const from = this.bytes;
|
|
252
|
+
for await (const line of sessionFileLines(reader, this.bytes)) {
|
|
253
|
+
if (!line.terminated) break;
|
|
254
|
+
observeClaudeLine(this, line); this.hashed.update(line.bytes); this.bytes = line.offset + line.length;
|
|
255
|
+
}
|
|
256
|
+
if (this.bytes !== from || this.edge == null) this.edge = await this.#edge(reader);
|
|
257
|
+
this.size = reader.size; this.stamp = `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The replay of the file as `reader` holds it: its messages in order, each as its skeleton with the mark of the
|
|
261
|
+
* records that make it. A group's skeleton is decoded once and kept while the replay selects it. */
|
|
262
|
+
async project(reader, fidelity) {
|
|
263
|
+
const key = `${fidelity}:${this.bytes}:${reader.size}`;
|
|
264
|
+
if (this.view?.key === key) return this.view;
|
|
265
|
+
const state = { lines: this.lines, parseErrors: this.parseErrors, version: this.version, records: new Map(),
|
|
266
|
+
graph: this.graph.clone(), meta: cloneNativeJson(this.meta), profile: this.profile.clone() };
|
|
267
|
+
// The file's last line while it has no LF yet is read for this answer alone. Cut inside a character, it is a
|
|
268
|
+
// line still being written: counted as one that does not parse, as a cut record is.
|
|
269
|
+
if (reader.size > this.bytes) {
|
|
270
|
+
const bytes = await reader.read(this.bytes, reader.size - this.bytes); let text = null;
|
|
271
|
+
try { text = decodeSessionUtf8(bytes); } catch { state.lines++; state.parseErrors++; }
|
|
272
|
+
if (text != null) observeClaudeLine(state, { offset: this.bytes, length: bytes.length, text, bytes });
|
|
273
|
+
}
|
|
274
|
+
const recordOf = (index) => state.records.get(index) ?? this.records.get(index);
|
|
275
|
+
const raw = async (index, at) => { const record = recordOf(index); return decodeSessionUtf8(await at.read(record.offset, record.length)); };
|
|
276
|
+
const selection = state.graph.selectLines(fidelity), groups = [], sizes = [], skeleton = [], skeletons = new Map(), marks = new Map(), models = new Map();
|
|
277
|
+
for (const group of claudeGroups(selection.lines, recordOf)) {
|
|
278
|
+
const id = groups.length, records = group.map(recordOf), mark = shortHash(records.map((record) => record.hash).join(','));
|
|
279
|
+
let made = skeletons.get(mark) ?? this.skeletons.get(mark);
|
|
280
|
+
if (!made) made = (await decodeClaudeGroup((index) => raw(index, reader), group)).map((message) => ({ model: Object.hasOwn(message.metadata, 'model') ? message.metadata.model : null, message: skeletonMessage(message) }));
|
|
281
|
+
skeletons.set(mark, made); groups.push(group); sizes.push(records.reduce((sum, record) => sum + record.length, 0));
|
|
282
|
+
made.forEach(({ message, model }, ordinal) => {
|
|
283
|
+
const placed = { ...message, metadata: Object.create(null) }; placed.metadata[REFERENCE] = `${id}:${ordinal}`;
|
|
284
|
+
marks.set(placed, shortHash(`${mark}:${ordinal}`)); if (model != null) models.set(placed, model);
|
|
285
|
+
skeleton.push(placed);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
this.skeletons = skeletons;
|
|
289
|
+
reorderClaudeToolResults(skeleton); ensureToolResultsPaired(skeleton);
|
|
290
|
+
// A result the pairing pass made up has no record: its mark is of what it says.
|
|
291
|
+
return this.view = { key, raw, groups, sizes, skeleton, marks: skeleton.map((message) => marks.get(message) ?? shortHash(JSON.stringify(messageJson(message)))),
|
|
292
|
+
model: models.get(skeleton.findLast((message) => models.has(message))) ?? null, residue: selection.residue, meta: state.meta, lines: state.lines,
|
|
293
|
+
parseErrors: state.parseErrors, version: state.version, history: state.profile.history };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The scans of the files read most recently, at most this many records between them: a transcript read again is
|
|
298
|
+
// read from where its scan stopped, and one read long ago is scanned again. Of the files shorter than
|
|
299
|
+
// SCAN_KEPT_BYTES only the one read last is kept (a pass reads its file more than once): scanning a short file again
|
|
300
|
+
// costs less than keeping the scan of every one read.
|
|
301
|
+
const SCANNED_RECORDS = 400_000, SCAN_KEPT_BYTES = 8 * 1024 * 1024;
|
|
302
|
+
const scans = new Map();
|
|
303
|
+
|
|
304
|
+
async function scanOf(path, reader) {
|
|
305
|
+
let scan = scans.get(path); scans.delete(path);
|
|
306
|
+
if (!scan || !await scan.holds(reader)) scan = new ClaudeRecordScan(reader);
|
|
307
|
+
await scan.extend(reader);
|
|
308
|
+
if (scan.bytes < SCAN_KEPT_BYTES) for (const [other, item] of scans) if (item.bytes < SCAN_KEPT_BYTES) scans.delete(other);
|
|
309
|
+
scans.set(path, scan);
|
|
310
|
+
let held = 0; for (const item of scans.values()) held += item.lines;
|
|
311
|
+
for (const [other, item] of scans) { if (held <= SCANNED_RECORDS || item === scan) break; scans.delete(other); held -= item.lines; }
|
|
312
|
+
return scan;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A window of a Claude Code transcript's messages from the place `from`, read record by record: at most `messages` of
|
|
317
|
+
* them and `bytes` of transcript (one message at least), with each one's mark and, when `marks` is asked, the mark of
|
|
318
|
+
* every message of the session. A mark is of the bytes of the records that make a message: while it stands, so does
|
|
319
|
+
* what a reader took of that message before. Null when the locator names anything else, which is loaded as its
|
|
320
|
+
* document.
|
|
321
|
+
*/
|
|
322
|
+
export async function loadClaudeRecordWindow(locator, { from, bytes = null, messages = null, marks = false }, { fidelity = 'semantic', metrics } = {}) {
|
|
323
|
+
const { path, kind } = locator.storage;
|
|
324
|
+
if (kind !== 'file' || locator.harness === 'hermes' || isCompressedSessionPath(path)) return null;
|
|
325
|
+
if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
|
|
326
|
+
if (!await supportsClaudeReadIndex(path, { metrics })) return null;
|
|
327
|
+
return (await withSessionFile(path, async (reader) => {
|
|
328
|
+
let view;
|
|
329
|
+
try { view = await (await scanOf(path, reader)).project(reader, fidelity); }
|
|
330
|
+
catch (error) {
|
|
331
|
+
scans.delete(path);
|
|
332
|
+
throw ['ERR_ENCODING_INVALID_ENCODED_DATA', 'ERR_STRING_TOO_LONG'].includes(error?.code) ? sessionTextError(path, error) : error;
|
|
333
|
+
}
|
|
334
|
+
const total = view.skeleton.length, offset = Math.min(from, total), session = createSession('claude_code'), counted = new Set();
|
|
335
|
+
let end = offset, used = 0, decoded = null, decodedGroup = null;
|
|
336
|
+
for (; end < total && (messages == null || end - offset < messages); end++) {
|
|
337
|
+
const message = view.skeleton[end], reference = message.metadata[REFERENCE];
|
|
338
|
+
if (reference === undefined) { session.messages.push(cloneNativeJson(message)); continue; }
|
|
339
|
+
const [group, ordinal] = reference.split(':').map(Number), cost = counted.has(group) ? 0 : view.sizes[group];
|
|
340
|
+
if (bytes != null && end > offset && used + cost > bytes) break;
|
|
341
|
+
counted.add(group); used += cost;
|
|
342
|
+
if (decodedGroup !== group) { decoded = await decodeClaudeGroup((index) => view.raw(index, reader), view.groups[group]); decodedGroup = group; }
|
|
343
|
+
session.messages.push(cloneNativeJson(decoded[ordinal]));
|
|
344
|
+
}
|
|
345
|
+
session.meta = cloneNativeJson(view.meta); session.imported_message_count = total;
|
|
346
|
+
session.parse_error_lines = view.parseErrors; session.load_residue = [...view.residue];
|
|
347
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: view.lines,
|
|
348
|
+
resolved_profile: recordedSessionProfile(session, { model: view.model, version: view.version }), resolved_profile_history: view.history.map(cloneNativeJson),
|
|
349
|
+
window: { offset, returned: end - offset, total_messages: total, marks: view.marks.slice(offset, end), ...(marks ? { all_marks: [...view.marks] } : {}) } } };
|
|
350
|
+
}, metrics)).result;
|
|
351
|
+
}
|
|
@@ -15,6 +15,9 @@ export function fromCodexDisplayStr(text, messageLimit) {
|
|
|
15
15
|
if (!Number.isSafeInteger(messageLimit) || messageLimit < 0) throw new RangeError('invalid Codex display message limit');
|
|
16
16
|
const session = createSession('codex'), { meta, messages } = session, precedingUsers = [], canonicalTexts = new Set();
|
|
17
17
|
const retain = Math.min(Number.MAX_SAFE_INTEGER, Math.max(messageLimit, 1) + 64);
|
|
18
|
+
// The item ids and event texts the kept messages may hold: a record that names neither replaces nothing and looks
|
|
19
|
+
// through no message, so a read with no bound on its messages (a display anchor's) is linear in what it reads.
|
|
20
|
+
const displayItems = new Set(), eventTexts = new Set();
|
|
18
21
|
let paginated = false, recordCount = 0, total = 0;
|
|
19
22
|
for (const line of nonEmptyLines(text)) {
|
|
20
23
|
recordCount++;
|
|
@@ -24,12 +27,13 @@ export function fromCodexDisplayStr(text, messageLimit) {
|
|
|
24
27
|
else if (kind === 'event_msg' && paginated && type === 'item_completed') {
|
|
25
28
|
const item = field(payload, 'item'), id = stringField(item, 'id');
|
|
26
29
|
if (id != null) {
|
|
27
|
-
const position = messages.findIndex((message) => message.metadata.codex_display_item === id), projected = [];
|
|
30
|
+
const held = displayItems.has(id), position = held ? messages.findIndex((message) => message.metadata.codex_display_item === id) : -1, projected = [];
|
|
28
31
|
pushCodexDisplayItem(item, projected);
|
|
29
32
|
for (const message of projected) message.metadata.codex_display_item = id;
|
|
33
|
+
if (projected.length) displayItems.add(id);
|
|
30
34
|
stampCodexMessages(projected, 0, timestamp);
|
|
31
35
|
let removed = 0;
|
|
32
|
-
for (let index = messages.length - 1; index >= 0; index--) if (messages[index].metadata.codex_display_item === id) { messages.splice(index, 1); removed++; }
|
|
36
|
+
if (held) for (let index = messages.length - 1; index >= 0; index--) if (messages[index].metadata.codex_display_item === id) { messages.splice(index, 1); removed++; }
|
|
33
37
|
total = Math.max(0, total - removed) + projected.length;
|
|
34
38
|
const at = Math.min(position === -1 ? messages.length : position, messages.length);
|
|
35
39
|
for (let index = 0; index < projected.length; index++) messages.splice(at + index, 0, projected[index]);
|
|
@@ -47,7 +51,7 @@ export function fromCodexDisplayStr(text, messageLimit) {
|
|
|
47
51
|
const isAssistant = type === 'message' && stringField(payload, 'role') === 'assistant';
|
|
48
52
|
const assistantText = isAssistant ? extractTextContent(payload, 'content') : null;
|
|
49
53
|
if (assistantText != null && nativeTrim(assistantText) !== '') {
|
|
50
|
-
const duplicate = messages.findLastIndex((message) => Object.hasOwn(message.metadata, 'codex_event_message') && message.content === assistantText);
|
|
54
|
+
const duplicate = eventTexts.has(assistantText) ? messages.findLastIndex((message) => Object.hasOwn(message.metadata, 'codex_event_message') && message.content === assistantText) : -1;
|
|
51
55
|
if (duplicate !== -1) { messages.splice(duplicate, 1); total = Math.max(0, total - 1); }
|
|
52
56
|
canonicalTexts.add(nativeTrim(assistantText));
|
|
53
57
|
}
|
|
@@ -62,7 +66,7 @@ export function fromCodexDisplayStr(text, messageLimit) {
|
|
|
62
66
|
const text = extractTextContent(payload, 'message');
|
|
63
67
|
if (nativeTrim(text) !== '' && !canonicalTexts.has(nativeTrim(text))) {
|
|
64
68
|
const before = messages.length; pushCodexAssistant(messages, text); total++;
|
|
65
|
-
messages.at(-1).metadata.codex_event_message = 'true';
|
|
69
|
+
messages.at(-1).metadata.codex_event_message = 'true'; eventTexts.add(messages.at(-1).content);
|
|
66
70
|
const phase = stringField(payload, 'phase'); if (phase != null) messages.at(-1).metadata.phase = phase;
|
|
67
71
|
stampCodexMessages(messages, before, timestamp);
|
|
68
72
|
}
|
package/session-file.mjs
CHANGED
|
@@ -26,23 +26,30 @@ export async function sessionFileBytes(reader, from = 0) {
|
|
|
26
26
|
}
|
|
27
27
|
export const sessionFileText = async (reader) => decodeSessionUtf8(await sessionFileBytes(reader));
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
let
|
|
33
|
-
|
|
29
|
+
/** A line where it stands: its text is decoded when first read, so a reader that only places a line (a last one
|
|
30
|
+
* still being written) is not refused for bytes it never reads as text. */
|
|
31
|
+
function fileLine(offset, bytes, terminated) {
|
|
32
|
+
let text;
|
|
33
|
+
return { offset, length: bytes.length, bytes, terminated, get text() { return text ??= decodeSessionUtf8(bytes); } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One physical line at a time from `from` (the start of a line), including an unterminated final line. LF and
|
|
37
|
+
* CR remain in the bytes; offsets always address the verified open file. `bytes` is the line as it is stored and
|
|
38
|
+
* `terminated` says its LF was read: a last line without one may still be being written. */
|
|
39
|
+
export async function* sessionFileLines(reader, from = 0) {
|
|
40
|
+
let pieces = [], pending = 0, start = from;
|
|
41
|
+
for (let offset = from; offset < reader.size;) {
|
|
34
42
|
const chunk = await reader.read(offset, Math.min(64 * 1024, reader.size - offset));
|
|
35
43
|
const chunkStart = offset; offset += chunk.length;
|
|
36
|
-
let
|
|
37
|
-
while ((end = chunk.indexOf(10,
|
|
38
|
-
pieces.push(chunk.subarray(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
start = chunkStart + end + 1; from = end + 1; pieces = []; pending = 0;
|
|
44
|
+
let at = 0, end;
|
|
45
|
+
while ((end = chunk.indexOf(10, at)) !== -1) {
|
|
46
|
+
pieces.push(chunk.subarray(at, end + 1)); pending += end + 1 - at;
|
|
47
|
+
yield fileLine(start, pieces.length === 1 ? pieces[0] : Buffer.concat(pieces, pending), true);
|
|
48
|
+
start = chunkStart + end + 1; at = end + 1; pieces = []; pending = 0;
|
|
42
49
|
}
|
|
43
|
-
if (
|
|
50
|
+
if (at < chunk.length) { pieces.push(chunk.subarray(at)); pending += chunk.length - at; }
|
|
44
51
|
}
|
|
45
|
-
if (pending) yield
|
|
52
|
+
if (pending) yield fileLine(start, Buffer.concat(pieces, pending), false);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export async function readSessionText(path, metrics) {
|
package/session-model.d.ts
CHANGED
|
@@ -108,7 +108,7 @@ export function normalizedItemCount(messages: readonly ChatMessage[]): number;
|
|
|
108
108
|
export function projectedMessageWindow(total: number, options: SessionLoadOptions): [number, number];
|
|
109
109
|
export function projectInlineMedia(message: MessageJson, options: SessionLoadOptions): MessageJson;
|
|
110
110
|
export function projectedSessionSummary(session: Session, options: SessionLoadOptions): SessionSummary;
|
|
111
|
-
export function recordedSessionProfile(session: Session): RecordedSessionProfile;
|
|
111
|
+
export function recordedSessionProfile(session: Session, read?: { model: string | null; version: string | null } | null): RecordedSessionProfile;
|
|
112
112
|
export function recordedSessionProfileHistory(session: Session): RecordedSessionProfileChange[];
|
|
113
113
|
export function projectedSessionJson(session: Session, options: SessionLoadOptions): ProjectedSession;
|
|
114
114
|
export function projectedSessionResult(session: Session, options: SessionLoadOptions): ProjectedSessionResult;
|
package/session-read.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface SessionReadContext { cwd?: string; env?: Record<string, string
|
|
|
10
10
|
export function parseSessionWire(text: string): unknown;
|
|
11
11
|
export function stringifySessionWire(value: unknown): string;
|
|
12
12
|
export function prettySessionWire(value: unknown): string;
|
|
13
|
-
export function parseSessionReadParams(params: unknown, context?: SessionReadContext): { locator: ReadLocator; fidelity: string; view: (SessionFollowOptions & { fromByte: number | null }) | null; options: import('./session-model.js').SessionLoadOptions | null };
|
|
14
|
-
export function loadSessionRead(params: unknown, context?: SessionReadContext): Promise<ProjectedSessionResult | { session: NormalizedSession & { resolved_profile: RecordedSessionProfile; resolved_profile_history: RecordedSessionProfileChange[] } }>;
|
|
13
|
+
export function parseSessionReadParams(params: unknown, context?: SessionReadContext): { locator: ReadLocator; fidelity: string; view: (SessionFollowOptions & { fromByte: number | null; fromMessage: number | null; windowBytes: number | null; windowMessages: number | null; marks: boolean }) | null; options: import('./session-model.js').SessionLoadOptions | null };
|
|
14
|
+
export function loadSessionRead(params: unknown, context?: SessionReadContext): Promise<ProjectedSessionResult | { session: NormalizedSession & { resolved_profile: RecordedSessionProfile; resolved_profile_history: RecordedSessionProfileChange[]; window?: { offset: number; returned: number; total_messages: number; marks: string[]; all_marks?: string[] } } }>;
|
|
15
15
|
export function openSessionReadFollower(params: unknown, context?: SessionReadContext): Promise<SessionFollower>;
|
|
16
16
|
/** Recorded values only; malformed complete Claude runtime state leaves mode unknown. */
|
|
17
17
|
export function loadRecordedSessionConfiguration(params: unknown, context?: SessionReadContext): Promise<{ harness: string; session_id: string | null; cwd: unknown; model: unknown; approval_policy: unknown; sandbox_policy: unknown; permission_mode: string | null }>;
|
package/session-read.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson, stringifyNativeJsonPretty } from './native-json.mjs';
|
|
5
5
|
import { loadSessionLocator } from './session-path.mjs';
|
|
6
6
|
import { loadSessionDisplayLocator, loadSessionDisplayFrom } from './session-display.mjs';
|
|
7
|
-
import { withClaudeReadIndex, supportsClaudeReadIndex } from './session-claude-index.mjs';
|
|
7
|
+
import { withClaudeReadIndex, supportsClaudeReadIndex, loadClaudeRecordWindow } from './session-claude-index.mjs';
|
|
8
8
|
import { SessionFollower } from './session-follower.mjs';
|
|
9
9
|
import { normalizedSessionJson } from './session-model.mjs';
|
|
10
10
|
import { boundSessionView, projectedMessageWindow, projectedSessionJson, projectedSessionResult, projectedSessionSummary, recordedSessionProfile, recordedSessionProfileHistory } from './session-views.mjs';
|
|
@@ -58,7 +58,8 @@ export function parseSessionReadParams(params, { cwd = process.cwd() } = {}) {
|
|
|
58
58
|
const suppliedView = object(params.view, 'view');
|
|
59
59
|
view = { includeSubagents: boolean(suppliedView, 'include_subagents', false), displayHistory: boolean(suppliedView, 'display_history', false),
|
|
60
60
|
messageLimit: unsigned(suppliedView, 'tail_messages', 5000), maxMessageChars: unsigned(suppliedView, 'max_message_chars', 64000),
|
|
61
|
-
fromByte: unsigned(suppliedView, 'from_byte')
|
|
61
|
+
fromByte: unsigned(suppliedView, 'from_byte'), fromMessage: unsigned(suppliedView, 'from_message'),
|
|
62
|
+
windowBytes: unsigned(suppliedView, 'window_bytes'), windowMessages: unsigned(suppliedView, 'window_messages'), marks: boolean(suppliedView, 'marks', false) };
|
|
62
63
|
if (view.messageLimit != null) view.messageLimit = Math.max(1, view.messageLimit);
|
|
63
64
|
if (view.maxMessageChars != null) view.maxMessageChars = Math.max(256, view.maxMessageChars);
|
|
64
65
|
}
|
|
@@ -97,6 +98,11 @@ export async function loadSessionRead(params, context = {}) {
|
|
|
97
98
|
return projectedSessionResult(await loadSessionLocator(locator, { ...context, fidelity: 'byte_lossless' }), options);
|
|
98
99
|
}
|
|
99
100
|
const readOptions = { ...context, fidelity, includeSubagents: view?.includeSubagents ?? true };
|
|
101
|
+
// A view from a message on is a window of the session read record by record, where its reader has one.
|
|
102
|
+
if (view?.fromMessage != null && !view.includeSubagents && !view.displayHistory) {
|
|
103
|
+
const windowed = await loadClaudeRecordWindow(locator, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
|
|
104
|
+
if (windowed != null) return windowed;
|
|
105
|
+
}
|
|
100
106
|
const session = view?.displayHistory
|
|
101
107
|
? view.fromByte != null ? await loadSessionDisplayFrom(locator, view.fromByte, readOptions)
|
|
102
108
|
: await loadSessionDisplayLocator(locator, view.messageLimit ?? 500, readOptions)
|
package/session-records.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { restoreTopLevelResidue } from './session-native-residue.mjs';
|
|
|
17
17
|
import { isCompressedSessionPath, looksLikeSessionSqlite, sessionTextError } from './session-path.mjs';
|
|
18
18
|
|
|
19
19
|
async function* lines(reader) {
|
|
20
|
-
|
|
20
|
+
// a line's text is decoded when first read: here, so that what cannot be decoded is said as this file's
|
|
21
|
+
try { for await (const line of sessionFileLines(reader)) { void line.text; yield line; } }
|
|
21
22
|
catch (error) { throw ['ERR_ENCODING_INVALID_ENCODED_DATA', 'ERR_STRING_TOO_LONG'].includes(error?.code) ? sessionTextError(reader.path, error) : error; }
|
|
22
23
|
}
|
|
23
24
|
|
package/session-views.mjs
CHANGED
|
@@ -94,10 +94,12 @@ const payloadFor = (record) => has(record, 'payload') ? record.payload : record;
|
|
|
94
94
|
function parsedRecord(line) { try { return parseNativeJson(line); } catch { return null; } }
|
|
95
95
|
|
|
96
96
|
/** Only recorded facts. Current configuration and the reader's executable
|
|
97
|
-
* never fill unknown historical fields.
|
|
98
|
-
|
|
97
|
+
* never fill unknown historical fields. `read` gives what a reader that holds
|
|
98
|
+
* neither every message nor the raw lines found in them: the last recorded
|
|
99
|
+
* model and Claude Code version. */
|
|
100
|
+
export function recordedSessionProfile(session, read = null) {
|
|
99
101
|
const { meta } = session;
|
|
100
|
-
let model = session.messages.findLast((message) => has(message.metadata, 'model'))?.metadata.model ?? meta.model;
|
|
102
|
+
let model = (read ? read.model : session.messages.findLast((message) => has(message.metadata, 'model'))?.metadata.model) ?? meta.model;
|
|
101
103
|
let cwd = meta.cwd, version = null, approval = null, sandbox = null;
|
|
102
104
|
for (const header of meta.codex_headers) {
|
|
103
105
|
const payload = payloadFor(header);
|
|
@@ -108,7 +110,8 @@ export function recordedSessionProfile(session) {
|
|
|
108
110
|
if (has(payload, 'sandbox_policy')) sandbox = cloneNativeJson(payload.sandbox_policy);
|
|
109
111
|
}
|
|
110
112
|
if (meta.source === 'claude_code') {
|
|
111
|
-
|
|
113
|
+
if (read) version = read.version;
|
|
114
|
+
else for (let at = session.raw.length - 1; at >= 0; at--) {
|
|
112
115
|
const recorded = string(parsedRecord(session.raw[at])?.version);
|
|
113
116
|
if (recorded != null) { version = recorded; break; }
|
|
114
117
|
}
|
|
@@ -119,30 +122,37 @@ export function recordedSessionProfile(session) {
|
|
|
119
122
|
};
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/** The recorded profile as each record changes it, one record at a time: `history` names every record that changed it. */
|
|
126
|
+
export function recordedProfileFold(source, profile = {
|
|
127
|
+
worker: { harness: source === 'codex' ? 'codex' : 'claude-code', model: null, cwd: null, binary: { version: null } },
|
|
128
|
+
permissions: { native: { approval: null, sandbox: null } }, source: 'native-session',
|
|
129
|
+
}, history = []) {
|
|
130
|
+
return {
|
|
131
|
+
history,
|
|
132
|
+
clone: () => recordedProfileFold(source, cloneNativeJson(profile), [...history]),
|
|
133
|
+
record(index, record) {
|
|
134
|
+
if (record == null) return;
|
|
135
|
+
const before = cloneNativeJson(profile), payload = payloadFor(record);
|
|
136
|
+
for (const key of ['cwd', 'model']) {
|
|
137
|
+
if (typeof payload?.[key] === 'string') profile.worker[key] = payload[key];
|
|
138
|
+
}
|
|
139
|
+
const version = string(has(payload, 'cli_version') ? payload.cli_version : record?.version);
|
|
140
|
+
if (version != null) profile.worker.binary.version = version;
|
|
141
|
+
if (typeof record?.message?.model === 'string') profile.worker.model = record.message.model;
|
|
142
|
+
for (const [key, target] of [['approval_policy', 'approval'], ['sandbox_policy', 'sandbox']]) {
|
|
143
|
+
if (has(payload, key)) profile.permissions.native[target] = cloneNativeJson(payload[key]);
|
|
144
|
+
}
|
|
145
|
+
if (!isDeepStrictEqual(before, profile)) history.push({ native_key: `profile-record:${index}`, at: string(record?.timestamp), profile: cloneNativeJson(profile) });
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
export function recordedSessionProfileHistory(session) {
|
|
123
151
|
if (!['codex', 'claude_code'].includes(session.meta.source)) return [];
|
|
124
|
-
const
|
|
125
|
-
worker: { harness: session.meta.source === 'codex' ? 'codex' : 'claude-code', model: null, cwd: null, binary: { version: null } },
|
|
126
|
-
permissions: { native: { approval: null, sandbox: null } }, source: 'native-session',
|
|
127
|
-
};
|
|
152
|
+
const fold = recordedProfileFold(session.meta.source);
|
|
128
153
|
const records = session.meta.source === 'codex' ? session.meta.codex_headers : session.raw;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const record = session.meta.source === 'codex' ? records[index] : parsedRecord(records[index]);
|
|
132
|
-
if (record == null) continue;
|
|
133
|
-
const before = cloneNativeJson(profile), payload = payloadFor(record);
|
|
134
|
-
for (const key of ['cwd', 'model']) {
|
|
135
|
-
if (typeof payload?.[key] === 'string') profile.worker[key] = payload[key];
|
|
136
|
-
}
|
|
137
|
-
const version = string(has(payload, 'cli_version') ? payload.cli_version : record?.version);
|
|
138
|
-
if (version != null) profile.worker.binary.version = version;
|
|
139
|
-
if (typeof record?.message?.model === 'string') profile.worker.model = record.message.model;
|
|
140
|
-
for (const [key, target] of [['approval_policy', 'approval'], ['sandbox_policy', 'sandbox']]) {
|
|
141
|
-
if (has(payload, key)) profile.permissions.native[target] = cloneNativeJson(payload[key]);
|
|
142
|
-
}
|
|
143
|
-
if (!isDeepStrictEqual(before, profile)) history.push({ native_key: `profile-record:${index}`, at: string(record?.timestamp), profile: cloneNativeJson(profile) });
|
|
144
|
-
}
|
|
145
|
-
return history;
|
|
154
|
+
for (let index = 0; index < records.length; index++) fold.record(index, session.meta.source === 'codex' ? records[index] : parsedRecord(records[index]));
|
|
155
|
+
return fold.history;
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
export function projectedSessionJson(session, options) {
|