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