@volter/supercode-interchange 0.1.16 → 0.1.18
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-append-scan.mjs +44 -0
- package/session-claude-index.mjs +192 -41
- package/session-codex-display.mjs +78 -57
- package/session-codex-window.mjs +212 -0
- package/session-file.mjs +20 -13
- package/session-model.d.ts +1 -1
- package/session-read.d.ts +2 -2
- package/session-read.mjs +14 -2
- package/session-records.mjs +2 -1
- package/session-views.mjs +35 -25
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/session-claude-index.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
1
2
|
import { withSessionFile } from './file-io.mjs';
|
|
2
3
|
import { sessionFileLines, decodeSessionUtf8 } from './session-file.mjs';
|
|
3
4
|
import { parseNativeJson, cloneNativeJson } from './native-json.mjs';
|
|
4
|
-
import { createSession } from './session-model.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { createSession, normalizedSessionJson } from './session-model.mjs';
|
|
6
|
+
import { messageJson } from './message.mjs';
|
|
7
|
+
import { messageHasContent, recordedProfileFold, recordedSessionProfile } from './session-views.mjs';
|
|
6
8
|
import { nativeTrim } from './text.mjs';
|
|
7
9
|
import { field, stringField, ensureToolResultsPaired } from './session-codec-utils.mjs';
|
|
8
10
|
import { captureNativeResidue, claudeResidueKind } from './session-native-residue.mjs';
|
|
@@ -10,11 +12,65 @@ import { captureClaudeMeta } from './session-claude.mjs';
|
|
|
10
12
|
import { ClaudeReplayIndex } from './session-claude-graph.mjs';
|
|
11
13
|
import { captureClaudeProvenance, pushClaudeUser, pushClaudeAttachment, pushClaudeSystem, mergeClaudeAssistantChunk, flushClaudeAssistant, reorderClaudeToolResults } from './session-claude-content.mjs';
|
|
12
14
|
import { sourceStat } from './store-registry.mjs';
|
|
13
|
-
import { looksLikeSessionSqlite } from './session-path.mjs';
|
|
15
|
+
import { isCompressedSessionPath, looksLikeSessionSqlite, sessionTextError } from './session-path.mjs';
|
|
14
16
|
import { detectSessionSource } from './session-source.mjs';
|
|
17
|
+
import { AppendScan, shortHash } from './session-append-scan.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,131 @@ 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
|
+
|
|
200
|
+
/** One line taken into a scan's state: its place among the file's lines, its header facts, its node in the graph,
|
|
201
|
+
* what it changes of the recorded profile and, for a record the replay can select, where it stands and the hash of
|
|
202
|
+
* its bytes. */
|
|
203
|
+
function observeClaudeLine(state, line) {
|
|
204
|
+
const index = state.lines++;
|
|
205
|
+
if (nativeTrim(line.text) === '') return;
|
|
206
|
+
let value;
|
|
207
|
+
try { value = parseNativeJson(line.text); } catch { state.parseErrors++; return; }
|
|
208
|
+
captureClaudeMeta(value, state.meta, line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text);
|
|
209
|
+
state.graph.observe(index, value);
|
|
210
|
+
state.profile.record(index, value);
|
|
211
|
+
if (typeof value?.version === 'string') state.version = value.version;
|
|
212
|
+
if (REPLAYED.has(stringField(value, 'type'))) state.records.set(index, { ...recordFacts(line, value), hash: shortHash(line.bytes) });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A transcript's complete lines as one pass over them leaves them, kept between reads of the same file: the next
|
|
216
|
+
* read takes only the lines written since (`extend`). Nothing a record says is kept, only where it is and what it is
|
|
217
|
+
* to the replay. */
|
|
218
|
+
class ClaudeRecordScan extends AppendScan {
|
|
219
|
+
records = new Map(); lines = 0; parseErrors = 0; version = null;
|
|
220
|
+
graph = new ClaudeReplayIndex(); meta = createSession('claude_code').meta; profile = recordedProfileFold('claude_code');
|
|
221
|
+
skeletons = new Map(); view = null;
|
|
222
|
+
|
|
223
|
+
/** The lines completed since the last pass. A last line with no LF yet stays outside the scan: `view` reads it. */
|
|
224
|
+
async extend(reader) {
|
|
225
|
+
const from = this.bytes;
|
|
226
|
+
for await (const line of sessionFileLines(reader, this.bytes)) {
|
|
227
|
+
if (!line.terminated) break;
|
|
228
|
+
observeClaudeLine(this, line); this.hashed.update(line.bytes); this.bytes = line.offset + line.length;
|
|
229
|
+
}
|
|
230
|
+
await this.settle(reader, from);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The replay of the file as `reader` holds it: its messages in order, each as its skeleton with the mark of the
|
|
234
|
+
* records that make it. A group's skeleton is decoded once and kept while the replay selects it. */
|
|
235
|
+
async project(reader, fidelity) {
|
|
236
|
+
const key = `${fidelity}:${this.bytes}:${reader.size}`;
|
|
237
|
+
if (this.view?.key === key) return this.view;
|
|
238
|
+
const state = { lines: this.lines, parseErrors: this.parseErrors, version: this.version, records: new Map(),
|
|
239
|
+
graph: this.graph.clone(), meta: cloneNativeJson(this.meta), profile: this.profile.clone() };
|
|
240
|
+
// The file's last line while it has no LF yet is read for this answer alone. Cut inside a character, it is a
|
|
241
|
+
// line still being written: counted as one that does not parse, as a cut record is.
|
|
242
|
+
if (reader.size > this.bytes) {
|
|
243
|
+
const bytes = await reader.read(this.bytes, reader.size - this.bytes); let text = null;
|
|
244
|
+
try { text = decodeSessionUtf8(bytes); } catch { state.lines++; state.parseErrors++; }
|
|
245
|
+
if (text != null) observeClaudeLine(state, { offset: this.bytes, length: bytes.length, text, bytes });
|
|
246
|
+
}
|
|
247
|
+
const recordOf = (index) => state.records.get(index) ?? this.records.get(index);
|
|
248
|
+
const raw = async (index, at) => { const record = recordOf(index); return decodeSessionUtf8(await at.read(record.offset, record.length)); };
|
|
249
|
+
const selection = state.graph.selectLines(fidelity), groups = [], sizes = [], skeleton = [], skeletons = new Map(), marks = new Map(), models = new Map();
|
|
250
|
+
for (const group of claudeGroups(selection.lines, recordOf)) {
|
|
251
|
+
const id = groups.length, records = group.map(recordOf), mark = shortHash(records.map((record) => record.hash).join(','));
|
|
252
|
+
let made = skeletons.get(mark) ?? this.skeletons.get(mark);
|
|
253
|
+
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) }));
|
|
254
|
+
skeletons.set(mark, made); groups.push(group); sizes.push(records.reduce((sum, record) => sum + record.length, 0));
|
|
255
|
+
made.forEach(({ message, model }, ordinal) => {
|
|
256
|
+
const placed = { ...message, metadata: Object.create(null) }; placed.metadata[REFERENCE] = `${id}:${ordinal}`;
|
|
257
|
+
marks.set(placed, shortHash(`${mark}:${ordinal}`)); if (model != null) models.set(placed, model);
|
|
258
|
+
skeleton.push(placed);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
this.skeletons = skeletons;
|
|
262
|
+
reorderClaudeToolResults(skeleton); ensureToolResultsPaired(skeleton);
|
|
263
|
+
// A result the pairing pass made up has no record: its mark is of what it says.
|
|
264
|
+
return this.view = { key, raw, groups, sizes, skeleton, marks: skeleton.map((message) => marks.get(message) ?? shortHash(JSON.stringify(messageJson(message)))),
|
|
265
|
+
model: models.get(skeleton.findLast((message) => models.has(message))) ?? null, residue: selection.residue, meta: state.meta, lines: state.lines,
|
|
266
|
+
parseErrors: state.parseErrors, version: state.version, history: state.profile.history };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The scans of the files read most recently, at most this many records between them: a transcript read again is
|
|
271
|
+
// read from where its scan stopped, and one read long ago is scanned again. Of the files shorter than
|
|
272
|
+
// SCAN_KEPT_BYTES only the one read last is kept (a pass reads its file more than once): scanning a short file again
|
|
273
|
+
// costs less than keeping the scan of every one read.
|
|
274
|
+
const SCANNED_RECORDS = 400_000, SCAN_KEPT_BYTES = 8 * 1024 * 1024;
|
|
275
|
+
const scans = new Map();
|
|
276
|
+
|
|
277
|
+
async function scanOf(path, reader) {
|
|
278
|
+
let scan = scans.get(path); scans.delete(path);
|
|
279
|
+
if (!scan || !await scan.holds(reader)) scan = new ClaudeRecordScan(reader);
|
|
280
|
+
await scan.extend(reader);
|
|
281
|
+
if (scan.bytes < SCAN_KEPT_BYTES) for (const [other, item] of scans) if (item.bytes < SCAN_KEPT_BYTES) scans.delete(other);
|
|
282
|
+
scans.set(path, scan);
|
|
283
|
+
let held = 0; for (const item of scans.values()) held += item.lines;
|
|
284
|
+
for (const [other, item] of scans) { if (held <= SCANNED_RECORDS || item === scan) break; scans.delete(other); held -= item.lines; }
|
|
285
|
+
return scan;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A window of a Claude Code transcript's messages from the place `from`, read record by record: at most `messages` of
|
|
290
|
+
* them and `bytes` of transcript (one message at least), with each one's mark and, when `marks` is asked, the mark of
|
|
291
|
+
* every message of the session. A mark is of the bytes of the records that make a message: while it stands, so does
|
|
292
|
+
* what a reader took of that message before. Null when the locator names anything else, which is loaded as its
|
|
293
|
+
* document.
|
|
294
|
+
*/
|
|
295
|
+
export async function loadClaudeRecordWindow(locator, { from, bytes = null, messages = null, marks = false }, { fidelity = 'semantic', metrics } = {}) {
|
|
296
|
+
const { path, kind } = locator.storage;
|
|
297
|
+
if (kind !== 'file' || locator.harness === 'hermes' || isCompressedSessionPath(path)) return null;
|
|
298
|
+
if ((await sourceStat(join(dirname(path), `${basename(path, extname(path))}.meta.json`)))?.isFile()) return null;
|
|
299
|
+
if (!await supportsClaudeReadIndex(path, { metrics })) return null;
|
|
300
|
+
return (await withSessionFile(path, async (reader) => {
|
|
301
|
+
let view;
|
|
302
|
+
try { view = await (await scanOf(path, reader)).project(reader, fidelity); }
|
|
303
|
+
catch (error) {
|
|
304
|
+
scans.delete(path);
|
|
305
|
+
throw ['ERR_ENCODING_INVALID_ENCODED_DATA', 'ERR_STRING_TOO_LONG'].includes(error?.code) ? sessionTextError(path, error) : error;
|
|
306
|
+
}
|
|
307
|
+
const total = view.skeleton.length, offset = Math.min(from, total), session = createSession('claude_code'), counted = new Set();
|
|
308
|
+
let end = offset, used = 0, decoded = null, decodedGroup = null;
|
|
309
|
+
for (; end < total && (messages == null || end - offset < messages); end++) {
|
|
310
|
+
const message = view.skeleton[end], reference = message.metadata[REFERENCE];
|
|
311
|
+
if (reference === undefined) { session.messages.push(cloneNativeJson(message)); continue; }
|
|
312
|
+
const [group, ordinal] = reference.split(':').map(Number), cost = counted.has(group) ? 0 : view.sizes[group];
|
|
313
|
+
if (bytes != null && end > offset && used + cost > bytes) break;
|
|
314
|
+
counted.add(group); used += cost;
|
|
315
|
+
if (decodedGroup !== group) { decoded = await decodeClaudeGroup((index) => view.raw(index, reader), view.groups[group]); decodedGroup = group; }
|
|
316
|
+
session.messages.push(cloneNativeJson(decoded[ordinal]));
|
|
317
|
+
}
|
|
318
|
+
session.meta = cloneNativeJson(view.meta); session.imported_message_count = total;
|
|
319
|
+
session.parse_error_lines = view.parseErrors; session.load_residue = [...view.residue];
|
|
320
|
+
return { session: { ...normalizedSessionJson(session), raw_record_count: view.lines,
|
|
321
|
+
resolved_profile: recordedSessionProfile(session, { model: view.model, version: view.version }), resolved_profile_history: view.history.map(cloneNativeJson),
|
|
322
|
+
window: { offset, returned: end - offset, total_messages: total, marks: view.marks.slice(offset, end), ...(marks ? { all_marks: [...view.marks] } : {}) } } };
|
|
323
|
+
}, metrics)).result;
|
|
324
|
+
}
|
|
@@ -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'), {
|
|
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
|
-
|
|
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-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,8 @@ 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
|
+
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';
|
|
@@ -58,7 +59,8 @@ export function parseSessionReadParams(params, { cwd = process.cwd() } = {}) {
|
|
|
58
59
|
const suppliedView = object(params.view, 'view');
|
|
59
60
|
view = { includeSubagents: boolean(suppliedView, 'include_subagents', false), displayHistory: boolean(suppliedView, 'display_history', false),
|
|
60
61
|
messageLimit: unsigned(suppliedView, 'tail_messages', 5000), maxMessageChars: unsigned(suppliedView, 'max_message_chars', 64000),
|
|
61
|
-
fromByte: unsigned(suppliedView, 'from_byte')
|
|
62
|
+
fromByte: unsigned(suppliedView, 'from_byte'), fromMessage: unsigned(suppliedView, 'from_message'),
|
|
63
|
+
windowBytes: unsigned(suppliedView, 'window_bytes'), windowMessages: unsigned(suppliedView, 'window_messages'), marks: boolean(suppliedView, 'marks', false) };
|
|
62
64
|
if (view.messageLimit != null) view.messageLimit = Math.max(1, view.messageLimit);
|
|
63
65
|
if (view.maxMessageChars != null) view.maxMessageChars = Math.max(256, view.maxMessageChars);
|
|
64
66
|
}
|
|
@@ -97,6 +99,16 @@ export async function loadSessionRead(params, context = {}) {
|
|
|
97
99
|
return projectedSessionResult(await loadSessionLocator(locator, { ...context, fidelity: 'byte_lossless' }), options);
|
|
98
100
|
}
|
|
99
101
|
const readOptions = { ...context, fidelity, includeSubagents: view?.includeSubagents ?? true };
|
|
102
|
+
// A view from a message on is a window of the session read record by record, where its reader has one.
|
|
103
|
+
if (view?.fromMessage != null && !view.includeSubagents && !view.displayHistory) {
|
|
104
|
+
const windowed = await loadClaudeRecordWindow(locator, { from: view.fromMessage, bytes: view.windowBytes, messages: view.windowMessages, marks: view.marks }, readOptions);
|
|
105
|
+
if (windowed != null) return windowed;
|
|
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
|
+
}
|
|
100
112
|
const session = view?.displayHistory
|
|
101
113
|
? view.fromByte != null ? await loadSessionDisplayFrom(locator, view.fromByte, readOptions)
|
|
102
114
|
: 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) {
|