@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-interchange",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "description": "Read-only harness metadata, transcript codecs and session projections for Supercode",
6
6
  "license": "MIT OR Apache-2.0",
@@ -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 { messageHasContent } from './session-views.mjs';
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
- const record = { offset: line.offset, length: line.length, assistant: false, error: false, messageId: null };
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.assistant = stringField(value, 'type') === 'assistant';
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
- let pending = [], pendingId = null;
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
- async #decodeGroup(group) {
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
- if (message.content != null) message.content = nativeTrim(message.content) === '' ? '' : 'x';
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
- /** One physical line at a time, including an unterminated final line. LF and
30
- * CR remain in the bytes; offsets always address the verified open file. */
31
- export async function* sessionFileLines(reader) {
32
- let pieces = [], pending = 0, start = 0;
33
- for (let offset = 0; offset < reader.size;) {
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 from = 0, end;
37
- while ((end = chunk.indexOf(10, from)) !== -1) {
38
- pieces.push(chunk.subarray(from, end + 1)); pending += end + 1 - from;
39
- const bytes = pieces.length === 1 ? pieces[0] : Buffer.concat(pieces, pending);
40
- yield { offset: start, length: pending, text: decodeSessionUtf8(bytes) };
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 (from < chunk.length) { pieces.push(chunk.subarray(from)); pending += chunk.length - from; }
50
+ if (at < chunk.length) { pieces.push(chunk.subarray(at)); pending += chunk.length - at; }
44
51
  }
45
- if (pending) yield { offset: start, length: pending, text: decodeSessionUtf8(Buffer.concat(pieces, pending)) };
52
+ if (pending) yield fileLine(start, Buffer.concat(pieces, pending), false);
46
53
  }
47
54
 
48
55
  export async function readSessionText(path, metrics) {
@@ -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)
@@ -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
- try { yield* sessionFileLines(reader); }
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
- export function recordedSessionProfile(session) {
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
- for (let at = session.raw.length - 1; at >= 0; at--) {
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 profile = {
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
- const history = [];
130
- for (let index = 0; index < records.length; index++) {
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) {