pi-tuck 1.0.1 → 1.0.3

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -5
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +7 -4
  5. package/dist/src/budget.d.ts +17 -4
  6. package/dist/src/budget.js +33 -10
  7. package/dist/src/build-id.d.ts +1 -1
  8. package/dist/src/build-id.js +1 -1
  9. package/dist/src/compaction.d.ts +4 -0
  10. package/dist/src/compaction.js +8 -5
  11. package/dist/src/diagnostics.d.ts +2 -1
  12. package/dist/src/omissions.d.ts +1 -1
  13. package/dist/src/omissions.js +8 -8
  14. package/dist/src/tuck/core/brief.d.ts +13 -43
  15. package/dist/src/tuck/core/brief.js +39 -255
  16. package/dist/src/tuck/core/format.d.ts +2 -6
  17. package/dist/src/tuck/core/format.js +4 -10
  18. package/dist/src/tuck/core/normalize.js +20 -43
  19. package/dist/src/tuck/core/shrink.d.ts +33 -0
  20. package/dist/src/tuck/core/shrink.js +221 -0
  21. package/dist/src/tuck/core/summarize.d.ts +15 -7
  22. package/dist/src/tuck/core/summarize.js +10 -23
  23. package/dist/src/tuck/core/transcript.d.ts +24 -13
  24. package/dist/src/tuck/core/transcript.js +243 -83
  25. package/dist/src/tuck/types.d.ts +4 -14
  26. package/dist/src/tuck.d.ts +8 -10
  27. package/dist/src/tuck.js +23 -25
  28. package/index.ts +8 -5
  29. package/package.json +1 -1
  30. package/src/budget.ts +29 -9
  31. package/src/build-id.ts +1 -1
  32. package/src/compaction.ts +11 -5
  33. package/src/diagnostics.ts +1 -1
  34. package/src/omissions.ts +8 -8
  35. package/src/tuck/core/brief.ts +46 -265
  36. package/src/tuck/core/format.ts +4 -11
  37. package/src/tuck/core/normalize.ts +20 -43
  38. package/src/tuck/core/shrink.ts +218 -0
  39. package/src/tuck/core/summarize.ts +20 -25
  40. package/src/tuck/core/transcript.ts +234 -82
  41. package/src/tuck/types.ts +7 -19
  42. package/src/tuck.ts +30 -32
  43. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  44. package/dist/src/tuck/core/brief-meter.js +0 -273
  45. package/dist/src/tuck/core/rank.d.ts +0 -42
  46. package/dist/src/tuck/core/rank.js +0 -292
  47. package/src/tuck/core/brief-meter.ts +0 -235
  48. package/src/tuck/core/rank.ts +0 -331
@@ -0,0 +1,218 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { blockRecords, decorateRecord, isDialogue, isOmittable, omissionRecord, type Clip, type DialogueBlock } from "./brief";
3
+ import { DIALOGUE_SUFFIX } from "./format";
4
+ import { orderByOriginalSource } from "./source-order";
5
+ import { serializeRecord, serializeTranscript, TRANSCRIPT_HEADER, type TranscriptRecord } from "./transcript";
6
+
7
+ type Role = "user" | "assistant" | "notice";
8
+ const ROLES: readonly Role[] = ["user", "assistant", "notice"];
9
+
10
+ /** The lossy variants of a unit, least clipped first: the words kept from the start of its text and from its end. */
11
+ const TIERS: Record<Role, readonly Clip[]> = {
12
+ assistant: [[80, 120], [40, 40], [16, 0]],
13
+ user: [[256, 0], [64, 0], [24, 0]],
14
+ notice: [[256, 0]],
15
+ };
16
+ const TOOLS_OMITTED: TranscriptRecord = { kind: "omission", reason: "Historical tool actions and observations omitted; read original sources with tuck_recall" };
17
+
18
+ export interface DialogueInput {
19
+ /** Normalized, noise-filtered blocks of the pre-cut history in chronological order: dialogue and tool blocks alike. */
20
+ blocks: NormalizedBlock[];
21
+ /** Sources the adapter quotes in its own base: they render as nothing here and are not counted as omitted. */
22
+ mandatorySourceIndices?: ReadonlySet<number>;
23
+ /** The source index of each original message and, when known, its entry id, for the original order of the records. */
24
+ sourceIndices: readonly (number | undefined)[];
25
+ sourceEntryIds?: readonly string[];
26
+ }
27
+
28
+ /** The configurations the ladder tries, in order; "none" is no configuration that fits. */
29
+ export type Step = "whole" | `A${1 | 2 | 3}` | "B" | `C${1 | 2 | 3}` | "D" | "E1" | "E2" | "none";
30
+ export interface Shrunk {
31
+ /** The summary text, or "" when nothing fits. */
32
+ text: string;
33
+ step: Step;
34
+ /** The length the ladder computed from per-unit lengths before it rendered `text`. */
35
+ size: number;
36
+ }
37
+
38
+ /** What one unit renders in a configuration, and its serialized size: each record's line plus a newline. */
39
+ interface Rendered { records: TranscriptRecord[]; cost: number }
40
+ const rendered = (records: TranscriptRecord[]): Rendered => ({ records, cost: records.reduce((n, r) => n + serializeRecord(r).length + 1, 0) });
41
+
42
+ /** Units [0, drop) of a role are dropped, [drop, end) are clipped to `tier`, and the rest stay whole. */
43
+ interface Setting { drop: number; tier: number; end: number }
44
+ type Config = Record<Role, Setting>;
45
+ const WHOLE: Setting = { drop: 0, tier: 0, end: 0 };
46
+
47
+ /** The units of one role in chronological order, with each variant's cost summed over every prefix. */
48
+ const createLane = (role: Role, units: DialogueBlock[], mandatory: ReadonlySet<number> | undefined, decorate: (record: TranscriptRecord) => TranscriptRecord) => {
49
+ const n = units.length;
50
+ const records = (block: DialogueBlock, clip?: Clip) => rendered(blockRecords(block, mandatory, clip).map(decorate));
51
+ const prefix = (level: Rendered[]) => { const sums = new Float64Array(n + 1); level.forEach((r, i) => sums[i + 1] = sums[i]! + r.cost); return sums; };
52
+ const levels: Rendered[][] = [units.map(block => records(block))];
53
+ const sums = [prefix(levels[0]!)];
54
+ // A clip keeps whole passages, so a unit that is one long sentence, code block or JSON region can keep no text at all. Such a clip renders
55
+ // nothing and its unit is counted in the role's one omission notice, like a dropped unit: a notice per empty clip would cost more than text.
56
+ const NONE: Rendered = { records: [], cost: 0 };
57
+ const emptied: Uint8Array[] = [new Uint8Array(n)];
58
+ const emptyCounts: Int32Array[] = [new Int32Array(n + 1)];
59
+ /** Level t holds each unit's tier-t variant. A clip that would not make the unit smaller is not taken, so sizes never grow with t. */
60
+ const level = (t: number): Rendered[] => {
61
+ while (levels.length <= t) {
62
+ const previous = levels[levels.length - 1]!, wasEmpty = emptied[emptied.length - 1]!, clip = TIERS[role][levels.length - 1]!;
63
+ const empty = new Uint8Array(n);
64
+ const next = units.map((block, i) => {
65
+ if (wasEmpty[i]) { empty[i] = 1; return previous[i]!; }
66
+ const clipped = records(block, clip);
67
+ if (role !== "notice" && isOmittable(block, mandatory) && !clipped.records.some(record => record.kind === "excerpt")) { empty[i] = 1; return NONE; }
68
+ return clipped.cost < previous[i]!.cost ? clipped : previous[i]!;
69
+ });
70
+ const counts = new Int32Array(n + 1);
71
+ empty.forEach((e, i) => counts[i + 1] = counts[i]! + e);
72
+ levels.push(next); sums.push(prefix(next)); emptied.push(empty); emptyCounts.push(counts);
73
+ }
74
+ return levels[t]!;
75
+ };
76
+ const sum = (t: number) => { level(t); return sums[t]!; };
77
+
78
+ // The notice for a dropped prefix of p units: how many unselected text blocks, the first locators, and whether some have none.
79
+ let tally: { counts: Int32Array; unlocated: Uint8Array; smallest: number[][] } | undefined;
80
+ /** The notice for the units a setting omits: the dropped prefix, and the clips in [drop, end) that kept no text. */
81
+ const omission = ({ drop, tier, end }: Setting): TranscriptRecord | undefined => {
82
+ if (role === "notice") return undefined;
83
+ if (!tally) {
84
+ tally = { counts: new Int32Array(n + 1), unlocated: new Uint8Array(n + 1), smallest: new Array(n + 1) };
85
+ let smallest: number[] = [], count = 0, unlocated = 0;
86
+ tally.smallest[0] = smallest;
87
+ units.forEach((block, i) => {
88
+ if (isOmittable(block, mandatory)) {
89
+ count++;
90
+ const source = block.sourceIndex;
91
+ if (source === undefined) unlocated = 1;
92
+ else if (!smallest.includes(source) && (smallest.length < 9 || source < smallest[8]!)) {
93
+ smallest = [...smallest, source].sort((a, b) => a - b).slice(0, 9);
94
+ }
95
+ }
96
+ tally!.counts[i + 1] = count; tally!.unlocated[i + 1] = unlocated; tally!.smallest[i + 1] = smallest;
97
+ });
98
+ }
99
+ let count = tally.counts[drop]!, unlocated = tally.unlocated[drop] === 1, sources = tally.smallest[drop]!;
100
+ if (end > drop && tier > 0) {
101
+ level(tier);
102
+ const empty = emptied[tier]!, counts = emptyCounts[tier]!;
103
+ count += counts[end]! - counts[drop]!;
104
+ if (counts[end]! > counts[drop]!) {
105
+ const extra: number[] = [];
106
+ for (let i = drop; i < end && extra.length < 9; i++) if (empty[i]) {
107
+ const source = units[i]!.sourceIndex;
108
+ if (source === undefined) unlocated = true; else extra.push(source);
109
+ }
110
+ for (let i = drop; i < end && !unlocated; i++) if (empty[i] && units[i]!.sourceIndex === undefined) unlocated = true;
111
+ sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
112
+ }
113
+ }
114
+ return count ? decorate(omissionRecord(role, count, sources, unlocated)) : undefined;
115
+ };
116
+ const omissionCosts = new Map<string, number>();
117
+ const omissionCost = (setting: Setting) => {
118
+ const key = `${setting.drop}:${setting.tier}:${setting.end}`;
119
+ let cost = omissionCosts.get(key);
120
+ if (cost === undefined) { const record = omission(setting); omissionCosts.set(key, cost = record ? rendered([record]).cost : 0); }
121
+ return cost;
122
+ };
123
+
124
+ return {
125
+ n, level, sum, omission,
126
+ cost: ({ drop, tier, end }: Setting) => sums[0]![n]! - sums[0]![Math.max(drop, end)]! + (end > drop ? sum(tier)[end]! - sum(tier)[drop]! : 0) + omissionCost({ drop, tier, end }),
127
+ };
128
+ };
129
+
130
+ /** The units of a dialogue and what any configuration of them renders, by arithmetic over per-unit lengths or as the summary text. */
131
+ const createDialogue = (input: DialogueInput) => {
132
+ const dialogue = input.blocks.filter(isDialogue);
133
+ const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
134
+ const decorate = (record: TranscriptRecord) => decorateRecord(record, times);
135
+ const roleOf = (block: DialogueBlock): Role => block.kind;
136
+ const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => roleOf(block) === role), input.mandatorySourceIndices, decorate)])) as
137
+ Record<Role, ReturnType<typeof createLane>>;
138
+ const seen: Record<Role, number> = { user: 0, assistant: 0, notice: 0 };
139
+ const order = dialogue.map(block => ({ role: roleOf(block), i: seen[roleOf(block)]++ }));
140
+ const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
141
+ const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
142
+
143
+ /** The length of the summary text of a configuration: its header, one line per record, and the closing section; 0 when it has no record. */
144
+ const size = (config: Config): number => {
145
+ const cost = ROLES.reduce((n, role) => n + lanes[role].cost(config[role]), tools?.cost ?? 0);
146
+ return cost === 0 ? 0 : overhead + cost;
147
+ };
148
+ const text = (config: Config): string => {
149
+ const records: TranscriptRecord[] = [];
150
+ for (const { role, i } of order) {
151
+ const { drop, tier, end } = config[role];
152
+ if (i >= drop) records.push(...lanes[role].level(i < end ? tier : 0)[i]!.records);
153
+ }
154
+ for (const role of ROLES) { const record = lanes[role].omission(config[role]); if (record) records.push(record); }
155
+ if (tools) records.push(...tools.records);
156
+ orderByOriginalSource(records, input.sourceIndices, input.sourceEntryIds);
157
+ return records.length ? serializeTranscript(records) + DIALOGUE_SUFFIX : "";
158
+ };
159
+ return { lanes, size, text };
160
+ };
161
+
162
+ /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted. "" when it renders nothing. */
163
+ export const wholeDialogue = (input: DialogueInput): string => {
164
+ const { text } = createDialogue(input);
165
+ return text({ user: WHOLE, assistant: WHOLE, notice: WHOLE });
166
+ };
167
+
168
+ /**
169
+ * The same dialogue shrunk until its summary text is at most `limit` characters, oldest first. The first configuration that fits wins:
170
+ * A. the newest k assistant units whole, the older ones clipped to tier 1, 2, 3 (the largest k that fits, for each tier in turn);
171
+ * B. the newest assistant units that fit a third of the limit whole, the older ones at tier 3 with the oldest d dropped;
172
+ * C. the newest assistant units that fit a third of the limit at tier 3, the rest dropped; then the oldest u user units clipped to tier 1, 2, 3;
173
+ * D. every user unit at tier 3, the oldest dropped;
174
+ * E. the oldest notices dropped, then the oldest of the remaining assistant units.
175
+ * Every step is a one-parameter family whose size is read from prefix sums of the per-unit lengths, so a step costs O(units). A notice is clipped
176
+ * to its one tier in every configuration but the whole dialogue. The size is arithmetic over unit lengths; the rendered text has the last say:
177
+ * a configuration whose text is over the limit is passed over.
178
+ */
179
+ export const shrinkDialogue = (input: DialogueInput, limit: number): Shrunk => {
180
+ const { lanes, size, text } = createDialogue(input);
181
+ const { user, assistant, notice } = lanes;
182
+ const clipped: Setting = { drop: 0, tier: 1, end: notice.n };
183
+ const found = (step: Step, config: Config): Shrunk | undefined => {
184
+ const length = size(config);
185
+ if (length > limit) return undefined;
186
+ const summary = text(config);
187
+ return summary.length <= limit ? { text: summary, step, size: length } : undefined;
188
+ };
189
+ const gone = (lane: { n: number }): Setting => ({ drop: lane.n, tier: 3, end: lane.n });
190
+ const tiers = [1, 2, 3] as const;
191
+
192
+ let hit = found("whole", { user: WHOLE, assistant: WHOLE, notice: WHOLE });
193
+ if (hit) return hit;
194
+ for (const t of tiers) for (let k = assistant.n; k >= 0; k--)
195
+ if (hit = found(`A${t}`, { user: WHOLE, assistant: { drop: 0, tier: t, end: assistant.n - k }, notice: clipped })) return hit;
196
+ // The newest assistant units that fit a third of the limit whole are kept until users have given way: B clips and drops only older ones.
197
+ // A clip can keep no text at all, so the third is sized on whole units; when the newest alone is over the third, it keeps its largest clip
198
+ // that fits and still has text.
199
+ let kept = 0;
200
+ while (kept < assistant.n && assistant.sum(0)[assistant.n]! - assistant.sum(0)[kept]! > limit / 3) kept++;
201
+ let recent: Setting = { drop: kept, tier: 3, end: kept };
202
+ if (kept === assistant.n && assistant.n > 0) for (const t of tiers) {
203
+ const cost = assistant.level(t)[assistant.n - 1]!.cost;
204
+ if (cost > 0 && cost <= limit / 3) { recent = { drop: assistant.n - 1, tier: t, end: assistant.n }; break; }
205
+ }
206
+ for (let d = 0; d <= recent.drop; d++)
207
+ if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d, end: Math.max(recent.end, recent.drop) }, notice: clipped })) return hit;
208
+ // From here users give way, and the assistant keeps only that recent third.
209
+ for (const t of tiers) for (let u = 0; u <= user.n; u++)
210
+ if (hit = found(`C${t}`, { user: { drop: 0, tier: t, end: u }, assistant: recent, notice: clipped })) return hit;
211
+ for (let d = 0; d <= user.n; d++)
212
+ if (hit = found("D", { user: { drop: d, tier: 3, end: user.n }, assistant: recent, notice: clipped })) return hit;
213
+ for (let d = 0; d <= notice.n; d++)
214
+ if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } })) return hit;
215
+ for (let d = recent.drop; d <= assistant.n; d++)
216
+ if (hit = found("E2", { user: gone(user), assistant: { drop: d, tier: recent.tier, end: Math.max(recent.end, d) }, notice: gone(notice) })) return hit;
217
+ return { text: "", step: "none", size: 0 };
218
+ };
@@ -1,41 +1,36 @@
1
- import { orderByOriginalSource } from "./source-order";
2
1
  import type { CompilerMessage } from "../types";
3
2
  import { normalize } from "./normalize";
4
3
  import { filterNoise } from "./filter-noise";
5
- import { compileBrief, planBrief, buildBriefRecords } from "./brief";
6
- import { DIALOGUE_SUFFIX, RANKED_SUFFIX } from "./format";
7
- import { parseRecords, serializeTranscript } from "./transcript";
8
- import { selectRankedBriefBlocks, type BriefRankingOptions } from "./rank";
4
+ import { shrinkDialogue, wholeDialogue, type DialogueInput } from "./shrink";
9
5
 
10
- export interface RankedCompileInput {
6
+ export interface CompileInput {
11
7
  messages: CompilerMessage[];
12
- /** Eligible original branch only; receipt identity/order, not extra ranked content. */
8
+ /** Eligible original branch only: receipt identity and order. */
13
9
  receiptContext?: { messages: CompilerMessage[]; sourceIndices: Array<number | undefined> };
14
10
  /** Native session-global source indices parallel to messages. */
15
11
  sourceIndices?: Array<number | undefined>;
16
12
  /** Actual native entry IDs parallel to originals, not inferred from text. */
17
13
  sourceEntryIds?: readonly string[];
18
14
  sourceTimes?: readonly import("./source-time").SourceTime[];
19
- ranking?: BriefRankingOptions;
15
+ ranking?: {
16
+ /** The complete serialized length a summary of the dialogue may have, header and closing section included. */
17
+ maxBriefChars?: number;
18
+ /** Full sources rendered separately by the caller: they render as nothing here. */
19
+ mandatorySourceIndices?: ReadonlySet<number>;
20
+ };
20
21
  }
21
22
 
22
- /** Select eligible originals once. No inferred state or prior-view merging. */
23
- export const compileRanked = (input: RankedCompileInput): string => {
24
- const blocks = filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes));
25
- const selected = selectRankedBriefBlocks(blocks, input.ranking);
26
- const transcript = serializeTranscript(parseRecords(compileBrief(selected)));
27
- return transcript ? transcript + RANKED_SUFFIX : "";
28
- };
23
+ const dialogueInput = (input: CompileInput): DialogueInput => ({
24
+ blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
25
+ mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
26
+ sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
27
+ sourceEntryIds: input.sourceEntryIds,
28
+ });
29
29
 
30
30
  /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
31
31
  * This disposable view grants neither present authority nor native tool execution. */
32
- export const compileDialogue = (input: RankedCompileInput): string => {
33
- const originals = filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes));
34
- const blocks = originals.filter(block => ["user", "assistant", "notice"].includes(block.kind));
35
- planBrief(blocks, new Set(blocks), originals, input.ranking?.mandatorySourceIndices);
36
- const records = buildBriefRecords(blocks);
37
- if (blocks.length < originals.length) records.push({ kind: "omission",
38
- reason: "Historical tool actions and observations omitted; read original sources with tuck_recall" });
39
- orderByOriginalSource(records, input.sourceIndices ?? input.messages.map((_, index) => index), input.sourceEntryIds);
40
- return records.length ? serializeTranscript(records) + DIALOGUE_SUFFIX : "";
41
- };
32
+ export const compileDialogue = (input: CompileInput): string => wholeDialogue(dialogueInput(input));
33
+
34
+ /** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first (see `shrinkDialogue`); "" when nothing fits. */
35
+ export const compileShrunkDialogue = (input: CompileInput & { ranking: { maxBriefChars: number } }): string =>
36
+ shrinkDialogue(dialogueInput(input), input.ranking.maxBriefChars).text;
@@ -1,4 +1,4 @@
1
- import { validSourceTime } from "./source-time";
1
+ import { validSourceTime, type SourceTime } from "./source-time";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import type { HistoricalReceipt } from "../types";
4
4
 
@@ -27,6 +27,7 @@ interface RecordFields {
27
27
  count?: number;
28
28
  title?: string;
29
29
  }
30
+ /** The tool_call, tool_receipt, tool_excerpt and bash kinds are v1 only: summaries saved by earlier builds hold them, and this build no longer writes them. */
30
31
  export type TranscriptRecord = RecordFields & (
31
32
  { kind: "excerpt"; role: "user" | "assistant" | "custom"; text: string } |
32
33
  { kind: "tool_call"; role: "assistant"; text: string; arguments?: Record<string, unknown>;
@@ -38,96 +39,247 @@ export type TranscriptRecord = RecordFields & (
38
39
  { kind: "section"; title: string; text: string }
39
40
  );
40
41
 
41
- export const TRANSCRIPT_HEADER = "TUCK-TRANSCRIPT/1";
42
- export const TRANSCRIPT_UNIT_CHARACTERS = 120;
43
- /** UTF-16 serialized characters, including JSON escapes, labels and framing. */
44
- export const lineUnits = (characters: number): number => Math.max(1, Math.ceil(characters / TRANSCRIPT_UNIT_CHARACTERS));
45
- export const transcriptUnits = (text: string): number => text === "" ? 0 :
46
- text.split("\n").reduce((n, line) => n + lineUnits(line.length), 0);
47
- export const serializeRecords = (records: TranscriptRecord[]): string => records.map(r => JSON.stringify(r)).join("\n");
48
- export const serializeTranscript = (records: TranscriptRecord[]): string => records.length ?
49
- `${TRANSCRIPT_HEADER}\n${serializeRecords(records)}` : "";
42
+ /** The format this build writes. */
43
+ export const TRANSCRIPT_HEADER = "TUCK-TRANSCRIPT/2";
44
+ /** The format of 1.0.x summaries: one JSON object per line. Read, never written. */
45
+ export const TRANSCRIPT_HEADER_V1 = "TUCK-TRANSCRIPT/1";
46
+ /** The header of summaries saved before the rename to pi-tuck: the v1 format. */
47
+ export const LEGACY_TRANSCRIPT_HEADER = "VCC-TRANSCRIPT/1";
48
+ export type TranscriptVersion = 1 | 2;
49
+ /** The header a summary of `version` is written with; a legacy one is rewritten as v1. */
50
+ export const transcriptHeader = (version: TranscriptVersion): string => version === 2 ? TRANSCRIPT_HEADER : TRANSCRIPT_HEADER_V1;
51
+
50
52
  /** Starts a summary's closing section, after the record lines. */
51
53
  export const CLOSING_SEPARATOR = "\n\n---\n\n";
52
- /** A summary in this format, split by structure: each record is one JSON line, so the first blank line after the header starts the
53
- * closing section. Any closing wording, from this build or an earlier one, is accepted; the records are returned byte for byte. */
54
- export const splitSummary = (summary: string): { body: string; closing: string } | undefined => {
55
- const header = [TRANSCRIPT_HEADER, LEGACY_TRANSCRIPT_HEADER].find(h => summary.startsWith(h + "\n"));
54
+ /** A summary in either format, split by structure: each record is one line, and no record line is empty, so the first blank line after the
55
+ * header starts the closing section. Any closing wording, from this build or an earlier one, is accepted; the records are returned byte
56
+ * for byte, with the format their header names. */
57
+ export const splitSummary = (summary: string): { body: string; closing: string; version: TranscriptVersion } | undefined => {
58
+ const header = [TRANSCRIPT_HEADER, TRANSCRIPT_HEADER_V1, LEGACY_TRANSCRIPT_HEADER].find(h => summary.startsWith(h + "\n"));
56
59
  if (!header) return undefined;
57
60
  const at = summary.indexOf(CLOSING_SEPARATOR, header.length);
58
61
  if (at < 0) return undefined;
59
62
  // A summary saved before the rename keeps its records byte for byte (their lengths are checked); only its closing is pointed at the
60
63
  // tools the model has now.
61
- const closing = header === TRANSCRIPT_HEADER ? summary.slice(at) : summary.slice(at).replaceAll("vcc_recall", "tuck_recall").replaceAll("vcc_search", "tuck_search");
62
- return { body: summary.slice(header.length + 1, at), closing };
64
+ const closing = header === LEGACY_TRANSCRIPT_HEADER ? summary.slice(at).replaceAll("vcc_recall", "tuck_recall").replaceAll("vcc_search", "tuck_search") : summary.slice(at);
65
+ return { body: summary.slice(header.length + 1, at), closing, version: header === TRANSCRIPT_HEADER ? 2 : 1 };
63
66
  };
64
- /** The header of summaries saved before the rename to pi-tuck. */
65
- export const LEGACY_TRANSCRIPT_HEADER = "VCC-TRANSCRIPT/1";
66
67
  const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall", "vcc_recall");
67
68
 
68
- /** Accept our explicit record protocol only. Never recover roles from payload text. */
69
- export const parseRecords = (text: string): TranscriptRecord[] => {
69
+ /*
70
+ * TUCK-TRANSCRIPT/2: the header line, one line per record, then the closing section.
71
+ *
72
+ * line = head ":" [ " " text ] no " " and no text when the text is empty
73
+ * record = [ locator " " ] role [ " " id ] [ " " time ] [ " " span ] ":" ... excerpt
74
+ * | [ locator " " ] [ role [ " " id ] " " ] "omitted" [ " " time ] [ " " span ] ":" ... omission of part of one source,
75
+ * or, with no role, of the tool history
76
+ * | role " omitted " count [ " " locator-time { " " locator-time } ] ":" ... omission of unselected text blocks
77
+ * | [ locator " " ] title ":" ... section (file ledger, action trail)
78
+ * locator = "#" index the source's session-wide index, as tuck_recall {"index"} takes it
79
+ * locator-time = locator [ "@" time-value ] every locator of a record has a time, or none does
80
+ * role = "user" | "assistant" | "custom"
81
+ * id = a JSON string the native entry id of a custom notice (role "custom" only)
82
+ * time = "@" time-value the recorded time of the locator that precedes the role (a record that has one)
83
+ * time-value = UTC | "journal:" UTC | "unknown" UTC is the message time, "journal:" UTC the session file's time of the entry
84
+ * span = "[chars " start "-" end " of " total "]" the characters of the source text block the text covers or the omission leaves out
85
+ * count = index how many text blocks the omission counts
86
+ * title = capital letter, then letters, digits, "_" and "-"
87
+ * index = "0" | nonzero digit, then digits a safe integer, no sign, no leading zero
88
+ *
89
+ * The text of an excerpt or section, or the reason of an omission, is the rest of the line: "\" is written "\\", a line feed "\n" and a
90
+ * carriage return "\r"; every other character is written as it is.
91
+ *
92
+ * The head is made of the tokens above only, and the text is the rest of the line after the first ":" that follows them, so no text can be
93
+ * mistaken for structure: a text that looks like a record is the text of one line. A role is lowercase and a title is capitalized, so the
94
+ * first word of a head tells which record it is. Lines that follow no production are rejected, as are fields the record shape does not
95
+ * have (a time without a locator, an id on a role other than "custom") and every check parseRecords makes of a v1 record.
96
+ *
97
+ * Examples:
98
+ * #12 user @2026-10-30T14:00:00Z: Please fix the parser.
99
+ * #12 assistant [chars 0-523 of 2004]: I will start with the tokenizer.
100
+ * #12 assistant omitted [chars 523-1900 of 2004]: tuck_recall {"index":12,"offset":523}
101
+ * #7 custom "a1b2c3d4": A note.
102
+ * assistant omitted 40 #3 #5 #9: Completely unselected historical text blocks; ...
103
+ * user omitted 3 #1@2026-10-30T14:00:00Z #4@unknown #8@journal:2026-10-30T14:00:05Z: Completely unselected ...
104
+ * omitted: Historical tool actions and observations omitted; read original sources with tuck_recall
105
+ * #4 Files: - read src/a.ts (tuck_recall {"index":4})
106
+ */
107
+ const escapeText = (text: string): string => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
108
+ /** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
109
+ export const escapedLength = (text: string): number => text.length + (text.match(/[\\\n\r]/g)?.length ?? 0);
110
+ const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
111
+ const refuse = (record: TranscriptRecord): never => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
112
+
113
+ /** One record as a line of TUCK-TRANSCRIPT/2 (without its newline). The records this build emits only: a record whose fields the grammar
114
+ * cannot carry exactly is refused, never written lossily. */
115
+ export const serializeRecord = (record: TranscriptRecord): string => {
116
+ const fields = record as unknown as Record<string, unknown>;
117
+ const present = Object.keys(fields).filter(key => fields[key] !== undefined);
118
+ const only = (...allowed: string[]) => { if (!present.every(key => allowed.includes(key))) refuse(record); };
119
+ const index = (n: unknown): string => typeof n === "number" && Number.isSafeInteger(n) && n >= 0 ? String(n) : refuse(record);
120
+ const time = (t: SourceTime): string => !validSourceTime(t) ? refuse(record) : t.basis === "unknown" ? "unknown" : t.basis === "journal" ? `journal:${t.utc}` : t.utc;
121
+ /** The record's times, one for each locator in order; none when it has none. */
122
+ const times = (locators: readonly number[]): string[] | undefined => {
123
+ const { sourceTimes } = record;
124
+ if (!sourceTimes) return undefined;
125
+ if (sourceTimes.length !== locators.length) refuse(record);
126
+ return sourceTimes.map(({ sourceIndex, ...value }, i) => sourceIndex === locators[i] ? time(value as SourceTime) : refuse(record));
127
+ };
128
+ const line = (head: string, text: string | undefined): string => head + ":" + (text ? " " + escapeText(text) : "");
129
+ if (record.kind === "section") {
130
+ only("kind", "title", "text", "sourceIndex");
131
+ if (typeof record.title !== "string" || !TITLE.test(record.title) || typeof record.text !== "string") refuse(record);
132
+ return line((record.sourceIndex === undefined ? "" : `#${index(record.sourceIndex)} `) + record.title, record.text);
133
+ }
134
+ if (record.kind !== "excerpt" && record.kind !== "omission") return refuse(record);
135
+ const { role, sourceIndex, sourceEntryId, span } = record, omission = record.kind === "omission";
136
+ if (role !== undefined && !["user", "assistant", "custom"].includes(role) || role === undefined && (!omission || sourceEntryId !== undefined) ||
137
+ sourceEntryId !== undefined && (role !== "custom" || typeof sourceEntryId !== "string")) refuse(record);
138
+ const who = (sourceEntryId === undefined ? "" : ` ${JSON.stringify(sourceEntryId)}`);
139
+ if (omission && record.count !== undefined) {
140
+ only("kind", "role", "count", "sourceIndices", "reason", "sourceTimes");
141
+ const locators = record.sourceIndices ?? [], stamped = times(locators);
142
+ if (role === undefined || typeof record.reason !== "string") refuse(record);
143
+ return line(`${role} omitted ${index(record.count)}` + locators.map((n, i) => ` #${index(n)}${stamped ? "@" + stamped[i] : ""}`).join(""), record.reason);
144
+ }
145
+ only("kind", "role", "sourceIndex", omission ? "reason" : "text", "span", "sourceEntryId", "sourceTimes");
146
+ const text = omission ? record.reason : record.text;
147
+ if (typeof text !== "string") refuse(record);
148
+ const stamped = times(sourceIndex === undefined ? [] : [sourceIndex]);
149
+ const where = (sourceIndex === undefined ? "" : `#${index(sourceIndex)} `) + (role === undefined ? "" : role + who + (omission ? " " : ""));
150
+ return line(where + (omission ? "omitted" : "") + (stamped ? ` @${stamped[0]}` : "") +
151
+ (span ? ` [chars ${index(span.start)}-${index(span.end)} of ${index(span.totalCharacters)}]` : ""), text);
152
+ };
153
+ export const serializeRecords = (records: TranscriptRecord[]): string => records.map(serializeRecord).join("\n");
154
+ export const serializeTranscript = (records: TranscriptRecord[]): string => records.length ?
155
+ `${TRANSCRIPT_HEADER}\n${serializeRecords(records)}` : "";
156
+
157
+ const invalid = (): never => { throw new Error("Invalid pi-tuck transcript record"); };
158
+ const unescapeText = (text: string): string => text.replace(/\\([\\nr])?/g, (_, c?: string) => c === undefined ? invalid() : c === "n" ? "\n" : c === "r" ? "\r" : "\\");
159
+ // Sticky patterns: each is matched at the position the line has been read up to.
160
+ const INDEX = "(0|[1-9]\\d*)";
161
+ const TIME = "(unknown|(?:journal:)?\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z)";
162
+ const LOCATOR = new RegExp(`#${INDEX} `, "y"), ROLE = /user|assistant|custom/y, OMITTED = /omitted/y;
163
+ const ID = / ("(?:[^"\\\u0000-\u001f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")/y, SPACE_OMITTED = / omitted/y, COUNT = new RegExp(` ${INDEX}`, "y");
164
+ const LOCATOR_TIME = new RegExp(` #${INDEX}(?:@${TIME})?`, "y"), TIME_TOKEN = new RegExp(` @${TIME}`, "y");
165
+ const SPAN = new RegExp(` \\[chars ${INDEX}-${INDEX} of ${INDEX}\\]`, "y"), TITLE_TOKEN = /[A-Z][A-Za-z0-9_-]*/y;
166
+
167
+ /** One line of TUCK-TRANSCRIPT/2, as the record the build that wrote it held (its fields in the order that build emits them). */
168
+ const parseLine = (line: string): unknown => {
169
+ let at = 0;
170
+ const take = (pattern: RegExp): RegExpExecArray | undefined => {
171
+ pattern.lastIndex = at;
172
+ const match = pattern.exec(line);
173
+ if (match) at = pattern.lastIndex;
174
+ return match ?? undefined;
175
+ };
176
+ const integer = (digits: string): number => Number.isSafeInteger(Number(digits)) ? Number(digits) : invalid();
177
+ const timeOf = (value: string): SourceTime => value === "unknown" ? { basis: "unknown" } :
178
+ value.startsWith("journal:") ? { basis: "journal", utc: value.slice(8) } : { basis: "message", utc: value };
179
+ const located = take(LOCATOR), sourceIndex = located ? integer(located[1]!) : undefined;
180
+ const title = take(TITLE_TOKEN)?.[0], role = title === undefined ? take(ROLE)?.[0] : undefined;
181
+ const id = role === "custom" ? take(ID)?.[1] : undefined;
182
+ const omitted = title === undefined && take(role === undefined ? OMITTED : SPACE_OMITTED) !== undefined;
183
+ if (title === undefined && role === undefined && !omitted) invalid();
184
+ const common = { ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
185
+ let record: Record<string, unknown>, text: string;
186
+ const end = (): string => {
187
+ if (line[at++] !== ":") return invalid();
188
+ const rest = line.slice(at);
189
+ return rest === "" ? "" : rest[0] === " " && rest.length > 1 && !rest.includes("\r") ? unescapeText(rest.slice(1)) : invalid();
190
+ };
191
+ const count = omitted && role !== undefined && sourceIndex === undefined && id === undefined ? take(COUNT) : undefined;
192
+ if (title !== undefined) {
193
+ text = end();
194
+ record = { kind: "section", title, text, ...common };
195
+ } else if (count) {
196
+ const items: { sourceIndex: number; time?: SourceTime }[] = [];
197
+ for (let item; (item = take(LOCATOR_TIME));) items.push({ sourceIndex: integer(item[1]!), ...(item[2] !== undefined ? { time: timeOf(item[2]) } : {}) });
198
+ const stamped = items.filter(item => item.time).length;
199
+ if (stamped && stamped !== items.length) invalid();
200
+ record = { kind: "omission", role, count: integer(count[1]!), ...(items.length ? { sourceIndices: items.map(item => item.sourceIndex) } : {}), reason: end(),
201
+ ...(stamped ? { sourceTimes: items.map(({ sourceIndex: n, time }) => ({ sourceIndex: n, ...time! })) } : {}) };
202
+ } else {
203
+ const time = take(TIME_TOKEN), span = take(SPAN);
204
+ if (time && sourceIndex === undefined) invalid();
205
+ const fields = {
206
+ ...(span ? { span: { coordinateSpace: "sanitized-block" as const, start: integer(span[1]!), end: integer(span[2]!), totalCharacters: integer(span[3]!) } } : {}),
207
+ ...(id !== undefined ? { sourceEntryId: JSON.parse(id) as string } : {}),
208
+ ...(time ? { sourceTimes: [{ sourceIndex: sourceIndex!, ...timeOf(time[1]!) }] } : {}),
209
+ };
210
+ text = end();
211
+ record = omitted ? { kind: "omission", ...(role !== undefined ? { role } : {}), ...common, reason: text, ...fields } :
212
+ { kind: "excerpt", role, ...common, text, ...fields };
213
+ }
214
+ return record;
215
+ };
216
+
217
+ /** The structure of a record, whichever format it was read from: roles, source times, spans and, for the v1-only kinds, receipts. */
218
+ const validate = (r: unknown): TranscriptRecord => {
219
+ if (!r || typeof r !== "object" || Array.isArray(r)) throw new Error("Invalid pi-tuck transcript record");
220
+ const v = r as TranscriptRecord;
221
+ const roles = ["user", "assistant", "custom", "bashExecution", "toolResult"];
222
+ const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
223
+ if (!["excerpt", "tool_call", "tool_receipt", "tool_excerpt", "bash", "omission", "section"].includes(v.kind) ||
224
+ (v.role !== undefined && !roles.includes(v.role)) ||
225
+ (v.sourceTime !== undefined && (v.sourceEntryId === undefined || !validSourceTime(v.sourceTime))) ||
226
+ (v.sourceTimes !== undefined && (!Array.isArray(v.sourceTimes) || !v.sourceTimes.length || v.sourceTimes.some(t => !integer(t.sourceIndex) || !validSourceTime(Object.fromEntries(Object.entries(t).filter(([key]) => key !== "sourceIndex")))))) ||
227
+ (v.sourceIndex !== undefined && !integer(v.sourceIndex)) ||
228
+ (v.sourceEntryId !== undefined && (typeof v.sourceEntryId !== "string" || !v.sourceEntryId || v.sourceEntryId.length > 200 ||
229
+ v.role !== "custom" || !["excerpt", "omission"].includes(v.kind))) ||
230
+ (v.sourceIndices !== undefined && (!Array.isArray(v.sourceIndices) || !v.sourceIndices.every(integer))) ||
231
+ (v.kind !== "omission" && typeof v.text !== "string") ||
232
+ (v.kind === "omission" && typeof v.reason !== "string") ||
233
+ (v.kind === "excerpt" && !["user", "assistant", "custom"].includes(v.role)) ||
234
+ (v.kind === "tool_call" && v.role !== "assistant") ||
235
+ (v.kind === "bash" && v.role !== "bashExecution") ||
236
+ (v.kind === "section" && typeof v.title !== "string") ||
237
+ (v.count !== undefined && !integer(v.count)) ||
238
+ (v.span !== undefined && (v.span.coordinateSpace !== "sanitized-block" || !integer(v.span.start) || !integer(v.span.end) || !integer(v.span.totalCharacters) ||
239
+ v.span.start > v.span.end || v.span.end > v.span.totalCharacters ||
240
+ (v.kind === "excerpt" && v.text!.length !== v.span.end - v.span.start))))
241
+ throw new Error("Invalid pi-tuck transcript record");
242
+ if (v.sourceTimes) {
243
+ const expected = v.sourceIndices ?? (v.sourceIndex === undefined ? [] : [v.sourceIndex]);
244
+ if (v.sourceTimes.length !== expected.length || v.sourceTimes.some((t, i) => t.sourceIndex !== expected[i])) throw new Error("Mismatched source times");
245
+ }
246
+ if (["arguments", "argumentsJSONPrefix", "completeArgumentMembers"].some(key => Object.hasOwn(v, key))) {
247
+ if (v.kind !== "tool_call" ||
248
+ (Object.hasOwn(v, "arguments") && (!smallOriginalArguments(v.arguments, Number.MAX_SAFE_INTEGER) ||
249
+ Object.hasOwn(v, "argumentsJSONPrefix") || Object.hasOwn(v, "completeArgumentMembers"))) ||
250
+ (Object.hasOwn(v, "argumentsJSONPrefix") && (typeof v.argumentsJSONPrefix !== "string" || !v.argumentsJSONPrefix)) ||
251
+ (Object.hasOwn(v, "completeArgumentMembers") && (!Object.hasOwn(v, "argumentsJSONPrefix") ||
252
+ !smallOriginalArguments(v.completeArgumentMembers, Number.MAX_SAFE_INTEGER) || !Object.keys(v.completeArgumentMembers).length)))
253
+ throw new Error("Invalid historical argument fields");
254
+ }
255
+ if (v.kind === "tool_receipt" || v.kind === "tool_excerpt") {
256
+ const p = v.producer;
257
+ const keys = [...(v.sourceTimes ? ["sourceTimes"] : []), "kind", "role", "sourceIndex", "sourceIndices", "historical", "isError", "text", "producer", ...(v.kind === "tool_excerpt" ? ["complete", "notice", "range"] : [])];
258
+ if (Object.keys(v).length !== keys.length || !Object.keys(v).every(k => keys.includes(k)) ||
259
+ v.role !== "toolResult" || v.historical !== true || typeof v.isError !== "boolean" ||
260
+ !v.text || v.text.length > MAX_RECEIPT_CHARACTERS || !integer(v.sourceIndex) ||
261
+ !p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).length !== 4 ||
262
+ !Object.keys(p).every(k => ["sourceIndex", "toolName", "toolCallId", "arguments", "argumentsOmitted"].includes(k)) ||
263
+ !integer(p.sourceIndex) || p.sourceIndex >= v.sourceIndex ||
264
+ typeof p.toolName !== "string" || !p.toolName.trim() || p.toolName.length > 160 ||
265
+ typeof p.toolCallId !== "string" || !p.toolCallId || p.toolCallId.length > 160 ||
266
+ !(Object.hasOwn(p, "arguments") && !Object.hasOwn(p, "argumentsOmitted") && smallOriginalArguments(p.arguments) ||
267
+ p.argumentsOmitted === true && !Object.hasOwn(p, "arguments")) ||
268
+ !Array.isArray(v.sourceIndices) || v.sourceIndices.length !== 2 ||
269
+ v.sourceIndices[0] !== p.sourceIndex || v.sourceIndices[1] !== v.sourceIndex)
270
+ throw new Error("Invalid historical receipt record");
271
+ if (v.kind === "tool_excerpt" && (v.complete !== false || ![RECEIPT_RANGE_NOTICE, LEGACY_RECEIPT_RANGE_NOTICE].includes(v.notice) || !v.range ||
272
+ Object.keys(v.range).length !== 4 || !Object.keys(v.range).every(k => ["coordinateSpace", "start", "end", "totalCharacters"].includes(k)) ||
273
+ v.range.coordinateSpace !== "readable-entry-utf16" || !integer(v.range.start) || !integer(v.range.end) || !integer(v.range.totalCharacters) ||
274
+ v.range.start >= v.range.end || v.range.end > v.range.totalCharacters || v.range.totalCharacters <= MAX_RECEIPT_CHARACTERS ||
275
+ v.text.length !== v.range.end - v.range.start)) throw new Error("Invalid historical receipt range");
276
+ }
277
+ return v;
278
+ };
279
+
280
+ /** Accept our explicit record protocol only. Never recover roles from payload text. `version` is the summary header's: v1 reads one JSON
281
+ * object per line, v2 the lines described above. Left out, it is taken from the first line, which a v1 record always begins with "{" and a v2 never. */
282
+ export const parseRecords = (text: string, version: TranscriptVersion = text.startsWith("{") ? 1 : 2): TranscriptRecord[] => {
70
283
  if (!text) return [];
71
- return text.split("\n").map(line => {
72
- const r: unknown = JSON.parse(line);
73
- if (!r || typeof r !== "object" || Array.isArray(r)) throw new Error("Invalid pi-tuck transcript record");
74
- const v = r as TranscriptRecord;
75
- const roles = ["user", "assistant", "custom", "bashExecution", "toolResult"];
76
- const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
77
- if (!["excerpt", "tool_call", "tool_receipt", "tool_excerpt", "bash", "omission", "section"].includes(v.kind) ||
78
- (v.role !== undefined && !roles.includes(v.role)) ||
79
- (v.sourceTime !== undefined && (v.sourceEntryId === undefined || !validSourceTime(v.sourceTime))) ||
80
- (v.sourceTimes !== undefined && (!Array.isArray(v.sourceTimes) || !v.sourceTimes.length || v.sourceTimes.some(t => !integer(t.sourceIndex) || !validSourceTime(Object.fromEntries(Object.entries(t).filter(([key]) => key !== "sourceIndex")))))) ||
81
- (v.sourceIndex !== undefined && !integer(v.sourceIndex)) ||
82
- (v.sourceEntryId !== undefined && (typeof v.sourceEntryId !== "string" || !v.sourceEntryId || v.sourceEntryId.length > 200 ||
83
- v.role !== "custom" || !["excerpt", "omission"].includes(v.kind))) ||
84
- (v.sourceIndices !== undefined && (!Array.isArray(v.sourceIndices) || !v.sourceIndices.every(integer))) ||
85
- (v.kind !== "omission" && typeof v.text !== "string") ||
86
- (v.kind === "omission" && typeof v.reason !== "string") ||
87
- (v.kind === "excerpt" && !["user", "assistant", "custom"].includes(v.role)) ||
88
- (v.kind === "tool_call" && v.role !== "assistant") ||
89
- (v.kind === "bash" && v.role !== "bashExecution") ||
90
- (v.kind === "section" && typeof v.title !== "string") ||
91
- (v.count !== undefined && !integer(v.count)) ||
92
- (v.span !== undefined && (v.span.coordinateSpace !== "sanitized-block" || !integer(v.span.start) || !integer(v.span.end) || !integer(v.span.totalCharacters) ||
93
- v.span.start > v.span.end || v.span.end > v.span.totalCharacters ||
94
- (v.kind === "excerpt" && v.text!.length !== v.span.end - v.span.start))))
95
- throw new Error("Invalid pi-tuck transcript record");
96
- if (v.sourceTimes) {
97
- const expected = v.sourceIndices ?? (v.sourceIndex === undefined ? [] : [v.sourceIndex]);
98
- if (v.sourceTimes.length !== expected.length || v.sourceTimes.some((t, i) => t.sourceIndex !== expected[i])) throw new Error("Mismatched source times");
99
- }
100
- if (["arguments", "argumentsJSONPrefix", "completeArgumentMembers"].some(key => Object.hasOwn(v, key))) {
101
- if (v.kind !== "tool_call" ||
102
- (Object.hasOwn(v, "arguments") && (!smallOriginalArguments(v.arguments, Number.MAX_SAFE_INTEGER) ||
103
- Object.hasOwn(v, "argumentsJSONPrefix") || Object.hasOwn(v, "completeArgumentMembers"))) ||
104
- (Object.hasOwn(v, "argumentsJSONPrefix") && (typeof v.argumentsJSONPrefix !== "string" || !v.argumentsJSONPrefix)) ||
105
- (Object.hasOwn(v, "completeArgumentMembers") && (!Object.hasOwn(v, "argumentsJSONPrefix") ||
106
- !smallOriginalArguments(v.completeArgumentMembers, Number.MAX_SAFE_INTEGER) || !Object.keys(v.completeArgumentMembers).length)))
107
- throw new Error("Invalid historical argument fields");
108
- }
109
- if (v.kind === "tool_receipt" || v.kind === "tool_excerpt") {
110
- const p = v.producer;
111
- const keys = [...(v.sourceTimes ? ["sourceTimes"] : []), "kind", "role", "sourceIndex", "sourceIndices", "historical", "isError", "text", "producer", ...(v.kind === "tool_excerpt" ? ["complete", "notice", "range"] : [])];
112
- if (Object.keys(v).length !== keys.length || !Object.keys(v).every(k => keys.includes(k)) ||
113
- v.role !== "toolResult" || v.historical !== true || typeof v.isError !== "boolean" ||
114
- !v.text || v.text.length > MAX_RECEIPT_CHARACTERS || !integer(v.sourceIndex) ||
115
- !p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).length !== 4 ||
116
- !Object.keys(p).every(k => ["sourceIndex", "toolName", "toolCallId", "arguments", "argumentsOmitted"].includes(k)) ||
117
- !integer(p.sourceIndex) || p.sourceIndex >= v.sourceIndex ||
118
- typeof p.toolName !== "string" || !p.toolName.trim() || p.toolName.length > 160 ||
119
- typeof p.toolCallId !== "string" || !p.toolCallId || p.toolCallId.length > 160 ||
120
- !(Object.hasOwn(p, "arguments") && !Object.hasOwn(p, "argumentsOmitted") && smallOriginalArguments(p.arguments) ||
121
- p.argumentsOmitted === true && !Object.hasOwn(p, "arguments")) ||
122
- !Array.isArray(v.sourceIndices) || v.sourceIndices.length !== 2 ||
123
- v.sourceIndices[0] !== p.sourceIndex || v.sourceIndices[1] !== v.sourceIndex)
124
- throw new Error("Invalid historical receipt record");
125
- if (v.kind === "tool_excerpt" && (v.complete !== false || ![RECEIPT_RANGE_NOTICE, LEGACY_RECEIPT_RANGE_NOTICE].includes(v.notice) || !v.range ||
126
- Object.keys(v.range).length !== 4 || !Object.keys(v.range).every(k => ["coordinateSpace", "start", "end", "totalCharacters"].includes(k)) ||
127
- v.range.coordinateSpace !== "readable-entry-utf16" || !integer(v.range.start) || !integer(v.range.end) || !integer(v.range.totalCharacters) ||
128
- v.range.start >= v.range.end || v.range.end > v.range.totalCharacters || v.range.totalCharacters <= MAX_RECEIPT_CHARACTERS ||
129
- v.text.length !== v.range.end - v.range.start)) throw new Error("Invalid historical receipt range");
130
- }
131
- return v;
132
- });
284
+ return text.split("\n").map(line => validate(version === 1 ? JSON.parse(line) : parseLine(line)));
133
285
  };