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.
- package/CHANGELOG.md +65 -3
- package/README.md +138 -57
- 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 +7 -0
- package/dist/src/compaction.js +32 -16
- 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 +41 -255
- package/dist/src/tuck/core/filter-noise.js +2 -1
- 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 +22 -50
- package/dist/src/tuck/core/shrink.d.ts +33 -0
- package/dist/src/tuck/core/shrink.js +218 -0
- package/dist/src/tuck/core/summarize.d.ts +15 -7
- package/dist/src/tuck/core/summarize.js +17 -22
- package/dist/src/tuck/core/transcript.d.ts +24 -13
- package/dist/src/tuck/core/transcript.js +251 -83
- package/dist/src/tuck/types.d.ts +4 -21
- 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 +33 -14
- package/src/diagnostics.ts +1 -1
- package/src/omissions.ts +8 -8
- package/src/tuck/core/brief.ts +48 -265
- package/src/tuck/core/filter-noise.ts +2 -1
- package/src/tuck/core/format.ts +4 -11
- package/src/tuck/core/normalize.ts +21 -50
- package/src/tuck/core/shrink.ts +217 -0
- package/src/tuck/core/summarize.ts +26 -24
- package/src/tuck/core/transcript.ts +238 -82
- package/src/tuck/types.ts +9 -20
- 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
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 && (
|
|
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 && (
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
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
|
-
/**
|
|
272
|
-
export const
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
7
|
+
!(isDialogue(b) && !b.text.trim()));
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
"
|
|
5
|
-
|
|
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 {
|
|
2
|
+
import { textOf } from "./content.js";
|
|
3
3
|
import { sanitize } from "./sanitize.js";
|
|
4
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
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;
|