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
@@ -1,292 +0,0 @@
1
- import { extractPath } from "./tool-args.js";
2
- import { compileBrief, planBrief, heredocCloseIndex, isSegmentClosingAssistant } from "./brief.js";
3
- import { createBriefMeter } from "./brief-meter.js";
4
- import { TRANSCRIPT_HEADER, transcriptUnits, MAX_RECEIPT_CHARACTERS } from "./transcript.js";
5
- const DEFAULT_MAX_BLOCKS = 80;
6
- const DEFAULT_RECENT_BLOCKS = 16;
7
- const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
8
- const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
9
- const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
10
- // Durable workflow facts: which PR/issue was acted on, and git state changes.
11
- // Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
12
- const WORKFLOW_COMMAND_RE = /(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
13
- const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
14
- // A bash block whose every meaningful line is pure scaffolding (set -e, cd,
15
- // export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
16
- // fact. Such blocks get a score penalty so the size-relative budget never
17
- // pulls them in ahead of real edits/commands when spare chars appear.
18
- const TRIVIAL_BASH_LINE_RE = /^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
19
- const TRIVIAL_BASH_PENALTY = 16;
20
- const isTrivialOnlyBash = (raw) => {
21
- const lines = raw.split("\n");
22
- const kept = [];
23
- for (let i = 0; i < lines.length; i++) {
24
- kept.push(lines[i]);
25
- // Skip heredoc bodies (they are content, not scaffolding) using the same
26
- // hardened detection as brief.ts: only skip when the terminator exists
27
- // downstream, so a stray `<<` never swallows a later real command.
28
- const close = heredocCloseIndex(lines, i);
29
- if (close !== -1)
30
- i = close;
31
- }
32
- const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
33
- return meaningful.length === 0;
34
- };
35
- const asPathSet = (paths) => new Set((paths ?? []).filter(Boolean));
36
- const bashCommandFromBlock = (block) => {
37
- if (block.kind === "bash")
38
- return block.command;
39
- if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
40
- return block.args.command;
41
- }
42
- return undefined;
43
- };
44
- const pathFromBlock = (block) => {
45
- if (block.kind === "tool_call")
46
- return extractPath(block.args) ?? undefined;
47
- if (block.kind === "bash") {
48
- const match = block.command.match(/(?:^|\s)([\w./-]+\.[\w-]+)(?:\s|$)/);
49
- return match?.[1];
50
- }
51
- return undefined;
52
- };
53
- const add = (ranked, points, reason) => {
54
- ranked.score += points;
55
- ranked.reasons.push(reason);
56
- };
57
- const scoreBlock = (block, index, total, modifiedFiles, readFiles) => {
58
- const ranked = { block, index, score: 0, reasons: [] };
59
- const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
60
- add(ranked, recency, "recency");
61
- if (block.kind === "user")
62
- add(ranked, 18, "user-turn");
63
- if (block.kind === "assistant")
64
- add(ranked, 10, "assistant-context");
65
- if (block.kind === "notice")
66
- add(ranked, 10, "framework-notice");
67
- if (block.kind === "tool_result")
68
- add(ranked, 1, "tool-result-low-value");
69
- if (block.kind === "tool_call") {
70
- const command = bashCommandFromBlock(block);
71
- if (EDIT_TOOL_RE.test(block.name))
72
- add(ranked, 34, "edit-tool");
73
- else if (command && TEST_COMMAND_RE.test(command))
74
- add(ranked, 26, "test-command");
75
- else if (READ_TOOL_RE.test(block.name))
76
- add(ranked, 6, "read-tool");
77
- else
78
- add(ranked, 12, "tool-call");
79
- if (command && WORKFLOW_COMMAND_RE.test(command))
80
- add(ranked, 14, "workflow-command");
81
- if (command && isTrivialOnlyBash(command))
82
- add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
83
- }
84
- if (block.kind === "bash") {
85
- add(ranked, 8, "bash");
86
- if (block.exitCode != null && block.exitCode !== 0)
87
- add(ranked, 24, "nonzero-exit");
88
- if (TEST_COMMAND_RE.test(block.command))
89
- add(ranked, 22, "test-command");
90
- if (WORKFLOW_COMMAND_RE.test(block.command))
91
- add(ranked, 14, "workflow-command");
92
- // Penalize scaffolding-only commands, but not ones that failed (a failed
93
- // `ls`/`cd` still records a real state fact via nonzero-exit).
94
- if (isTrivialOnlyBash(block.command) && !(block.exitCode != null && block.exitCode !== 0)) {
95
- add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
96
- }
97
- }
98
- const path = pathFromBlock(block);
99
- if (path) {
100
- if (modifiedFiles.has(path))
101
- add(ranked, 18, "hook-modified-file");
102
- if (readFiles.has(path))
103
- add(ranked, 6, "hook-read-file");
104
- }
105
- if (block.kind === "tool_result" && block.text.length > 1000)
106
- add(ranked, -8, "long-tool-result");
107
- return ranked;
108
- };
109
- const boostAdjacency = (ranked) => {
110
- const important = ranked
111
- .filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
112
- .map((r) => r.index);
113
- for (const idx of important) {
114
- for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
115
- if (ranked[i].block.kind === "user") {
116
- add(ranked[i], 10, "near-important-event");
117
- break;
118
- }
119
- }
120
- for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
121
- if (ranked[i].block.kind === "assistant") {
122
- add(ranked[i], 7, "near-important-event");
123
- break;
124
- }
125
- }
126
- for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
127
- if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "bash") {
128
- add(ranked[i], 5, "after-important-event");
129
- break;
130
- }
131
- }
132
- }
133
- };
134
- const boostSegmentClosingAssistants = (ranked, blocks) => {
135
- for (const current of ranked) {
136
- if (current.block.kind !== "assistant")
137
- continue;
138
- if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS)
139
- continue;
140
- if (isSegmentClosingAssistant(blocks, current.index))
141
- add(current, 14, "segment-closing-assistant");
142
- }
143
- };
144
- export const rankBriefBlocks = (blocks, options = {}) => {
145
- const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
146
- const readFiles = asPathSet(options.fileOps?.readFiles);
147
- const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
148
- boostAdjacency(ranked);
149
- boostSegmentClosingAssistants(ranked, blocks);
150
- return ranked;
151
- };
152
- export const selectRankedBriefBlocks = (blocks, options = {}) => {
153
- // A rendered allowance owns capacity. An independent count must not discard
154
- // fitting original dialogue; all blocks were already normalized in memory.
155
- const maxBlocks = options.maxBlocks ?? (options.maxBriefChars != null ? blocks.length : DEFAULT_MAX_BLOCKS);
156
- // Size-relative budget: when a ceiling + slope are provided, the effective
157
- // char budget scales with transcript length (block count) between the floor
158
- // (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
159
- // long-tail, so they earn more brief budget; small sessions stay at the floor.
160
- const maxBriefChars = options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
161
- ? Math.round(Math.min(options.maxBriefCharsCeiling, Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length)))
162
- : options.maxBriefChars;
163
- // Fast path: nothing to trim by count and no char budget to enforce.
164
- if (blocks.length <= maxBlocks && maxBriefChars == null && options.maxBriefUnits == null)
165
- return blocks;
166
- const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
167
- const ranked = rankBriefBlocks(blocks, options);
168
- const selected = new Set();
169
- // Charge the actual candidate serialization. Adjacent-block truncation and
170
- // tool omission notices can change its size; isolated-block estimates cannot certify it.
171
- // The meter keeps that size current as blocks join; the final render below remains the authority.
172
- const excerpts = new WeakMap();
173
- const full = new Set();
174
- const meter = createBriefMeter(blocks, options.mandatorySourceIndices, excerpts);
175
- const within = (size) => (maxBriefChars == null || size.chars <= maxBriefChars) &&
176
- (options.maxBriefUnits == null || size.units <= options.maxBriefUnits);
177
- const admit = (indices, preferFull = false) => {
178
- const added = indices.filter(i => !selected.has(i));
179
- if (!added.length && !preferFull)
180
- return true;
181
- if (selected.size + added.length > maxBlocks)
182
- return false;
183
- const promoted = preferFull ? indices.filter(i => ["user", "assistant", "bash"].includes(blocks[i].kind) && !full.has(blocks[i])) : [];
184
- // Notices are best effort: never overflow or displace otherwise fitting text.
185
- const fits = () => { const { withNotices, withoutNotices } = meter.size(added); return within(withNotices) || within(withoutNotices); };
186
- for (const i of added)
187
- meter.select(i);
188
- for (const i of promoted)
189
- meter.promote(i);
190
- if (!fits()) {
191
- for (const i of promoted.splice(0))
192
- meter.demote(i);
193
- // A whole shell proposal that does not fit is withdrawn, not retried as an excerpt.
194
- if ((preferFull && indices.some(i => blocks[i].kind === "bash")) || !fits()) {
195
- for (const i of added)
196
- meter.unselect(i);
197
- return false;
198
- }
199
- }
200
- for (const i of added)
201
- selected.add(i);
202
- meter.commit(added);
203
- for (const i of promoted)
204
- full.add(blocks[i]);
205
- return true;
206
- };
207
- // Keep the latest blocks to preserve local continuity, iterating NEWEST first
208
- // so the most recent context is guaranteed. When a char budget is active these
209
- // are charged against it too and over-budget blocks are skipped -- otherwise a
210
- // run of large recent blocks could blow past maxBriefChars (the old bug on very
211
- // long transcripts, where preserve-recent alone exceeded the budget).
212
- for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
213
- // Historical action overviews compete after the conversational base. Fresh
214
- // native rounds are protected by the adapter, outside this old-prefix view.
215
- if (["tool_result", "tool_call", "bash"].includes(blocks[i].kind))
216
- continue;
217
- if (selected.has(i))
218
- continue;
219
- if (!admit([i]))
220
- continue;
221
- }
222
- // Structural exchanges are derived from the original chronology. A user
223
- // carries prose from the preceding assistant message, including prose before
224
- // that message's trailing calls. Only explicit source identity crosses calls.
225
- const groups = ranked.filter(r => r.block.kind === "user").map(item => {
226
- let preceding = item.index - 1;
227
- while (preceding >= 0 && blocks[preceding].kind === "tool_result")
228
- preceding--;
229
- const previous = blocks[preceding];
230
- const prose = [];
231
- if (previous?.kind === "assistant" || previous?.kind === "tool_call") {
232
- const source = previous.sourceIndex;
233
- for (let i = preceding; i >= 0; i--) {
234
- if (source === undefined ? i !== preceding : blocks[i].sourceIndex !== source)
235
- break;
236
- if (blocks[i].kind === "assistant")
237
- prose.unshift(i);
238
- }
239
- }
240
- return [...prose, item.index];
241
- }).reverse();
242
- // Admit the conversational base before spending room on any full proposal.
243
- // Otherwise a newly fitting recent assistant can crowd out an older user.
244
- for (const group of groups) {
245
- if (!admit(group)) {
246
- // Bounded atomicity: an oversized pair must not exclude a useful user turn.
247
- admit([group.at(-1)]);
248
- }
249
- }
250
- const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
251
- for (const item of ordered) {
252
- if (selected.size >= maxBlocks)
253
- break;
254
- if (selected.has(item.index))
255
- continue;
256
- if (item.block.kind === "tool_result")
257
- continue;
258
- if (!admit([item.index]))
259
- continue;
260
- }
261
- // Complete the existing coverage pass before expanding already represented
262
- // user wording. Whole proposals/choices are still eligible in spare room.
263
- for (const i of [...selected].sort((a, b) => b - a))
264
- if (blocks[i].kind === "user")
265
- admit([i], true);
266
- // Expand selected assistant prose only into spare capacity. A large recent
267
- // message must not consume the budget before user exchanges are considered.
268
- for (const i of [...selected].sort((a, b) => b - a))
269
- if (blocks[i].kind === "assistant")
270
- admit([i], true);
271
- // Cheap exact observations use only spare allowance after dialogue. Size
272
- // and recency are signals, not proof of usefulness, freshness or success.
273
- for (let i = blocks.length - 1; i >= 0; i--) {
274
- const block = blocks[i];
275
- if (block.kind === "tool_result" && block.originalReceipt)
276
- admit([i]);
277
- }
278
- // Enrich already retained shell events only after the unchanged base. Whole
279
- // stored output may be truncated; it is historical evidence, not verification.
280
- for (const i of [...selected].sort((a, b) => b - a)) {
281
- const block = blocks[i];
282
- if (block.kind === "bash" && block.sourceIndex !== undefined && block.output.length <= MAX_RECEIPT_CHARACTERS)
283
- admit([i], true);
284
- }
285
- const result = [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
286
- planBrief(result, full, blocks, options.mandatorySourceIndices);
287
- const rendered = TRANSCRIPT_HEADER + "\n" + compileBrief(result, excerpts);
288
- if ((maxBriefChars != null && rendered.length > maxBriefChars) ||
289
- (options.maxBriefUnits != null && transcriptUnits(rendered) > options.maxBriefUnits))
290
- planBrief(result, full, blocks, options.mandatorySourceIndices, false);
291
- return result;
292
- };
@@ -1,235 +0,0 @@
1
- import type { NormalizedBlock } from "../types";
2
- import { absorbCall, blockRecords, coveredCallKey, decorateRecord, isOmittable, noticeTimes, omissionRecord, receiptKey, repeatsCall, segmentClosing,
3
- type ExcerptCache } from "./brief";
4
- import { TRANSCRIPT_HEADER, lineUnits, type TranscriptRecord } from "./transcript";
5
-
6
- /** A set of integers in [0, size) with ordered neighbour queries, in a tree of 32-bit words. */
7
- export class IndexSet {
8
- private readonly levels: Uint32Array[] = [];
9
- constructor(size: number) {
10
- let words = Math.max(1, size);
11
- do { words = Math.ceil(words / 32); this.levels.push(new Uint32Array(words)); } while (words > 1);
12
- }
13
- has(i: number): boolean { return (this.levels[0]![i >>> 5]! & (1 << (i & 31))) !== 0; }
14
- add(i: number): void {
15
- for (const level of this.levels) {
16
- const word = i >>> 5, was = level[word]!;
17
- level[word] = was | (1 << (i & 31));
18
- if (was !== 0) return;
19
- i = word;
20
- }
21
- }
22
- remove(i: number): void {
23
- for (const level of this.levels) {
24
- const word = i >>> 5;
25
- level[word] = level[word]! & ~(1 << (i & 31));
26
- if (level[word] !== 0) return;
27
- i = word;
28
- }
29
- }
30
- /** The smallest member above `i`, or -1. */
31
- next(i: number): number {
32
- for (let l = 0; l < this.levels.length; l++) {
33
- const word = i >>> 5, bit = i & 31;
34
- const rest = bit === 31 ? 0 : this.levels[l]![word]! & (-1 << (bit + 1));
35
- if (rest === 0) { i = word; continue; }
36
- let at = (word << 5) | (31 - Math.clz32(rest & -rest));
37
- for (let k = l - 1; k >= 0; k--) { const w = this.levels[k]![at]!; at = (at << 5) | (31 - Math.clz32(w & -w)); }
38
- return at;
39
- }
40
- return -1;
41
- }
42
- /** The largest member below `i`, or -1. */
43
- prev(i: number): number {
44
- for (let l = 0; l < this.levels.length; l++) {
45
- const word = i >>> 5, bit = i & 31;
46
- const rest = this.levels[l]![word]! & ~(-1 << bit);
47
- if (rest === 0) { i = word; continue; }
48
- let at = (word << 5) | (31 - Math.clz32(rest));
49
- for (let k = l - 1; k >= 0; k--) at = (at << 5) | (31 - Math.clz32(this.levels[k]![at]!));
50
- return at;
51
- }
52
- return -1;
53
- }
54
- }
55
-
56
- export interface BriefSize { chars: number; units: number }
57
-
58
- export interface BriefMeter {
59
- /** Block `i` joins the selection, or leaves it again. */
60
- select(i: number): void;
61
- unselect(i: number): void;
62
- /** Block `i` is rendered whole instead of as an excerpt, or no longer. */
63
- promote(i: number): void;
64
- demote(i: number): void;
65
- /** The rendered size of the selection, were the unselected blocks `added` selected too, with and without the omission notices. */
66
- size(added: readonly number[]): { withNotices: BriefSize; withoutNotices: BriefSize };
67
- /** Settles the omission notices for blocks selected for good. */
68
- commit(added: readonly number[]): void;
69
- }
70
-
71
- /**
72
- * The size of `TRANSCRIPT_HEADER + "\n" + compileBrief(selected)` as blocks join, kept up to date by recomputing only the records a change
73
- * can affect, which are those of the block, its neighbours and the identical calls merged with them. This is the admission loop's yardstick,
74
- * so it must equal what the full render measures; the final render in `selectRankedBriefBlocks` remains the authority.
75
- *
76
- * It relies on what `normalize` guarantees for the blocks of one brief: they are distinct objects; all carry the same `sourceTimes` or none
77
- * does; and notices that share a `sourceEntryId` share a `sourceTime`.
78
- */
79
- export const createBriefMeter = (blocks: NormalizedBlock[], mandatory: ReadonlySet<number> | undefined, cache: ExcerptCache): BriefMeter => {
80
- const n = blocks.length;
81
- const closing = segmentClosing(blocks);
82
- const times = blocks[0]?.sourceTimes;
83
- const entryTimes = noticeTimes(blocks);
84
- const lengths = new WeakMap<TranscriptRecord, number>();
85
- const lengthOf = (record: TranscriptRecord): number => {
86
- let length = lengths.get(record);
87
- if (length === undefined) lengths.set(record, length = JSON.stringify(decorateRecord(record, times, entryTimes)).length);
88
- return length;
89
- };
90
-
91
- // The state of each block: selected, rendered whole, and the records it renders alone while it has any (`active`).
92
- const selected = new Uint8Array(n), whole = new Uint8Array(n);
93
- const records: (TranscriptRecord[] | undefined)[] = new Array(n);
94
- const rendered: (TranscriptRecord[] | undefined)[][] = [new Array(n), new Array(n)];
95
- const active = new IndexSet(n);
96
- // Blocks whose first record is a repeat of the record before it fold into it; the rest of the output follows from these relations.
97
- const merged = new Uint8Array(n);
98
- const contributedChars = new Int32Array(n), contributedUnits = new Int32Array(n), contributedRecords = new Int32Array(n);
99
- let chars = 0, units = 0, count = 0;
100
-
101
- // A selected receipt makes the overview of the call it quotes redundant.
102
- const callKeys: (string | undefined)[] = new Array(n);
103
- const receiptKeys: (string | undefined)[] = new Array(n);
104
- const callsOf = new Map<string, number[]>(), receipts = new Map<string, number>();
105
- blocks.forEach((block, i) => {
106
- const call = coveredCallKey(block);
107
- if (call !== undefined) { callKeys[i] = call; const calls = callsOf.get(call); if (calls) calls.push(i); else callsOf.set(call, [i]); }
108
- receiptKeys[i] = receiptKey(block);
109
- });
110
-
111
- const own = (i: number): TranscriptRecord[] | undefined => {
112
- const block = blocks[i]!, key = callKeys[i];
113
- if (key !== undefined && receipts.get(key)) return undefined;
114
- const slot = rendered[whole[i]!]!;
115
- const result = slot[i] ??= blockRecords(block, { full: whole[i] === 1, closing: closing.has(block), mandatory, bounded: true, covered: false }, cache);
116
- return result.length ? result : undefined;
117
- };
118
-
119
- const repeats = (a: number, b: number): boolean => {
120
- const next = records[b]!;
121
- return next.length === 1 && repeatsCall(records[a]!.at(-1), next[0]!);
122
- };
123
-
124
- const first = (from: number): number => active.has(from) ? from : active.next(from);
125
-
126
- const setRecords = (j: number, next: TranscriptRecord[] | undefined): void => {
127
- if (records[j] === next) return;
128
- // Every group of repeats that the change can reach: from the head of the group before `j` to the end of the group after it.
129
- const before = active.prev(j), after = active.next(j);
130
- let low = j, high = j;
131
- if (before !== -1) for (low = before; merged[low]; low = active.prev(low));
132
- if (after !== -1) for (high = after; ;) { const further = active.next(high); if (further === -1 || !merged[further]) break; high = further; }
133
- for (let b = first(low); b !== -1 && b <= high; b = active.next(b)) {
134
- chars -= contributedChars[b]!; units -= contributedUnits[b]!; count -= contributedRecords[b]!;
135
- contributedChars[b] = contributedUnits[b] = contributedRecords[b] = 0;
136
- }
137
- records[j] = next;
138
- if (next) active.add(j); else active.remove(j);
139
- let group: number[] = [];
140
- const settle = () => {
141
- if (!group.length) return;
142
- const head = records[group[0]!]!;
143
- const shown = group.length === 1 ? head :
144
- [group.slice(1).reduce((all, b) => absorbCall(all, records[b]![0]!), head[0]!)];
145
- for (const record of shown) {
146
- const length = lengthOf(record);
147
- contributedChars[group[0]!] += length; contributedUnits[group[0]!] += lineUnits(length); contributedRecords[group[0]!]++;
148
- }
149
- chars += contributedChars[group[0]!]!; units += contributedUnits[group[0]!]!; count += contributedRecords[group[0]!]!;
150
- };
151
- for (let b = first(low); b !== -1 && b <= high; b = active.next(b)) {
152
- const previous = active.prev(b);
153
- merged[b] = previous !== -1 && repeats(previous, b) ? 1 : 0;
154
- if (!merged[b]) { settle(); group = []; }
155
- group.push(b);
156
- }
157
- settle();
158
- };
159
- const refresh = (i: number) => setRecords(i, selected[i] ? own(i) : undefined);
160
-
161
- const quoted = (i: number, delta: 1 | -1) => {
162
- const key = receiptKeys[i];
163
- if (key === undefined) return;
164
- const now = (receipts.get(key) ?? 0) + delta;
165
- receipts.set(key, now);
166
- if (now === (delta === 1 ? 1 : 0)) for (const call of callsOf.get(key) ?? []) if (selected[call]) refresh(call);
167
- };
168
-
169
- // The omission notices say how many unselected user and assistant text blocks there are and where the first of them were read.
170
- const roles = (["user", "assistant"] as const).map(role => {
171
- const perSource = new Map<number, number>();
172
- let total = 0, unlocated = 0;
173
- for (const block of blocks) if (block.kind === role && isOmittable(block, mandatory)) {
174
- total++;
175
- if (block.sourceIndex === undefined) unlocated++; else perSource.set(block.sourceIndex, (perSource.get(block.sourceIndex) ?? 0) + 1);
176
- }
177
- const sources = [...perSource.keys()].sort((a, b) => a - b);
178
- const ids = new Map(sources.map((source, id) => [source, id]));
179
- const alive = new IndexSet(sources.length);
180
- sources.forEach((_, id) => alive.add(id));
181
- return { role, total, unlocated, perSource, sources, ids, alive };
182
- });
183
- const omittedBy = (added: readonly number[], role: typeof roles[number]) => {
184
- let removed = 0, unlocated = 0;
185
- const taken = new Map<number, number>();
186
- for (const i of added) {
187
- const block = blocks[i]!;
188
- if (block.kind !== role.role || !isOmittable(block, mandatory)) continue;
189
- removed++;
190
- if (block.sourceIndex === undefined) unlocated++; else taken.set(block.sourceIndex, (taken.get(block.sourceIndex) ?? 0) + 1);
191
- }
192
- return { removed, unlocated, taken };
193
- };
194
- const notice = (added: readonly number[], role: typeof roles[number]): TranscriptRecord | undefined => {
195
- const { removed, unlocated, taken } = omittedBy(added, role);
196
- if (role.total === removed) return undefined;
197
- const gone = new Set<number>();
198
- for (const [source, k] of taken) if (role.perSource.get(source) === k) gone.add(source);
199
- const sources: number[] = [];
200
- for (let id = role.alive.has(0) ? 0 : role.alive.next(0); id !== -1 && sources.length < 9; id = role.alive.next(id))
201
- if (!gone.has(role.sources[id]!)) sources.push(role.sources[id]!);
202
- return omissionRecord(role.role, role.total - removed, sources, role.unlocated > unlocated);
203
- };
204
-
205
- const header = TRANSCRIPT_HEADER.length + 1;
206
- const rendering = (c: number, u: number, k: number): BriefSize => k === 0 ? { chars: header, units: 2 } : { chars: header + c + k - 1, units: 1 + u };
207
-
208
- return {
209
- select(i) { selected[i] = 1; quoted(i, 1); refresh(i); },
210
- unselect(i) { selected[i] = 0; refresh(i); quoted(i, -1); },
211
- promote(i) { whole[i] = 1; refresh(i); },
212
- demote(i) { whole[i] = 0; refresh(i); },
213
- size(added) {
214
- let c = 0, u = 0, k = 0;
215
- for (const role of roles) {
216
- const record = notice(added, role);
217
- if (!record) continue;
218
- const length = lengthOf(record);
219
- c += length; u += lineUnits(length); k++;
220
- }
221
- return { withNotices: rendering(chars + c, units + u, count + k), withoutNotices: rendering(chars, units, count) };
222
- },
223
- commit(added) {
224
- for (const role of roles) {
225
- const { removed, unlocated, taken } = omittedBy(added, role);
226
- role.total -= removed; role.unlocated -= unlocated;
227
- for (const [source, k] of taken) {
228
- const left = role.perSource.get(source)! - k;
229
- role.perSource.set(source, left);
230
- if (left === 0) role.alive.remove(role.ids.get(source)!);
231
- }
232
- }
233
- },
234
- };
235
- };