pi-tuck 1.0.1 → 1.0.4

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 (50) hide show
  1. package/CHANGELOG.md +65 -3
  2. package/README.md +138 -57
  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 +7 -0
  10. package/dist/src/compaction.js +32 -16
  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 +41 -255
  16. package/dist/src/tuck/core/filter-noise.js +2 -1
  17. package/dist/src/tuck/core/format.d.ts +2 -6
  18. package/dist/src/tuck/core/format.js +4 -10
  19. package/dist/src/tuck/core/normalize.js +22 -50
  20. package/dist/src/tuck/core/shrink.d.ts +33 -0
  21. package/dist/src/tuck/core/shrink.js +218 -0
  22. package/dist/src/tuck/core/summarize.d.ts +15 -7
  23. package/dist/src/tuck/core/summarize.js +17 -22
  24. package/dist/src/tuck/core/transcript.d.ts +24 -13
  25. package/dist/src/tuck/core/transcript.js +251 -83
  26. package/dist/src/tuck/types.d.ts +4 -21
  27. package/dist/src/tuck.d.ts +8 -10
  28. package/dist/src/tuck.js +23 -25
  29. package/index.ts +8 -5
  30. package/package.json +1 -1
  31. package/src/budget.ts +29 -9
  32. package/src/build-id.ts +1 -1
  33. package/src/compaction.ts +33 -14
  34. package/src/diagnostics.ts +1 -1
  35. package/src/omissions.ts +8 -8
  36. package/src/tuck/core/brief.ts +48 -265
  37. package/src/tuck/core/filter-noise.ts +2 -1
  38. package/src/tuck/core/format.ts +4 -11
  39. package/src/tuck/core/normalize.ts +21 -50
  40. package/src/tuck/core/shrink.ts +217 -0
  41. package/src/tuck/core/summarize.ts +26 -24
  42. package/src/tuck/core/transcript.ts +238 -82
  43. package/src/tuck/types.ts +9 -20
  44. package/src/tuck.ts +30 -32
  45. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  46. package/dist/src/tuck/core/brief-meter.js +0 -273
  47. package/dist/src/tuck/core/rank.d.ts +0 -42
  48. package/dist/src/tuck/core/rank.js +0 -292
  49. package/src/tuck/core/brief-meter.ts +0 -235
  50. package/src/tuck/core/rank.ts +0 -331
@@ -1,14 +1,4 @@
1
- import { clip } from "./content.js";
2
- import { serializeRecords, transcriptUnits, TRANSCRIPT_UNIT_CHARACTERS, smallOriginalArguments, MAX_PRODUCER_ARGUMENT_CHARACTERS } from "./transcript.js";
3
- const TRUNCATE_USER = 256;
4
- const SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS = 120;
5
- const SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS = 120;
6
- const ASSISTANT_HEAD_WORDS = 80;
7
- const ASSISTANT_TAIL_WORDS = 120;
8
- // ── noise filtering ──
9
- const isNoiseUser = (text) => {
10
- return !text.trim();
11
- };
1
+ import { escapedLength } from "./transcript.js";
12
2
  // ── truncation ──
13
3
  // Unicode-aware word segmentation via Intl.Segmenter (built-in, zero dependency)
14
4
  const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
@@ -108,22 +98,26 @@ const passageRanges = (text) => {
108
98
  prose(cursor, text.length);
109
99
  return ranges;
110
100
  };
111
- /** Existing word/serialized budgets choose complete source passages, never
112
- * rewritten prose. Physical line wrapping does not define a prose candidate. */
113
- const excerptRecords = (text, role, sourceIndex, headLimit, tailLimit = 0, boundedChars = false) => {
101
+ const analyze = (text) => {
114
102
  const passages = passageRanges(text);
115
- const costs = passages.map(({ start, end }) => [...segmenter.segment(text.slice(start, end))]
116
- .filter((seg) => isWord(seg) && !STOP_WORDS.has(seg.segment.toLowerCase())).length);
103
+ return { passages, costs: passages.map(({ start, end }) => [...segmenter.segment(text.slice(start, end))]
104
+ .filter((seg) => isWord(seg) && !STOP_WORDS.has(seg.segment.toLowerCase())).length) };
105
+ };
106
+ /** Existing word/serialized budgets choose complete source passages, never
107
+ * rewritten prose. Physical line wrapping does not define a prose candidate. A passage's serialized length is its length in a record line. */
108
+ const excerptRecords = (text, { passages, costs }, role, sourceIndex, headLimit, tailLimit, readFrom) => {
117
109
  const charLimit = 1024;
118
110
  const identity = { role, ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
119
- if ((!boundedChars || text.length <= charLimit && JSON.stringify(text).length <= charLimit) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
111
+ // A line carries at least as many characters as the text, so a long text is over the limit without counting its escapes.
112
+ const fits = (from, to) => to - from <= charLimit && escapedLength(text.slice(from, to)) <= charLimit;
113
+ if (fits(0, text.length) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
120
114
  return [{ kind: "excerpt", ...identity, text }];
121
115
  let head = 0, headCost = 0, tail = passages.length, tailCost = 0;
122
- while (head < tail && headCost + costs[head] <= headLimit && (!boundedChars || JSON.stringify(text.slice(0, passages[head].end)).length <= charLimit)) {
116
+ while (head < tail && headCost + costs[head] <= headLimit && fits(0, passages[head].end)) {
123
117
  headCost += costs[head++];
124
118
  }
125
119
  if (tailLimit > 0) {
126
- while (tail > head && tailCost + costs[tail - 1] <= tailLimit && (!boundedChars || JSON.stringify(text.slice(passages[tail - 1].start)).length <= charLimit)) {
120
+ while (tail > head && tailCost + costs[tail - 1] <= tailLimit && fits(passages[tail - 1].start, text.length)) {
127
121
  tailCost += costs[--tail];
128
122
  }
129
123
  }
@@ -133,259 +127,51 @@ const excerptRecords = (text, role, sourceIndex, headLimit, tailLimit = 0, bound
133
127
  const records = [];
134
128
  if (headEnd)
135
129
  records.push({ kind: "excerpt", ...identity, text: text.slice(0, headEnd), span: span(0, headEnd) });
136
- records.push({ kind: "omission", ...identity, reason: "source truncated; read source; omitted text may qualify retained quotations", span: span(headEnd, tailStart) });
130
+ records.push({ kind: "omission", ...identity, reason: readFrom(headEnd), span: span(headEnd, tailStart) });
137
131
  if (tailStart < text.length)
138
132
  records.push({ kind: "excerpt", ...identity, text: text.slice(tailStart), span: span(tailStart, text.length) });
139
133
  return records;
140
134
  };
141
- const nextRenderableBlock = (blocks, index) => {
142
- for (let i = index + 1; i < blocks.length; i++) {
143
- if (blocks[i].kind !== "tool_result")
144
- return blocks[i];
145
- }
146
- return undefined;
147
- };
148
- export const isSegmentClosingAssistant = (blocks, index) => {
149
- if (blocks[index]?.kind !== "assistant")
150
- return false;
151
- const next = nextRenderableBlock(blocks, index);
152
- return !next || next.kind === "user";
153
- };
154
- // ── bash command compression ──
155
- export const HEREDOC_OPEN_RE = /(?<![\w)])<<-?\s*["']?([A-Za-z_]\w*)["']?/;
156
- /**
157
- * If `lines[i]` opens a heredoc whose terminator actually appears on a later
158
- * line, return that terminator's line index; otherwise -1. Callers use -1 to
159
- * leave following lines intact instead of treating a stray `<<` (a shift op, a
160
- * quoted string, or a truncated body) as a heredoc and skipping real commands.
161
- */
162
- export const heredocCloseIndex = (lines, i) => {
163
- const hd = lines[i].match(HEREDOC_OPEN_RE);
164
- if (!hd)
165
- return -1;
166
- const term = hd[1];
167
- for (let j = i + 1; j < lines.length; j++) {
168
- if (lines[j].trim() === term)
169
- return j;
170
- }
171
- return -1;
172
- };
173
- /** Source-relative call identities remain explicitly bounded. */
174
- const excerpt = (text, limit) => JSON.stringify(clip(text, limit)) + (text.length > limit ? " [truncated; read source]" : "");
175
- /** Exact serialized prefix only after a lossless JSON roundtrip. No argument interpretation. */
176
- const incompletePrefix = (text) => {
177
- let end = Math.min(text.length, MAX_PRODUCER_ARGUMENT_CHARACTERS);
178
- if (end < text.length && end > 0 && /[\uD800-\uDBFF]/u.test(text[end - 1]) && /[\uDC00-\uDFFF]/u.test(text[end]))
179
- end--;
180
- return JSON.stringify(text.slice(0, end)) + " [incomplete prefix; omitted text may qualify it; read original source]";
181
- };
182
- /** Small complete arguments remain unchanged; oversized valid originals expose an exact prefix. */
183
- const toolOneLiner = (name, args) => `* ${name} ` + (smallOriginalArguments(args)
184
- ? `arguments=${JSON.stringify(args)}`
185
- : smallOriginalArguments(args, Number.MAX_SAFE_INTEGER)
186
- ? `serializedArgumentsPrefix=${incompletePrefix(JSON.stringify(args))}`
187
- : "arguments omitted; read original source");
188
- /** Literal JSON fields avoid quoting JSON inside quoted transcript text.
189
- * Keep the old leading excerpt before adding whole members. Its existing row
190
- * unit allowance bounds the offer; full character/admission owners still apply. */
191
- const literalArguments = (baseline, args, facts) => {
192
- const budget = transcriptUnits(serializeRecords([baseline])) * TRANSCRIPT_UNIT_CHARACTERS;
193
- const charge = (record) => serializeRecords([record]).length;
194
- const complete = { ...baseline, text: facts, arguments: args };
195
- if (charge(complete) <= budget)
196
- return complete;
197
- const original = JSON.stringify(args);
198
- let end = Math.min(original.length, MAX_PRODUCER_ARGUMENT_CHARACTERS);
199
- if (end < original.length && /[\uD800-\uDBFF]/u.test(original[end - 1]) && /[\uDC00-\uDFFF]/u.test(original[end]))
200
- end--;
201
- const record = { ...baseline,
202
- text: facts + " [incomplete arguments; omitted text may qualify them; read original source]",
203
- argumentsJSONPrefix: original.slice(0, end) };
204
- if (charge(record) > budget)
205
- return baseline;
206
- const members = Object.entries(args).map(([key, value], order) => ({ key, value, order,
207
- cost: JSON.stringify({ [key]: value }).length })).sort((a, b) => a.cost - b.cost || a.order - b.order);
208
- const selected = [];
209
- const projected = () => Object.fromEntries([...selected].sort((a, b) => a.order - b.order).map(({ key, value }) => [key, value]));
210
- for (const member of members) {
211
- selected.push(member);
212
- if (charge({ ...record, completeArgumentMembers: projected() }) > budget)
213
- selected.pop();
214
- }
215
- if (selected.length)
216
- record.completeArgumentMembers = projected();
217
- // Search only complete code-point boundaries: escaped lone surrogates would
218
- // otherwise make encoded prefix prices non-monotonic.
219
- const ends = [end], ceiling = Math.min(original.length - 1, budget);
220
- while (end < ceiling) {
221
- const next = end + (/[\uD800-\uDBFF]/u.test(original[end]) && /[\uDC00-\uDFFF]/u.test(original[end + 1]) ? 2 : 1);
222
- if (next > ceiling)
223
- break;
224
- ends.push(end = next);
225
- }
226
- let low = 0, high = ends.length - 1;
227
- while (low < high) {
228
- const mid = Math.ceil((low + high) / 2);
229
- if (charge({ ...record, argumentsJSONPrefix: original.slice(0, ends[mid]) }) <= budget)
230
- low = mid;
231
- else
232
- high = mid - 1;
233
- }
234
- record.argumentsJSONPrefix = original.slice(0, ends[low]);
235
- return record;
236
- };
237
- /** Direct shell history quotes an exact command prefix, never an interpreted command. */
238
- const bashRecord = (command, exitCode) => (smallOriginalArguments({ command }) ? `command=${JSON.stringify(command)}`
239
- : smallOriginalArguments({ command }, Number.MAX_SAFE_INTEGER)
240
- ? command.length <= MAX_PRODUCER_ARGUMENT_CHARACTERS ? `command=${JSON.stringify(command)}` : `commandPrefix=${incompletePrefix(command)}`
241
- : "command omitted; read original source") +
242
- ` recordedExitCode=${exitCode === undefined ? "unknown" : String(exitCode)}`;
243
- const plans = new WeakMap();
244
- /** The assistant texts that close a segment: no further text before the next user turn or the end of the chronology. */
245
- export const segmentClosing = (chronology) => new Set(chronology.filter((_, i) => isSegmentClosingAssistant(chronology, i)));
135
+ export const isDialogue = (block) => block.kind === "user" || block.kind === "assistant" || block.kind === "notice";
246
136
  /** A user or assistant text block the omission notices count when it is unselected: non-empty, and not rendered in the mandatory base. */
247
137
  export const isOmittable = (block, mandatorySources) => (block.kind === "user" || block.kind === "assistant") && Boolean(block.text.trim()) &&
248
138
  (block.sourceIndex === undefined || !mandatorySources?.has(block.sourceIndex));
249
- /** `sources` are the distinct locators of the omitted blocks in ascending order; at most the first 9 decide the notice. */
139
+ /** `sources` are the distinct locators of the omitted blocks in ascending order; at most the first 9 decide the notice. They are written
140
+ * as "#N" on the record's line. */
250
141
  export const omissionRecord = (role, count, sources, unlocated) => ({
251
142
  kind: "omission", role, count,
252
143
  ...(sources.length ? { sourceIndices: sources.slice(0, 8) } : {}),
253
144
  reason: "Completely unselected historical text blocks; count is text blocks, not messages. " +
254
- "sourceIndices are individual original locators, not a contiguous range. " +
145
+ "The # locators are individual original locators, not a contiguous range. " +
255
146
  (sources.length > 8 ? "Locator list incomplete. " : "") +
256
147
  (unlocated ? "Some text blocks have no source locator. " : "") +
257
148
  "Read originals with tuck_recall; omitted text may qualify retained text. No new authority."
258
149
  });
259
- export const planBrief = (blocks, full, chronology, mandatorySources, includeOmissions = true) => {
260
- const selected = new Set(blocks), omissions = [];
261
- if (includeOmissions)
262
- for (const role of ["user", "assistant"]) {
263
- const missing = chronology.filter(block => block.kind === role && isOmittable(block, mandatorySources) && !selected.has(block));
264
- if (!missing.length)
265
- continue;
266
- const sources = [...new Set(missing.flatMap(block => block.sourceIndex === undefined ? [] : [block.sourceIndex]))].sort((a, b) => a - b);
267
- omissions.push(omissionRecord(role, missing.length, sources, missing.some(block => block.sourceIndex === undefined)));
268
- }
269
- plans.set(blocks, { full, closing: segmentClosing(chronology), mandatorySources, omissions });
150
+ const analyses = new WeakMap();
151
+ /** The records of one dialogue block alone, before source times are attached: its whole original text, or, with a `clip`, the excerpts
152
+ * that keep their sanitized-block spans around an omission. A mandatory source renders nothing: the adapter quotes it in its base. */
153
+ export const blockRecords = (b, mandatory, clip) => {
154
+ if (!b.text.trim() || b.sourceIndex !== undefined && mandatory?.has(b.sourceIndex))
155
+ return [];
156
+ const role = b.kind === "notice" ? "custom" : b.kind;
157
+ const notice = b.kind === "notice" && b.sourceEntryId !== undefined ? { sourceEntryId: b.sourceEntryId } : {};
158
+ if (!clip)
159
+ return [{ kind: "excerpt", role, ...(b.sourceIndex !== undefined ? { sourceIndex: b.sourceIndex } : {}), text: b.originalText ?? b.text, ...notice }];
160
+ let analysis = analyses.get(b);
161
+ if (!analysis)
162
+ analyses.set(b, analysis = analyze(b.text));
163
+ // How to read what a clip leaves out. tuck_recall offsets count the source's readable text, so the clip's offset into the block is one
164
+ // only when the block begins that text and sanitizing left it as it was; otherwise the whole source is read from its start.
165
+ const locator = b.sourceIndex !== undefined ? { index: b.sourceIndex } : b.kind === "notice" && b.sourceEntryId !== undefined ? { entryId: b.sourceEntryId } : undefined;
166
+ const exact = b.startsSource === true && b.originalText === b.text;
167
+ const readFrom = (offset) => locator ? `tuck_recall ${JSON.stringify(exact ? { ...locator, offset } : locator)}` : "source has no locator to read it by";
168
+ return excerptRecords(b.text, analysis, role, b.sourceIndex, clip[0], clip[1], readFrom).map(record => ({ ...record, ...notice }));
270
169
  };
271
- /** The original call that a selected complete receipt quotes; that call's own overview is then redundant. */
272
- export const receiptKey = (b) => b.kind === "tool_result" && b.sourceIndex !== undefined && b.originalReceipt
273
- ? JSON.stringify([b.originalReceipt.producer.sourceIndex, b.originalReceipt.producer.toolCallId,
274
- b.originalReceipt.producer.toolName, b.sourceIndex, b.originalReceipt.isError]) : undefined;
275
- /** The receipt that would make this call redundant, when the call and its single receipt are fully identified. */
276
- export const coveredCallKey = (b) => {
277
- const receipt = b.kind === "tool_call" ? b.receipt : undefined;
278
- return b.kind === "tool_call" && smallOriginalArguments(b.args) && b.sourceIndex !== undefined && receipt &&
279
- (receipt.status === "error" || receipt.status === "non-error") && receipt.sourceIndices.length === 1
280
- ? JSON.stringify([b.sourceIndex, b.callId, b.name, receipt.sourceIndices[0], receipt.status === "error"]) : undefined;
281
- };
282
- /** The records of one block alone: before identical calls merge and before source times are attached. */
283
- export const blockRecords = (b, view, cache) => {
284
- const records = [];
285
- const identity = b.sourceIndex !== undefined ? { sourceIndex: b.sourceIndex } : {};
286
- const excerpts = (block, role, head, tail = 0) => {
287
- // The adapter already renders these complete sources in its mandatory base.
288
- // Keep them visible to structural grouping without charging them twice.
289
- if (block.sourceIndex !== undefined && view.mandatory?.has(block.sourceIndex))
290
- return [];
291
- if (view.full)
292
- return [{ kind: "excerpt", role, ...identity, text: block.originalText ?? block.text, ...(block.kind === "notice" && block.sourceEntryId !== undefined ? { sourceEntryId: block.sourceEntryId } : {}) }];
293
- const key = `${head}:${tail}:${view.bounded}`;
294
- const saved = cache?.get(block)?.get(key);
295
- if (saved)
296
- return saved;
297
- const result = excerptRecords(block.text, role, block.sourceIndex, head, tail, view.bounded).map(record => block.kind === "notice" && block.sourceEntryId !== undefined ? { ...record, sourceEntryId: block.sourceEntryId } : record);
298
- if (cache) {
299
- let variants = cache.get(block);
300
- if (!variants) {
301
- variants = new Map();
302
- cache.set(block, variants);
303
- }
304
- variants.set(key, result);
305
- }
306
- return result;
307
- };
308
- if (b.kind === "user" || b.kind === "notice" || b.kind === "bash") {
309
- if (b.kind === "bash") {
310
- const stored = view.full ? "\nstoredShell=" + JSON.stringify({ historical: true,
311
- output: b.output, cancelled: b.cancelled ?? "unknown", truncated: b.truncated ?? "unknown" }) : "";
312
- records.push({ kind: "bash", role: "bashExecution", ...identity, text: bashRecord(b.command, b.exitCode) + stored });
313
- }
314
- else if (!isNoiseUser(b.text)) {
315
- records.push(...excerpts(b, b.kind === "user" ? "user" : "custom", TRUNCATE_USER));
316
- }
317
- }
318
- else if (b.kind === "assistant") {
319
- if (b.text)
320
- records.push(...excerpts(b, "assistant", view.closing ? SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS : ASSISTANT_HEAD_WORDS, view.closing ? SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS : ASSISTANT_TAIL_WORDS));
321
- }
322
- else if (b.kind === "tool_call") {
323
- if (!b.name.trim() || view.covered)
324
- return records;
325
- const receipt = b.receipt;
326
- const relation = receipt ? `; receipt=${receipt.status}` +
327
- (receipt.sourceIndices.length ? ` source=${receipt.sourceIndices.map(n => `#${n}`).join(",")}` : "") : "";
328
- const suffix = (b.callId ? ` call=${excerpt(b.callId, 160)}` : "") + relation;
329
- const text = toolOneLiner(b.name, b.args) + suffix;
330
- const sourceIndices = b.sourceIndex === undefined ? undefined :
331
- [...new Set([b.sourceIndex, ...(receipt?.dependencyIndices ?? [])])].sort((a, b) => a - b);
332
- const baseline = { kind: "tool_call", role: "assistant", ...identity,
333
- ...(sourceIndices && sourceIndices.length > 1 ? { sourceIndices } : {}), text };
334
- records.push(!smallOriginalArguments(b.args) && smallOriginalArguments(b.args, Number.MAX_SAFE_INTEGER)
335
- ? literalArguments(baseline, b.args, `* ${b.name}${suffix}`) : baseline);
336
- }
337
- if (b.kind === "tool_result" && b.originalReceipt && b.sourceIndex !== undefined) {
338
- const r = b.originalReceipt;
339
- // One historical quote carries its own addressed producer, without a
340
- // redundant overview for the same original event. Never create native tool messages.
341
- records.push({ kind: "tool_receipt", role: "toolResult", sourceIndex: b.sourceIndex,
342
- sourceIndices: [r.producer.sourceIndex, b.sourceIndex], historical: true,
343
- isError: r.isError, text: r.text, producer: r.producer });
344
- }
345
- return records;
346
- };
347
- /** Derived tool summaries can share wording; provenance remains explicit. */
348
- const payloads = new WeakMap();
349
- const callPayload = (r) => {
350
- let payload = payloads.get(r);
351
- if (payload === undefined)
352
- payloads.set(r, payload = JSON.stringify([r.text, r.arguments, r.argumentsJSONPrefix, r.completeArgumentMembers]));
353
- return payload;
354
- };
355
- /** Whether `record` is the same call as the `previous` record, shown a second time. */
356
- export const repeatsCall = (previous, record) => record.kind === "tool_call" && record.sourceIndex !== undefined && previous?.kind === "tool_call" &&
357
- previous.sourceIndex !== undefined && callPayload(previous) === callPayload(record);
358
- /** The earlier record of a repeated call, now also locating the later one. */
359
- export const absorbCall = (previous, record) => ({ ...previous, sourceIndices: [...new Set([...(previous.sourceIndices ?? [previous.sourceIndex]), ...(record.sourceIndices ?? [record.sourceIndex])])].sort((a, b) => a - b) });
360
- /** A source time for each locator the record names; only briefs of times-carrying blocks have them. */
361
- export const noticeTimes = (blocks) => new Map(blocks.flatMap(b => b.kind === "notice" && b.sourceEntryId && b.sourceTime ? [[b.sourceEntryId, b.sourceTime]] : []));
362
- export const decorateRecord = (record, times, entryTimes) => {
363
- if (!times)
170
+ /** A source time for each locator a user record names; only briefs of times-carrying blocks have them. */
171
+ export const decorateRecord = (record, times) => {
172
+ // Times mark the user's turns only: on every record they cost a fifth to a third of the summary's tokens.
173
+ if (!times || record.role !== "user")
364
174
  return record;
365
- if (record.sourceEntryId !== undefined)
366
- return { ...record, sourceTime: entryTimes.get(record.sourceEntryId) ?? { basis: "unknown" } };
367
175
  const indices = record.sourceIndices ?? (record.sourceIndex === undefined ? [] : [record.sourceIndex]);
368
176
  return indices.length ? { ...record, sourceTimes: indices.map(sourceIndex => ({ sourceIndex, ...(times[sourceIndex] ?? { basis: "unknown" }) })) } : record;
369
177
  };
370
- export const buildBriefRecords = (blocks, cache) => {
371
- const records = [];
372
- const coveredCalls = new Set(blocks.flatMap(b => receiptKey(b) ?? []));
373
- const plan = plans.get(blocks);
374
- for (let i = 0; i < blocks.length; i++) {
375
- const b = blocks[i];
376
- const key = coveredCallKey(b);
377
- const view = { full: Boolean(plan?.full.has(b)), closing: plan ? plan.closing.has(b) : isSegmentClosingAssistant(blocks, i),
378
- mandatory: plan?.mandatorySources, bounded: Boolean(plan), covered: key !== undefined && coveredCalls.has(key) };
379
- for (const record of blockRecords(b, view, cache)) {
380
- const previous = records.at(-1);
381
- if (repeatsCall(previous, record))
382
- records[records.length - 1] = absorbCall(previous, record);
383
- else
384
- records.push(record);
385
- }
386
- }
387
- const times = blocks.find(b => b.sourceTimes)?.sourceTimes;
388
- const entryTimes = noticeTimes(blocks);
389
- return [...records, ...(plan?.omissions ?? [])].map(record => decorateRecord(record, times, entryTimes));
390
- };
391
- export const compileBrief = (blocks, cache) => serializeRecords(buildBriefRecords(blocks, cache));
@@ -1,6 +1,7 @@
1
1
  import { isSourceExport, isLookupTool, isRecallTool } from "./recall-operation.js";
2
+ import { isDialogue } from "./brief.js";
2
3
  /** Suppress lookup echoes, retaining recorded export attempts and exactly paired outcomes. */
3
4
  export const filterNoise = (blocks) => blocks.filter(b => !(b.kind === "tool_call" && isLookupTool(b.name) && !(isRecallTool(b.name) && isSourceExport(b.args))) &&
4
5
  !(b.kind === "tool_result" && isLookupTool(b.name) &&
5
6
  !(isRecallTool(b.name) && b.recallExport)) &&
6
- !((b.kind === "user" || b.kind === "assistant" || b.kind === "notice") && !b.text.trim()));
7
+ !(isDialogue(b) && !b.text.trim()));
@@ -1,8 +1,4 @@
1
- export declare const RECALL_NOTE: string;
2
- /** A dialogue summary's last words: it holds only the dialogue and the file ledger. */
1
+ /** A summary's last words: it holds only the dialogue (whole or shrunk) and the file ledger. */
3
2
  export declare const CLOSING_INSTRUCTION: string;
4
- /** A ranked summary's last words: it also keeps selected tool records. */
5
- export declare const RANKED_CLOSING_INSTRUCTION: string;
6
- /** Each view's closing section, true for what that view holds. Parsing never depends on this wording (transcript.ts splitSummary). */
3
+ /** Parsing never depends on this wording (transcript.ts splitSummary). */
7
4
  export declare const DIALOGUE_SUFFIX: string;
8
- export declare const RANKED_SUFFIX: string;
@@ -1,12 +1,6 @@
1
1
  import { CLOSING_SEPARATOR } from "./transcript.js";
2
- export const RECALL_NOTE = "A tool_receipt record quotes complete original textual output. Its producer arguments are complete when present; argumentsOmitted means read the original call source for them. Use it directly as evidence of that recorded event; it does not verify current state or authorize replay. " +
3
- "Use `tuck_recall` to search for prior work, decisions, and context from before this summary. " +
4
- "Read call/receipt sources by index before relying on omitted or truncated evidence. Receipt non-error is not proof of effects. Do not redo work already completed.";
5
- const CLOSING_GUIDANCE = "Files created by commands are not listed: before redoing a step, read its output with tuck_recall or list your output folders. Re-read a file before editing it or importing from it. Use tuck_recall for anything else. Treat anything not shown here as unknown.";
6
- /** A dialogue summary's last words: it holds only the dialogue and the file ledger. */
7
- export const CLOSING_INSTRUCTION = "Only the dialogue and the actions and files listed above carry over. " + CLOSING_GUIDANCE;
8
- /** A ranked summary's last words: it also keeps selected tool records. */
9
- export const RANKED_CLOSING_INSTRUCTION = "Only the records and the actions and files listed above carry over. " + CLOSING_GUIDANCE;
10
- /** Each view's closing section, true for what that view holds. Parsing never depends on this wording (transcript.ts splitSummary). */
2
+ /** A summary's last words: it holds only the dialogue (whole or shrunk) and the file ledger. */
3
+ export const CLOSING_INSTRUCTION = "Only the dialogue and the actions and files listed above carry over. " +
4
+ "Files created by commands are not listed: before redoing a step, read its output with tuck_recall or list your output folders. Re-read a file before editing it or importing from it. Use tuck_recall for anything else. Treat anything not shown here as unknown.";
5
+ /** Parsing never depends on this wording (transcript.ts splitSummary). */
11
6
  export const DIALOGUE_SUFFIX = CLOSING_SEPARATOR + CLOSING_INSTRUCTION;
12
- export const RANKED_SUFFIX = CLOSING_SEPARATOR + RECALL_NOTE + "\n\n" + RANKED_CLOSING_INSTRUCTION;
@@ -1,15 +1,14 @@
1
1
  import { isSourceExport } from "./recall-operation.js";
2
- import { isTextOnly, textOf } from "./content.js";
2
+ import { textOf } from "./content.js";
3
3
  import { sanitize } from "./sanitize.js";
4
- import { MAX_RECEIPT_CHARACTERS, smallOriginalArguments } from "./transcript.js";
5
- const normalizeOne = (msg, msgIndex, receipts, originals) => {
4
+ const normalizeOne = (msg, msgIndex) => {
6
5
  if (msg.role === "custom")
7
- return [{ kind: "notice", text: sanitize(textOf(msg.content)), originalText: textOf(msg.content), sourceIndex: msgIndex }];
6
+ return [{ kind: "notice", text: sanitize(textOf(msg.content)), originalText: textOf(msg.content), sourceIndex: msgIndex, startsSource: true }];
8
7
  if (msg.role === "user") {
9
8
  const blocks = [];
10
9
  const text = sanitize(textOf(msg.content));
11
10
  if (text)
12
- blocks.push({ kind: "user", text, originalText: textOf(msg.content), sourceIndex: msgIndex });
11
+ blocks.push({ kind: "user", text, originalText: textOf(msg.content), sourceIndex: msgIndex, startsSource: true });
13
12
  if (msg.content && typeof msg.content !== "string") {
14
13
  for (const part of msg.content) {
15
14
  if (part.type === "image") {
@@ -19,40 +18,30 @@ const normalizeOne = (msg, msgIndex, receipts, originals) => {
19
18
  }
20
19
  return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
21
20
  }
22
- if (msg.role === "bashExecution") {
23
- const cmd = msg.command ?? "";
24
- const out = msg.output ?? "";
25
- const exit = msg.exitCode;
26
- return [{ kind: "bash", command: cmd, output: out, exitCode: exit, cancelled: msg.cancelled, truncated: msg.truncated, sourceIndex: msgIndex }];
27
- }
28
- if (msg.role === "toolResult") {
29
- const original = originals.get(msg.toolCallId);
30
- return [{
31
- kind: "tool_result",
32
- name: msg.toolName,
33
- text: sanitize(textOf(msg.content)),
34
- sourceIndex: msgIndex,
35
- originalReceipt: (original?.text.length ?? Infinity) <= MAX_RECEIPT_CHARACTERS ? original : undefined,
36
- }];
37
- }
21
+ if (msg.role === "bashExecution")
22
+ return [{ kind: "bash", sourceIndex: msgIndex }];
23
+ if (msg.role === "toolResult")
24
+ return [{ kind: "tool_result", name: msg.toolName, sourceIndex: msgIndex }];
38
25
  if (msg.role === "assistant") {
39
26
  if (!msg.content)
40
27
  return [];
41
28
  if (typeof msg.content === "string") {
42
- return [{ kind: "assistant", text: sanitize(msg.content), originalText: msg.content, sourceIndex: msgIndex }];
29
+ return [{ kind: "assistant", text: sanitize(msg.content), originalText: msg.content, sourceIndex: msgIndex, startsSource: true }];
43
30
  }
44
31
  const blocks = [];
32
+ // The readable text of a message is its text and tool-call parts in order: only the first part starts it.
33
+ let readable = false;
45
34
  for (const part of msg.content) {
46
35
  if (part.type === "text") {
47
- blocks.push({ kind: "assistant", text: sanitize(part.text), originalText: part.text, sourceIndex: msgIndex });
36
+ blocks.push({ kind: "assistant", text: sanitize(part.text), originalText: part.text, sourceIndex: msgIndex, ...(readable ? {} : { startsSource: true }) });
37
+ readable = true;
48
38
  }
49
39
  else if (part.type === "toolCall") {
40
+ readable = true;
50
41
  blocks.push({
51
42
  kind: "tool_call",
52
43
  name: part.name,
53
44
  args: part.arguments,
54
- callId: part.id,
55
- receipt: receipts.get(part.id),
56
45
  sourceIndex: msgIndex,
57
46
  });
58
47
  }
@@ -101,34 +90,17 @@ export const normalize = (messages, sourceIndices, receiptContext, sourceEntryId
101
90
  order, index: context.sourceIndices[order], isError: msg.isError, content: msg.content });
102
91
  }
103
92
  });
104
- const links = new Map();
105
- const originals = new Map();
106
- for (const [id, { calls, receipts }] of pairs) {
107
- const call = calls[0], receipt = receipts[0];
108
- const status = calls.length !== 1 || receipts.length > 1 ? "ambiguous" : !receipt ? "missing" :
109
- receipt.name !== call.name || receipt.order <= call.order ? "ambiguous" : receipt.isError ? "error" : "non-error";
110
- links.set(id, { status, sourceIndices: receipts.flatMap(r => r.index === undefined ? [] : [r.index]),
111
- dependencyIndices: [...calls, ...receipts].flatMap(source => source.index === undefined ? [] : [source.index]) });
112
- if ((status === "error" || status === "non-error") && call.index !== undefined && receipt.index !== undefined &&
113
- Number.isSafeInteger(call.index) && call.index >= 0 && Number.isSafeInteger(receipt.index) && receipt.index > call.index &&
114
- call.name.trim() && call.name.length <= 160 && id.length <= 160 && smallOriginalArguments(call.args, Number.MAX_SAFE_INTEGER) &&
115
- isTextOnly(receipt.content)) {
116
- const text = textOf(receipt.content);
117
- if (text)
118
- originals.set(id, {
119
- text, isError: receipt.isError,
120
- producer: { sourceIndex: call.index, toolName: call.name, toolCallId: id,
121
- ...(smallOriginalArguments(call.args) ? { arguments: call.args } : { argumentsOmitted: true }) },
122
- });
123
- }
124
- }
125
- return messages.flatMap((msg, i) => normalizeOne(msg, sourceIndices ? sourceIndices[i] : i, links, originals).map(block => {
126
- if (block.kind === "tool_result" && msg.role === "toolResult" &&
127
- ["error", "non-error"].includes(links.get(msg.toolCallId)?.status ?? "") &&
93
+ // A call paired with exactly one later receipt of the same tool has a recorded outcome. An export through recall keeps its outcome block.
94
+ const paired = (id) => {
95
+ const pair = pairs.get(id), call = pair?.calls[0], receipt = pair?.receipts[0];
96
+ return Boolean(pair && call && receipt && pair.calls.length === 1 && pair.receipts.length === 1 && receipt.name === call.name && receipt.order > call.order);
97
+ };
98
+ return messages.flatMap((msg, i) => normalizeOne(msg, sourceIndices ? sourceIndices[i] : i).map(block => {
99
+ if (block.kind === "tool_result" && msg.role === "toolResult" && paired(msg.toolCallId) &&
128
100
  isSourceExport(pairs.get(msg.toolCallId)?.calls[0]?.args))
129
101
  block = { ...block, recallExport: true };
130
102
  if (sourceTimes)
131
- block = { ...block, sourceTime: sourceTimes[i], sourceTimes: timeByIndex };
103
+ block = { ...block, sourceTimes: timeByIndex };
132
104
  return block.kind === "notice" && sourceEntryIds?.[i] !== undefined ? { ...block, sourceEntryId: sourceEntryIds[i] } : block;
133
105
  }));
134
106
  };
@@ -0,0 +1,33 @@
1
+ import type { NormalizedBlock } from "../types.js";
2
+ export interface DialogueInput {
3
+ /** Normalized, noise-filtered blocks of the pre-cut history in chronological order: dialogue and tool blocks alike. */
4
+ blocks: NormalizedBlock[];
5
+ /** Sources the adapter quotes in its own base: they render as nothing here and are not counted as omitted. */
6
+ mandatorySourceIndices?: ReadonlySet<number>;
7
+ /** The source index of each original message and, when known, its entry id, for the original order of the records. */
8
+ sourceIndices: readonly (number | undefined)[];
9
+ sourceEntryIds?: readonly string[];
10
+ }
11
+ /** The configurations the ladder tries, in order; "none" is no configuration that fits. */
12
+ export type Step = "whole" | `A${1 | 2 | 3}` | "B" | `C${1 | 2 | 3}` | "D" | "E1" | "E2" | "none";
13
+ export interface Shrunk {
14
+ /** The summary text, or "" when nothing fits. */
15
+ text: string;
16
+ step: Step;
17
+ /** The length the ladder computed from per-unit lengths before it rendered `text`. */
18
+ size: number;
19
+ }
20
+ /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted. "" when it renders nothing. */
21
+ export declare const wholeDialogue: (input: DialogueInput) => string;
22
+ /**
23
+ * The same dialogue shrunk until its summary text is at most `limit` characters, oldest first. The first configuration that fits wins:
24
+ * 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);
25
+ * B. the newest assistant units that fit a third of the limit whole, the older ones at tier 3 with the oldest d dropped;
26
+ * 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;
27
+ * D. every user unit at tier 3, the oldest dropped;
28
+ * E. the oldest notices dropped, then the oldest of the remaining assistant units.
29
+ * 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
30
+ * 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:
31
+ * a configuration whose text is over the limit is passed over.
32
+ */
33
+ export declare const shrinkDialogue: (input: DialogueInput, limit: number) => Shrunk;