@volter/supercode-interchange 0.1.17 → 0.1.19

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.17",
3
+ "version": "0.1.19",
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",
@@ -0,0 +1,44 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const EDGE_BYTES = 64 * 1024, VERIFY_MS = 10 * 60_000;
4
+ export const shortHash = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 24);
5
+
6
+ /** What a pass over a file that is only added to keeps of where it read: the file, the bytes it read through (from
7
+ * `from` to `bytes`, each given to `hashed` as it was read) and how the file stood when it stopped. The next read of
8
+ * the same file takes only what was written since. */
9
+ export class AppendScan {
10
+ bytes = 0; from = 0; hashed = createHash('sha256'); stamp = null; edge = null; size = 0; verified = Date.now();
11
+
12
+ constructor(reader) { this.device = reader.fingerprint.device; this.inode = reader.fingerprint.inode; }
13
+
14
+ /** Whether this is the scan of `reader`'s file: the same file, no shorter, its scanned bytes as they were. A file
15
+ * whose size and modification time are those last read is as it was. One that grew is an append while the last
16
+ * EDGE_BYTES the scan read are as they were (a bounded read), and is taken as one for at most VERIFY_MS after its
17
+ * bytes were last read through. Any other (written without growing, an edge that moved, or a growing file not read
18
+ * through for that long) is read through to where the scan stopped and compared by the hash of those bytes, no
19
+ * line decoded: a record rewritten in place, whatever its length, ends the scan and every mark made from it, at
20
+ * once when the file did not also grow, and within VERIFY_MS when it did. */
21
+ async holds(reader) {
22
+ if (reader.fingerprint.device !== this.device || reader.fingerprint.inode !== this.inode || reader.size < this.bytes) return false;
23
+ if (this.unchanged(reader)) return true;
24
+ if (reader.size > this.size && Date.now() - this.verified < VERIFY_MS) return this.edge === await this.edgeOf(reader);
25
+ const hash = createHash('sha256');
26
+ for (let at = this.from; at < this.bytes;) { const block = await reader.read(at, Math.min(1024 * 1024, this.bytes - at)); hash.update(block); at += block.length; }
27
+ this.verified = Date.now();
28
+ return hash.digest('hex') === this.hashed.copy().digest('hex');
29
+ }
30
+
31
+ /** Whether the file's size and modification time are those this scan last read it at. */
32
+ unchanged(reader) { return this.stamp === `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`; }
33
+
34
+ async edgeOf(reader) {
35
+ const from = Math.max(this.from, this.bytes - EDGE_BYTES);
36
+ return shortHash(await reader.read(from, this.bytes - from));
37
+ }
38
+
39
+ /** The file as a pass that read on from `from` leaves it. */
40
+ async settle(reader, from) {
41
+ if (this.bytes !== from || this.edge == null) this.edge = await this.edgeOf(reader);
42
+ this.size = reader.size; this.stamp = `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`;
43
+ }
44
+ }
@@ -150,12 +150,15 @@ export function pushClaudeAttachment(value, out) {
150
150
  }
151
151
  if (text == null || nativeTrim(text) === '') return;
152
152
  const message = createMessage('user', text); message.metadata.attachmentType = kind;
153
+ Object.assign(message.metadata, claudeUserProvenance(attachment));
154
+ if (field(attachment, 'humanTurn') === true) message.metadata.origin = 'human';
153
155
  const mode = stringField(attachment, 'commandMode'); if (mode != null) message.metadata.commandMode = mode;
156
+ const messageId = stringField(attachment, 'msg_id'); if (messageId != null) message.metadata.native_msg_id = messageId;
154
157
  out.push(message);
155
158
  }
156
159
 
157
160
  export function captureClaudeProvenance(value, messages, from = 0) {
158
- const facts = [['timestamp', stringField(value, 'timestamp')], ['claude_uuid', stringField(value, 'uuid')], ['model', stringField(field(value, 'message'), 'model')]];
161
+ const facts = [['timestamp', stringField(value, 'timestamp')], ['claude_uuid', stringField(value, 'uuid')], ['native_msg_id', stringField(value, 'msg_id')], ['model', stringField(field(value, 'message'), 'model')]];
159
162
  for (let index = from; index < messages.length; index++) for (const [key, fact] of facts) {
160
163
  if (fact != null && !Object.hasOwn(messages[index].metadata, key)) messages[index].metadata[key] = fact;
161
164
  }
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { basename, dirname, extname, join } from 'node:path';
3
2
  import { withSessionFile } from './file-io.mjs';
4
3
  import { sessionFileLines, decodeSessionUtf8 } from './session-file.mjs';
@@ -15,6 +14,7 @@ import { captureClaudeProvenance, pushClaudeUser, pushClaudeAttachment, pushClau
15
14
  import { sourceStat } from './store-registry.mjs';
16
15
  import { isCompressedSessionPath, looksLikeSessionSqlite, sessionTextError } from './session-path.mjs';
17
16
  import { detectSessionSource } from './session-source.mjs';
17
+ import { AppendScan, shortHash } from './session-append-scan.mjs';
18
18
 
19
19
  const REFERENCE = 'supercode.internal.read_index';
20
20
 
@@ -196,8 +196,6 @@ export async function withClaudeReadIndex(path, work, { fidelity = 'byte_lossles
196
196
  }
197
197
 
198
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
199
 
202
200
  /** One line taken into a scan's state: its place among the file's lines, its header facts, its node in the graph,
203
201
  * what it changes of the recorded profile and, for a record the replay can select, where it stands and the hash of
@@ -217,34 +215,10 @@ function observeClaudeLine(state, line) {
217
215
  /** A transcript's complete lines as one pass over them leaves them, kept between reads of the same file: the next
218
216
  * read takes only the lines written since (`extend`). Nothing a record says is kept, only where it is and what it is
219
217
  * to the replay. */
220
- class ClaudeRecordScan {
221
- records = new Map(); lines = 0; parseErrors = 0; version = null; bytes = 0;
218
+ class ClaudeRecordScan extends AppendScan {
219
+ records = new Map(); lines = 0; parseErrors = 0; version = null;
222
220
  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
- }
221
+ skeletons = new Map(); view = null;
248
222
 
249
223
  /** The lines completed since the last pass. A last line with no LF yet stays outside the scan: `view` reads it. */
250
224
  async extend(reader) {
@@ -253,8 +227,7 @@ class ClaudeRecordScan {
253
227
  if (!line.terminated) break;
254
228
  observeClaudeLine(this, line); this.hashed.update(line.bytes); this.bytes = line.offset + line.length;
255
229
  }
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}`;
230
+ await this.settle(reader, from);
258
231
  }
259
232
 
260
233
  /** The replay of the file as `reader` holds it: its messages in order, each as its skeleton with the mark of the
@@ -9,68 +9,89 @@ import { codexUserContext } from './session-codex-context.mjs';
9
9
  import { pushCodexDisplayItem } from './session-codex-display-items.mjs';
10
10
  import { nativeTrim } from './text.mjs';
11
11
 
12
+ /** The display fold of a Codex rollout, one record at a time over the messages made so far (`session.messages`).
13
+ * Of a message made by an earlier record it reads only its display item, whether it is an event message of a text
14
+ * (`eventKey`), and, of the last one, all of it (a call joins its open turn): a reader that keeps the earlier ones
15
+ * without what they say (session-codex-window.mjs) folds the same way. `textKey` names a text the fold remembers
16
+ * (itself, or a digest for a reader that keeps none). `observe` answers where a display item's messages were placed
17
+ * when they were not appended. */
18
+ export function codexDisplayFold(session, textKey = (text) => text, held = null) {
19
+ const { meta, messages } = session, canonicalTexts = new Set(held?.canonicalTexts);
20
+ // The item ids and event texts the kept messages may hold: a record that names neither replaces nothing and looks
21
+ // through no message, so a read with no bound on its messages (a display anchor's) is linear in what it reads.
22
+ const displayItems = new Set(held?.displayItems), eventTexts = new Set(held?.eventTexts), events = held?.events ?? new WeakMap();
23
+ const eventKey = (message) => events.has(message) ? events.get(message) : typeof message.content === 'string' ? textKey(message.content) : undefined;
24
+ const fold = {
25
+ paginated: held?.paginated ?? false, recordCount: held?.recordCount ?? 0, total: held?.total ?? 0, events, eventKey,
26
+ clone: (other) => codexDisplayFold(other, textKey, { canonicalTexts, displayItems, eventTexts, events, paginated: fold.paginated, recordCount: fold.recordCount, total: fold.total }),
27
+ observe(line) {
28
+ fold.recordCount++;
29
+ let value; try { value = parseNativeJson(line); } catch { session.parse_error_lines++; return null; }
30
+ const kind = stringField(value, 'type'), payload = field(value, 'payload'), type = stringField(payload, 'type'), timestamp = stringField(value, 'timestamp');
31
+ if (kind === 'session_meta') { fold.paginated = stringField(payload, 'history_mode') === 'paginated'; captureCodexMeta(payload, meta); }
32
+ else if (kind === 'event_msg' && fold.paginated && type === 'item_completed') {
33
+ const item = field(payload, 'item'), id = stringField(item, 'id');
34
+ if (id != null) {
35
+ const held = displayItems.has(id), position = held ? messages.findIndex((message) => message.metadata.codex_display_item === id) : -1, projected = [];
36
+ pushCodexDisplayItem(item, projected);
37
+ for (const message of projected) message.metadata.codex_display_item = id;
38
+ if (projected.length) displayItems.add(id);
39
+ stampCodexMessages(projected, 0, timestamp);
40
+ let removed = 0;
41
+ if (held) for (let index = messages.length - 1; index >= 0; index--) if (messages[index].metadata.codex_display_item === id) { messages.splice(index, 1); removed++; }
42
+ fold.total = Math.max(0, fold.total - removed) + projected.length;
43
+ const at = Math.min(position === -1 ? messages.length : position, messages.length);
44
+ for (let index = 0; index < projected.length; index++) messages.splice(at + index, 0, projected[index]);
45
+ return { at, count: projected.length };
46
+ }
47
+ } else if (['response_item', 'event_msg'].includes(kind) && fold.paginated) { /* Model stream is separate from display history. */ }
48
+ else if (kind === 'turn_context' && meta.model == null) meta.model = stringField(payload, 'model');
49
+ else if (kind === 'event_msg' && type === 'user_message') {
50
+ const urls = [];
51
+ for (const key of ['images', 'local_images']) {
52
+ const images = field(payload, key); if (Array.isArray(images)) for (const image of images) if (typeof image === 'string') urls.push(image);
53
+ }
54
+ const text = stringField(payload, 'message') ?? '';
55
+ messages.push(urls.length ? codexUserWithImages(text, urls) : createMessage('user', text)); fold.total++;
56
+ } else if (kind === 'response_item' && type !== 'reasoning') {
57
+ const isAssistant = type === 'message' && stringField(payload, 'role') === 'assistant';
58
+ const assistantText = isAssistant ? extractTextContent(payload, 'content') : null;
59
+ if (assistantText != null && nativeTrim(assistantText) !== '') {
60
+ const key = textKey(assistantText);
61
+ const duplicate = eventTexts.has(key) ? messages.findLastIndex((message) => Object.hasOwn(message.metadata, 'codex_event_message') && eventKey(message) === key) : -1;
62
+ if (duplicate !== -1) { messages.splice(duplicate, 1); fold.total = Math.max(0, fold.total - 1); }
63
+ canonicalTexts.add(textKey(nativeTrim(assistantText)));
64
+ }
65
+ const before = messages.length;
66
+ if (type === 'message' && stringField(payload, 'role') === 'user') {
67
+ const context = codexUserContext(payload);
68
+ if (context.kind === 'hook_prompt') pushCodexDisplayItem(context.item, messages);
69
+ } else pushCodexItem(payload, messages);
70
+ fold.total += Math.max(0, messages.length - before); stampCodexMessages(messages, before, timestamp);
71
+ if (messages.length === before + 1) restoreGrokMessageExtension(payload, messages[before]);
72
+ } else if (kind === 'event_msg' && type === 'agent_message') {
73
+ const text = extractTextContent(payload, 'message');
74
+ if (nativeTrim(text) !== '' && !canonicalTexts.has(textKey(nativeTrim(text)))) {
75
+ const before = messages.length; pushCodexAssistant(messages, text); fold.total++;
76
+ messages.at(-1).metadata.codex_event_message = 'true'; eventTexts.add(textKey(messages.at(-1).content));
77
+ const phase = stringField(payload, 'phase'); if (phase != null) messages.at(-1).metadata.phase = phase;
78
+ stampCodexMessages(messages, before, timestamp);
79
+ }
80
+ }
81
+ return null;
82
+ },
83
+ };
84
+ return fold;
85
+ }
86
+
12
87
  /** Bounded human-visible history, never a continuation or export artifact.
13
88
  * Durable TurnItems and legacy typed-user events retain their distinct paths. */
14
89
  export function fromCodexDisplayStr(text, messageLimit) {
15
90
  if (!Number.isSafeInteger(messageLimit) || messageLimit < 0) throw new RangeError('invalid Codex display message limit');
16
- const session = createSession('codex'), { meta, messages } = session, precedingUsers = [], canonicalTexts = new Set();
91
+ const session = createSession('codex'), { messages } = session, precedingUsers = [], fold = codexDisplayFold(session);
17
92
  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();
21
- let paginated = false, recordCount = 0, total = 0;
22
93
  for (const line of nonEmptyLines(text)) {
23
- recordCount++;
24
- let value; try { value = parseNativeJson(line); } catch { session.parse_error_lines++; continue; }
25
- const kind = stringField(value, 'type'), payload = field(value, 'payload'), type = stringField(payload, 'type'), timestamp = stringField(value, 'timestamp');
26
- if (kind === 'session_meta') { paginated = stringField(payload, 'history_mode') === 'paginated'; captureCodexMeta(payload, meta); }
27
- else if (kind === 'event_msg' && paginated && type === 'item_completed') {
28
- const item = field(payload, 'item'), id = stringField(item, 'id');
29
- if (id != null) {
30
- const held = displayItems.has(id), position = held ? messages.findIndex((message) => message.metadata.codex_display_item === id) : -1, projected = [];
31
- pushCodexDisplayItem(item, projected);
32
- for (const message of projected) message.metadata.codex_display_item = id;
33
- if (projected.length) displayItems.add(id);
34
- stampCodexMessages(projected, 0, timestamp);
35
- let removed = 0;
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++; }
37
- total = Math.max(0, total - removed) + projected.length;
38
- const at = Math.min(position === -1 ? messages.length : position, messages.length);
39
- for (let index = 0; index < projected.length; index++) messages.splice(at + index, 0, projected[index]);
40
- }
41
- } else if (['response_item', 'event_msg'].includes(kind) && paginated) { /* Model stream is separate from display history. */ }
42
- else if (kind === 'turn_context' && meta.model == null) meta.model = stringField(payload, 'model');
43
- else if (kind === 'event_msg' && type === 'user_message') {
44
- const urls = [];
45
- for (const key of ['images', 'local_images']) {
46
- const images = field(payload, key); if (Array.isArray(images)) for (const image of images) if (typeof image === 'string') urls.push(image);
47
- }
48
- const text = stringField(payload, 'message') ?? '';
49
- messages.push(urls.length ? codexUserWithImages(text, urls) : createMessage('user', text)); total++;
50
- } else if (kind === 'response_item' && type !== 'reasoning') {
51
- const isAssistant = type === 'message' && stringField(payload, 'role') === 'assistant';
52
- const assistantText = isAssistant ? extractTextContent(payload, 'content') : null;
53
- if (assistantText != null && nativeTrim(assistantText) !== '') {
54
- const duplicate = eventTexts.has(assistantText) ? messages.findLastIndex((message) => Object.hasOwn(message.metadata, 'codex_event_message') && message.content === assistantText) : -1;
55
- if (duplicate !== -1) { messages.splice(duplicate, 1); total = Math.max(0, total - 1); }
56
- canonicalTexts.add(nativeTrim(assistantText));
57
- }
58
- const before = messages.length;
59
- if (type === 'message' && stringField(payload, 'role') === 'user') {
60
- const context = codexUserContext(payload);
61
- if (context.kind === 'hook_prompt') pushCodexDisplayItem(context.item, messages);
62
- } else pushCodexItem(payload, messages);
63
- total += Math.max(0, messages.length - before); stampCodexMessages(messages, before, timestamp);
64
- if (messages.length === before + 1) restoreGrokMessageExtension(payload, messages[before]);
65
- } else if (kind === 'event_msg' && type === 'agent_message') {
66
- const text = extractTextContent(payload, 'message');
67
- if (nativeTrim(text) !== '' && !canonicalTexts.has(nativeTrim(text))) {
68
- const before = messages.length; pushCodexAssistant(messages, text); total++;
69
- messages.at(-1).metadata.codex_event_message = 'true'; eventTexts.add(messages.at(-1).content);
70
- const phase = stringField(payload, 'phase'); if (phase != null) messages.at(-1).metadata.phase = phase;
71
- stampCodexMessages(messages, before, timestamp);
72
- }
73
- }
94
+ fold.observe(line);
74
95
  if (messages.length > retain) {
75
96
  for (const message of messages.splice(0, messages.length - retain)) {
76
97
  if (message.role !== 'user') continue;
@@ -79,7 +100,7 @@ export function fromCodexDisplayStr(text, messageLimit) {
79
100
  }
80
101
  }
81
102
  clearCodexBookkeeping(messages, { display: true }); truncateMessagesWithAnchor(messages, messageLimit, precedingUsers);
82
- session.raw = Array(recordCount).fill(''); session.raw_trailing_newline = text.endsWith('\n'); session.imported_message_count = total;
103
+ session.raw = Array(fold.recordCount).fill(''); session.raw_trailing_newline = text.endsWith('\n'); session.imported_message_count = fold.total;
83
104
  session.load_residue = ['display history is a bounded native-record projection, not resumable model context'];
84
105
  return session;
85
106
  }
@@ -0,0 +1,212 @@
1
+ import { withSessionFile } from './file-io.mjs';
2
+ import { sessionFileLines, decodeSessionUtf8 } from './session-file.mjs';
3
+ import { cloneNativeJson } from './native-json.mjs';
4
+ import { createSession, normalizedSessionJson } from './session-model.mjs';
5
+ import { messageJson } from './message.mjs';
6
+ import { recordedSessionProfile, recordedSessionProfileHistory } from './session-views.mjs';
7
+ import { nativeTrim } from './text.mjs';
8
+ import { sourceStat } from './store-registry.mjs';
9
+ import { isCompressedSessionPath, looksLikeSessionSqlite } from './session-path.mjs';
10
+ import { detectSessionSource } from './session-source.mjs';
11
+ import { codexDisplayFold } from './session-codex-display.mjs';
12
+ import { clearCodexBookkeeping } from './session-codex-items.mjs';
13
+ import { AppendScan, shortHash } from './session-append-scan.mjs';
14
+
15
+ // What a message of the fold is made of, for a message held whole and for one kept without what it says: the records
16
+ // that made it (where each stands: the one that made it, then each whose call joined its open turn), its place among
17
+ // the messages its first record made, whether the rollout was paginated there, and, once kept, its mark: the digest
18
+ // of the message as a read answers it.
19
+ const made = new WeakMap();
20
+ // All the fold reads of a message an earlier record made (codexDisplayFold), and the model a profile reads.
21
+ const FOLDED = ['codex_display_item', 'codex_event_message', '__codex_open_turn', 'turn_id', 'model'];
22
+
23
+ /** A message as a read answers it: the fold's own notes on it cleared, as they are when a whole read ends. */
24
+ function answered(message) {
25
+ const sent = { ...message, metadata: cloneNativeJson(message.metadata) };
26
+ clearCodexBookkeeping([sent], { display: true });
27
+ return sent;
28
+ }
29
+ const markOf = (message) => shortHash(JSON.stringify(messageJson(answered(message))));
30
+
31
+ /** The messages an entry's records make, as the fold made them: read again from where the records stand. */
32
+ async function replay(entry, reader) {
33
+ const session = createSession('codex'), fold = codexDisplayFold(session, shortHash);
34
+ fold.paginated = entry.paginated;
35
+ for (const record of entry.records) fold.observe(nativeTrim(decodeSessionUtf8(await reader.read(record.offset, record.length))));
36
+ return session.messages;
37
+ }
38
+
39
+ /** The display fold over records taken one at a time, its messages kept without what they say: every message but
40
+ * the last (a call may still join its open turn) is replaced, once a later one follows it, by its role, the notes the
41
+ * fold reads and its mark. What a kept message says is read again from its records (`replay`). */
42
+ class CodexDisplayState {
43
+ constructor(session = createSession('codex'), fold = codexDisplayFold(session, shortHash), modelled = false) {
44
+ this.session = session; this.fold = fold; this.modelled = modelled;
45
+ }
46
+
47
+ /** A state a record can be taken into without this one changing: for a last line that is still being written. */
48
+ fork() {
49
+ const messages = this.session.messages.slice(), last = messages.at(-1), entry = last && made.get(last);
50
+ if (entry && entry.mark == null) {
51
+ const copy = cloneNativeJson(last); made.set(copy, { ...entry, records: entry.records.slice() }); messages[messages.length - 1] = copy;
52
+ }
53
+ const session = { ...this.session, meta: cloneNativeJson(this.session.meta), messages };
54
+ return new CodexDisplayState(session, this.fold.clone(session), this.modelled);
55
+ }
56
+
57
+ async observe(record, text, reader) {
58
+ const { messages } = this.session; let live = messages.at(-1);
59
+ // A kept message that is last again (those after it were replaced) with its turn open is made again whole
60
+ // before a call may join it.
61
+ if (live && made.get(live).mark != null && live.role === 'assistant' && Object.hasOwn(live.metadata, '__codex_open_turn')) {
62
+ const entry = made.get(live); live = (await replay(entry, reader))[entry.ordinal];
63
+ made.set(live, { ...entry, mark: null }); messages[messages.length - 1] = live;
64
+ }
65
+ const calls = live?.tool_calls?.length ?? 0, placed = this.fold.observe(text);
66
+ if (live && messages.at(-1) === live && (live.tool_calls?.length ?? 0) !== calls) { const entry = made.get(live); entry.records.push(record); entry.bytes += record.length; }
67
+ let from = placed ? placed.at : messages.length; const end = placed ? placed.at + placed.count : messages.length;
68
+ if (!placed) while (from > 0 && !made.has(messages[from - 1])) from--;
69
+ for (let at = from; at < end; at++) made.set(messages[at], { records: [record], ordinal: at - from, paginated: this.fold.paginated, bytes: record.length, mark: null });
70
+ // The message that was last, where it stands now if it was not replaced: before what this record appended.
71
+ const before = end === messages.length ? from - 1 : messages.length - 1;
72
+ if (live && before >= 0 && before < messages.length - 1 && messages[before] === live && made.get(live).mark == null) this.#keep(before);
73
+ for (let at = from; at < end && at < messages.length - 1; at++) this.#keep(at);
74
+ }
75
+
76
+ #keep(at) {
77
+ const { messages } = this.session, whole = messages[at], metadata = Object.create(null);
78
+ for (const key of FOLDED) if (Object.hasOwn(whole.metadata, key)) metadata[key] = whole.metadata[key];
79
+ const kept = { role: whole.role, metadata };
80
+ if (Object.hasOwn(metadata, 'codex_event_message')) this.fold.events.set(kept, this.fold.eventKey(whole));
81
+ if (Object.hasOwn(metadata, 'model')) this.modelled = true;
82
+ made.set(kept, { ...made.get(whole), mark: markOf(whole) }); messages[at] = kept;
83
+ }
84
+
85
+ /** The mark of every message, and the model the session's profile reads: that of the last message naming one. */
86
+ view() {
87
+ const { messages } = this.session, last = messages.at(-1);
88
+ const modelled = this.modelled || (last != null && Object.hasOwn(last.metadata, 'model'));
89
+ return { state: this, messages, marks: messages.map((message) => made.get(message).mark ?? markOf(message)),
90
+ model: modelled ? messages.findLast((message) => Object.hasOwn(message.metadata, 'model'))?.metadata.model ?? null : null };
91
+ }
92
+ }
93
+
94
+ /** A rollout's complete lines from its display anchor as one pass over them leaves them, kept between reads of the
95
+ * same file: the next read takes only the lines written since (`extend`). The anchor's read is the header and every
96
+ * line after the record the anchor cuts (session-display.mjs loadSessionDisplayFrom): `head` is the header as it was
97
+ * read, and the bytes read through start after the cut record. */
98
+ class CodexDisplayScan extends AppendScan {
99
+ state = new CodexDisplayState(); head = null; kept = null;
100
+
101
+ constructor(reader, anchor) { super(reader); this.anchor = anchor; }
102
+
103
+ async holds(reader) {
104
+ if (this.head == null || !await super.holds(reader)) return false;
105
+ return this.unchanged(reader) || this.head.hash === shortHash(await reader.read(0, this.head.length));
106
+ }
107
+
108
+ async #take(state, line, reader) {
109
+ let text;
110
+ try { text = line.text; } catch (error) { throw new Error(`${reader.path} contains non-UTF-8 data after its display anchor`, { cause: error }); }
111
+ const trimmed = nativeTrim(text);
112
+ if (trimmed !== '') await state.observe({ offset: line.offset, length: line.length }, trimmed, reader);
113
+ }
114
+
115
+ /** The lines completed since the last pass. A last line with no LF yet stays outside the scan: `project` reads it. */
116
+ async extend(reader, first) {
117
+ const from = this.bytes;
118
+ if (this.head == null) {
119
+ if (!first.terminated) return;
120
+ let start = null;
121
+ for (let at = this.anchor; at < reader.size && start == null;) {
122
+ const block = await reader.read(at, Math.min(64 * 1024, reader.size - at)), newline = block.indexOf(10);
123
+ if (newline !== -1) start = at + newline + 1;
124
+ at += block.length;
125
+ }
126
+ if (start == null) return;
127
+ await this.#take(this.state, first, reader);
128
+ this.head = { length: first.length, hash: shortHash(first.bytes) }; this.from = this.bytes = start;
129
+ }
130
+ for await (const line of sessionFileLines(reader, this.bytes)) {
131
+ if (!line.terminated) break;
132
+ await this.#take(this.state, line, reader); this.hashed.update(line.bytes); this.bytes = line.offset + line.length;
133
+ }
134
+ await this.settle(reader, from);
135
+ }
136
+
137
+ /** The fold of the file as `reader` holds it. A last line with no LF yet is taken for this answer alone; cut inside
138
+ * a character, it is a line still being written: counted as one that does not parse, as a cut record is. */
139
+ async project(reader, first) {
140
+ if (this.head != null && reader.size === this.bytes) return this.kept?.bytes === this.bytes ? this.kept.view : (this.kept = { bytes: this.bytes, view: this.state.view() }).view;
141
+ const state = this.head == null ? new CodexDisplayState() : this.state.fork();
142
+ if (this.head == null) await this.#take(state, first, reader);
143
+ else {
144
+ const bytes = await reader.read(this.bytes, reader.size - this.bytes); let text = null;
145
+ try { text = decodeSessionUtf8(bytes); } catch { state.fold.recordCount++; state.session.parse_error_lines++; }
146
+ if (text != null && nativeTrim(text) !== '') await state.observe({ offset: this.bytes, length: bytes.length }, nativeTrim(text), reader);
147
+ }
148
+ return state.view();
149
+ }
150
+ }
151
+
152
+ // The scans of the rollouts read most recently, at most this many messages between them (about 630 bytes a message
153
+ // kept: 126 MB): a rollout read again is read from where its scan stopped, and one read long ago is scanned again.
154
+ // Of the files whose scan read less than SCAN_KEPT_BYTES only the one read last is kept: scanning a short file again
155
+ // costs less than keeping every one read.
156
+ const SCANNED_MESSAGES = 200_000, SCAN_KEPT_BYTES = 8 * 1024 * 1024;
157
+ const scans = new Map();
158
+ const scanned = (scan) => scan.bytes - scan.from;
159
+
160
+ async function scanOf(path, reader, anchor, first) {
161
+ let scan = scans.get(path); scans.delete(path);
162
+ if (scan?.anchor !== anchor || !await scan.holds(reader)) scan = new CodexDisplayScan(reader, anchor);
163
+ await scan.extend(reader, first);
164
+ if (scanned(scan) < SCAN_KEPT_BYTES) for (const [other, item] of scans) if (scanned(item) < SCAN_KEPT_BYTES) scans.delete(other);
165
+ scans.set(path, scan);
166
+ let held = 0; for (const item of scans.values()) held += item.state.session.messages.length;
167
+ for (const [other, item] of scans) { if (held <= SCANNED_MESSAGES || item === scan) break; scans.delete(other); held -= item.state.session.messages.length; }
168
+ return scan;
169
+ }
170
+
171
+ /**
172
+ * A window of a Codex rollout's display history as read from the byte `anchor` (loadSessionDisplayFrom), from the
173
+ * message at place `from`: at most `messages` of them and `bytes` of rollout (one message at least), with each one's
174
+ * mark and, when `marks` is asked, the mark of every message of that read. The rollout is read a line at a time and
175
+ * only as far as it was not read before; a message is the same one the whole read answers at that place, and its
176
+ * mark is the digest of it: while it stands at a place, so does what a reader took of that message there. Null when
177
+ * the locator names anything else, which is loaded as its document.
178
+ */
179
+ export async function loadCodexRecordWindow(locator, anchor, { from, bytes = null, messages = null, marks = false }, { metrics } = {}) {
180
+ const { path, kind } = locator.storage;
181
+ if (kind !== 'file' || locator.harness === 'hermes' || anchor === 0 || isCompressedSessionPath(path)) return null;
182
+ if ((await sourceStat(path))?.isDirectory() || await looksLikeSessionSqlite(path, metrics)) return null;
183
+ return (await withSessionFile(path, async (reader) => {
184
+ let first = null;
185
+ for await (const line of sessionFileLines(reader)) { first = line; break; }
186
+ if (first == null || detectSessionSource(first.text) !== 'codex') return null;
187
+ if (anchor >= reader.size) throw new Error(`${path} is shorter than its display anchor (${anchor})`);
188
+ try {
189
+ const view = await (await scanOf(path, reader, anchor, first)).project(reader, first);
190
+ const total = view.messages.length, offset = Math.min(from, total), session = createSession('codex'), counted = new Set();
191
+ let end = offset, used = 0, again = null, againOf = null;
192
+ for (; end < total && (messages == null || end - offset < messages); end++) {
193
+ const message = view.messages[end], entry = made.get(message), record = entry.records[0].offset;
194
+ const cost = counted.has(record) ? 0 : entry.bytes;
195
+ if (bytes != null && end > offset && used + cost > bytes) break;
196
+ counted.add(record); used += cost;
197
+ if (entry.mark == null) { session.messages.push(answered(message)); continue; }
198
+ const key = `${record}:${entry.records.length}`;
199
+ if (againOf !== key) { again = await replay(entry, reader); againOf = key; }
200
+ const whole = again[entry.ordinal];
201
+ if (whole == null || markOf(whole) !== entry.mark) throw new Error(`${path} is no longer the rollout its scan read`);
202
+ session.messages.push(answered(whole));
203
+ }
204
+ const { state } = view;
205
+ session.meta = cloneNativeJson(state.session.meta); session.imported_message_count = total; session.parse_error_lines = state.session.parse_error_lines;
206
+ session.load_residue = ['display history is a bounded native-record projection, not resumable model context', `older native records remain before this display anchor (${anchor})`];
207
+ return { session: { ...normalizedSessionJson(session), raw_record_count: state.fold.recordCount,
208
+ resolved_profile: recordedSessionProfile(session, { model: view.model, version: null }), resolved_profile_history: recordedSessionProfileHistory(session),
209
+ window: { offset, returned: end - offset, total_messages: total, marks: view.marks.slice(offset, end), ...(marks ? { all_marks: view.marks } : {}) } } };
210
+ } catch (error) { scans.delete(path); throw error; }
211
+ }, metrics)).result;
212
+ }
package/session-read.mjs CHANGED
@@ -5,6 +5,7 @@ import { nativeInteger, nativeNumberKind, parseNativeJson, stringifyNativeJson,
5
5
  import { loadSessionLocator } from './session-path.mjs';
6
6
  import { loadSessionDisplayLocator, loadSessionDisplayFrom } from './session-display.mjs';
7
7
  import { withClaudeReadIndex, supportsClaudeReadIndex, loadClaudeRecordWindow } from './session-claude-index.mjs';
8
+ import { loadCodexRecordWindow } from './session-codex-window.mjs';
8
9
  import { SessionFollower } from './session-follower.mjs';
9
10
  import { normalizedSessionJson } from './session-model.mjs';
10
11
  import { boundSessionView, projectedMessageWindow, projectedSessionJson, projectedSessionResult, projectedSessionSummary, recordedSessionProfile, recordedSessionProfileHistory } from './session-views.mjs';
@@ -103,6 +104,11 @@ export async function loadSessionRead(params, context = {}) {
103
104
  const windowed = await loadClaudeRecordWindow(locator, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
104
105
  if (windowed != null) return windowed;
105
106
  }
107
+ // So is a view from a message on of a Codex rollout's display history read from an anchor.
108
+ if (view?.fromMessage != null && !view.includeSubagents && view.displayHistory && view.fromByte != null) {
109
+ const windowed = await loadCodexRecordWindow(locator, view.fromByte, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
110
+ if (windowed != null) return windowed;
111
+ }
106
112
  const session = view?.displayHistory
107
113
  ? view.fromByte != null ? await loadSessionDisplayFrom(locator, view.fromByte, readOptions)
108
114
  : await loadSessionDisplayLocator(locator, view.messageLimit ?? 500, readOptions)