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,4 +1,4 @@
|
|
|
1
|
-
import { validSourceTime } from "./source-time";
|
|
1
|
+
import { validSourceTime, type SourceTime } from "./source-time";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import type { HistoricalReceipt } from "../types";
|
|
4
4
|
|
|
@@ -27,6 +27,7 @@ interface RecordFields {
|
|
|
27
27
|
count?: number;
|
|
28
28
|
title?: string;
|
|
29
29
|
}
|
|
30
|
+
/** The tool_call, tool_receipt, tool_excerpt and bash kinds are v1 only: summaries saved by earlier builds hold them, and this build no longer writes them. */
|
|
30
31
|
export type TranscriptRecord = RecordFields & (
|
|
31
32
|
{ kind: "excerpt"; role: "user" | "assistant" | "custom"; text: string } |
|
|
32
33
|
{ kind: "tool_call"; role: "assistant"; text: string; arguments?: Record<string, unknown>;
|
|
@@ -38,96 +39,251 @@ export type TranscriptRecord = RecordFields & (
|
|
|
38
39
|
{ kind: "section"; title: string; text: string }
|
|
39
40
|
);
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
export const
|
|
43
|
-
/**
|
|
44
|
-
export const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
/** The format this build writes. */
|
|
43
|
+
export const TRANSCRIPT_HEADER = "TUCK-TRANSCRIPT/2";
|
|
44
|
+
/** The format of 1.0.x summaries: one JSON object per line. Read, never written. */
|
|
45
|
+
export const TRANSCRIPT_HEADER_V1 = "TUCK-TRANSCRIPT/1";
|
|
46
|
+
/** The header of summaries saved before the rename to pi-tuck: the v1 format. */
|
|
47
|
+
export const LEGACY_TRANSCRIPT_HEADER = "VCC-TRANSCRIPT/1";
|
|
48
|
+
export type TranscriptVersion = 1 | 2;
|
|
49
|
+
/** The header a summary of `version` is written with; a legacy one is rewritten as v1. */
|
|
50
|
+
export const transcriptHeader = (version: TranscriptVersion): string => version === 2 ? TRANSCRIPT_HEADER : TRANSCRIPT_HEADER_V1;
|
|
51
|
+
|
|
50
52
|
/** Starts a summary's closing section, after the record lines. */
|
|
51
53
|
export const CLOSING_SEPARATOR = "\n\n---\n\n";
|
|
52
|
-
/** A summary in
|
|
53
|
-
* closing section. Any closing wording, from this build or an earlier one, is accepted; the records are returned byte
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** A summary in either format, split by structure: each record is one line, and no record line is empty, so the first blank line after the
|
|
55
|
+
* header starts the closing section. Any closing wording, from this build or an earlier one, is accepted; the records are returned byte
|
|
56
|
+
* for byte, with the format their header names. */
|
|
57
|
+
export const splitSummary = (summary: string): { body: string; closing: string; version: TranscriptVersion } | undefined => {
|
|
58
|
+
const header = [TRANSCRIPT_HEADER, TRANSCRIPT_HEADER_V1, LEGACY_TRANSCRIPT_HEADER].find(h => summary.startsWith(h + "\n"));
|
|
56
59
|
if (!header) return undefined;
|
|
57
60
|
const at = summary.indexOf(CLOSING_SEPARATOR, header.length);
|
|
58
61
|
if (at < 0) return undefined;
|
|
59
62
|
// A summary saved before the rename keeps its records byte for byte (their lengths are checked); only its closing is pointed at the
|
|
60
63
|
// tools the model has now.
|
|
61
|
-
const closing = header ===
|
|
62
|
-
return { body: summary.slice(header.length + 1, at), closing };
|
|
64
|
+
const closing = header === LEGACY_TRANSCRIPT_HEADER ? summary.slice(at).replaceAll("vcc_recall", "tuck_recall").replaceAll("vcc_search", "tuck_search") : summary.slice(at);
|
|
65
|
+
return { body: summary.slice(header.length + 1, at), closing, version: header === TRANSCRIPT_HEADER ? 2 : 1 };
|
|
63
66
|
};
|
|
64
|
-
/** The header of summaries saved before the rename to pi-tuck. */
|
|
65
|
-
export const LEGACY_TRANSCRIPT_HEADER = "VCC-TRANSCRIPT/1";
|
|
66
67
|
const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall", "vcc_recall");
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
/*
|
|
70
|
+
* TUCK-TRANSCRIPT/2: the header line, one line per record, then the closing section.
|
|
71
|
+
*
|
|
72
|
+
* line = head ":" [ " " text ] no " " and no text when the text is empty
|
|
73
|
+
* record = [ locator " " ] role [ " " id ] [ " " time ] [ " " span ] ":" ... excerpt
|
|
74
|
+
* | [ locator " " ] [ role [ " " id ] " " ] "omitted" [ " " time ] [ " " span ] ":" ... omission of part of one source,
|
|
75
|
+
* or, with no role, of the tool history
|
|
76
|
+
* | role " omitted " count [ " " locator-time { " " locator-time } ] ":" ... omission of unselected text blocks
|
|
77
|
+
* | [ locator " " ] title ":" ... section (file ledger, action trail)
|
|
78
|
+
* locator = "#" index the source's session-wide index, as tuck_recall {"index"} takes it
|
|
79
|
+
* locator-time = locator [ "@" time-value ] every locator of a record has a time, or none does
|
|
80
|
+
* role = "user" | "assistant" | "custom"
|
|
81
|
+
* id = a JSON string the native entry id of a custom notice (role "custom" only)
|
|
82
|
+
* time = "@" time-value the recorded time of the locator that precedes the role (a record that has one)
|
|
83
|
+
* time-value = UTC | "journal:" UTC | "unknown" UTC is the message time, "journal:" UTC the session file's time of the entry
|
|
84
|
+
* span = "[chars " start "-" end " of " total "]" the characters of the source text block the text covers or the omission leaves out
|
|
85
|
+
* count = index how many text blocks the omission counts
|
|
86
|
+
* title = capital letter, then letters, digits, "_" and "-"
|
|
87
|
+
* index = "0" | nonzero digit, then digits a safe integer, no sign, no leading zero
|
|
88
|
+
*
|
|
89
|
+
* The text of an excerpt or section, or the reason of an omission, is the rest of the line: "\" is written "\\", a line feed "\n" and a
|
|
90
|
+
* carriage return "\r"; every other character is written as it is.
|
|
91
|
+
*
|
|
92
|
+
* The head is made of the tokens above only, and the text is the rest of the line after the first ":" that follows them, so no text can be
|
|
93
|
+
* mistaken for structure: a text that looks like a record is the text of one line. A role is lowercase and a title is capitalized, so the
|
|
94
|
+
* first word of a head tells which record it is. Lines that follow no production are rejected, as are fields the record shape does not
|
|
95
|
+
* have (a time without a locator, an id on a role other than "custom") and every check parseRecords makes of a v1 record.
|
|
96
|
+
*
|
|
97
|
+
* Examples:
|
|
98
|
+
* #12 user @2026-10-30T14:00:00Z: Please fix the parser.
|
|
99
|
+
* #12 assistant [chars 0-523 of 2004]: I will start with the tokenizer.
|
|
100
|
+
* #12 assistant omitted [chars 523-1900 of 2004]: tuck_recall {"index":12,"offset":523}
|
|
101
|
+
* #7 custom "a1b2c3d4": A note.
|
|
102
|
+
* assistant omitted 40 #3 #5 #9: Completely unselected historical text blocks; ...
|
|
103
|
+
* user omitted 3 #1@2026-10-30T14:00:00Z #4@unknown #8@journal:2026-10-30T14:00:05Z: Completely unselected ...
|
|
104
|
+
* omitted: Historical tool actions and observations omitted; read original sources with tuck_recall
|
|
105
|
+
* #4 Files: - read src/a.ts (tuck_recall {"index":4})
|
|
106
|
+
*/
|
|
107
|
+
const escapeText = (text: string): string => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
|
|
108
|
+
/** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
|
|
109
|
+
export const escapedLength = (text: string): number => {
|
|
110
|
+
let length = text.length;
|
|
111
|
+
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if (c === 92 || c === 10 || c === 13) length++; }
|
|
112
|
+
return length;
|
|
113
|
+
};
|
|
114
|
+
const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
|
|
115
|
+
const refuse = (record: TranscriptRecord): never => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
|
|
116
|
+
|
|
117
|
+
/** One record as a line of TUCK-TRANSCRIPT/2 (without its newline). The records this build emits only: a record whose fields the grammar
|
|
118
|
+
* cannot carry exactly is refused, never written lossily. */
|
|
119
|
+
export const serializeRecord = (record: TranscriptRecord): string => {
|
|
120
|
+
const fields = record as unknown as Record<string, unknown>;
|
|
121
|
+
const present = Object.keys(fields).filter(key => fields[key] !== undefined);
|
|
122
|
+
const only = (...allowed: string[]) => { if (!present.every(key => allowed.includes(key))) refuse(record); };
|
|
123
|
+
const index = (n: unknown): string => typeof n === "number" && Number.isSafeInteger(n) && n >= 0 ? String(n) : refuse(record);
|
|
124
|
+
const time = (t: SourceTime): string => !validSourceTime(t) ? refuse(record) : t.basis === "unknown" ? "unknown" : t.basis === "journal" ? `journal:${t.utc}` : t.utc;
|
|
125
|
+
/** The record's times, one for each locator in order; none when it has none. */
|
|
126
|
+
const times = (locators: readonly number[]): string[] | undefined => {
|
|
127
|
+
const { sourceTimes } = record;
|
|
128
|
+
if (!sourceTimes) return undefined;
|
|
129
|
+
if (sourceTimes.length !== locators.length) refuse(record);
|
|
130
|
+
return sourceTimes.map(({ sourceIndex, ...value }, i) => sourceIndex === locators[i] ? time(value as SourceTime) : refuse(record));
|
|
131
|
+
};
|
|
132
|
+
const line = (head: string, text: string | undefined): string => head + ":" + (text ? " " + escapeText(text) : "");
|
|
133
|
+
if (record.kind === "section") {
|
|
134
|
+
only("kind", "title", "text", "sourceIndex");
|
|
135
|
+
if (typeof record.title !== "string" || !TITLE.test(record.title) || typeof record.text !== "string") refuse(record);
|
|
136
|
+
return line((record.sourceIndex === undefined ? "" : `#${index(record.sourceIndex)} `) + record.title, record.text);
|
|
137
|
+
}
|
|
138
|
+
if (record.kind !== "excerpt" && record.kind !== "omission") return refuse(record);
|
|
139
|
+
const { role, sourceIndex, sourceEntryId, span } = record, omission = record.kind === "omission";
|
|
140
|
+
if (role !== undefined && !["user", "assistant", "custom"].includes(role) || role === undefined && (!omission || sourceEntryId !== undefined) ||
|
|
141
|
+
sourceEntryId !== undefined && (role !== "custom" || typeof sourceEntryId !== "string")) refuse(record);
|
|
142
|
+
const who = (sourceEntryId === undefined ? "" : ` ${JSON.stringify(sourceEntryId)}`);
|
|
143
|
+
if (omission && record.count !== undefined) {
|
|
144
|
+
only("kind", "role", "count", "sourceIndices", "reason", "sourceTimes");
|
|
145
|
+
const locators = record.sourceIndices ?? [], stamped = times(locators);
|
|
146
|
+
if (role === undefined || typeof record.reason !== "string") refuse(record);
|
|
147
|
+
return line(`${role} omitted ${index(record.count)}` + locators.map((n, i) => ` #${index(n)}${stamped ? "@" + stamped[i] : ""}`).join(""), record.reason);
|
|
148
|
+
}
|
|
149
|
+
only("kind", "role", "sourceIndex", omission ? "reason" : "text", "span", "sourceEntryId", "sourceTimes");
|
|
150
|
+
const text = omission ? record.reason : record.text;
|
|
151
|
+
if (typeof text !== "string") refuse(record);
|
|
152
|
+
const stamped = times(sourceIndex === undefined ? [] : [sourceIndex]);
|
|
153
|
+
const where = (sourceIndex === undefined ? "" : `#${index(sourceIndex)} `) + (role === undefined ? "" : role + who + (omission ? " " : ""));
|
|
154
|
+
return line(where + (omission ? "omitted" : "") + (stamped ? ` @${stamped[0]}` : "") +
|
|
155
|
+
(span ? ` [chars ${index(span.start)}-${index(span.end)} of ${index(span.totalCharacters)}]` : ""), text);
|
|
156
|
+
};
|
|
157
|
+
export const serializeRecords = (records: TranscriptRecord[]): string => records.map(serializeRecord).join("\n");
|
|
158
|
+
export const serializeTranscript = (records: TranscriptRecord[]): string => records.length ?
|
|
159
|
+
`${TRANSCRIPT_HEADER}\n${serializeRecords(records)}` : "";
|
|
160
|
+
|
|
161
|
+
const invalid = (): never => { throw new Error("Invalid pi-tuck transcript record"); };
|
|
162
|
+
const unescapeText = (text: string): string => text.replace(/\\([\\nr])?/g, (_, c?: string) => c === undefined ? invalid() : c === "n" ? "\n" : c === "r" ? "\r" : "\\");
|
|
163
|
+
// Sticky patterns: each is matched at the position the line has been read up to.
|
|
164
|
+
const INDEX = "(0|[1-9]\\d*)";
|
|
165
|
+
const TIME = "(unknown|(?:journal:)?\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z)";
|
|
166
|
+
const LOCATOR = new RegExp(`#${INDEX} `, "y"), ROLE = /user|assistant|custom/y, OMITTED = /omitted/y;
|
|
167
|
+
const ID = / ("(?:[^"\\\u0000-\u001f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")/y, SPACE_OMITTED = / omitted/y, COUNT = new RegExp(` ${INDEX}`, "y");
|
|
168
|
+
const LOCATOR_TIME = new RegExp(` #${INDEX}(?:@${TIME})?`, "y"), TIME_TOKEN = new RegExp(` @${TIME}`, "y");
|
|
169
|
+
const SPAN = new RegExp(` \\[chars ${INDEX}-${INDEX} of ${INDEX}\\]`, "y"), TITLE_TOKEN = /[A-Z][A-Za-z0-9_-]*/y;
|
|
170
|
+
|
|
171
|
+
/** One line of TUCK-TRANSCRIPT/2, as the record the build that wrote it held (its fields in the order that build emits them). */
|
|
172
|
+
const parseLine = (line: string): unknown => {
|
|
173
|
+
let at = 0;
|
|
174
|
+
const take = (pattern: RegExp): RegExpExecArray | undefined => {
|
|
175
|
+
pattern.lastIndex = at;
|
|
176
|
+
const match = pattern.exec(line);
|
|
177
|
+
if (match) at = pattern.lastIndex;
|
|
178
|
+
return match ?? undefined;
|
|
179
|
+
};
|
|
180
|
+
const integer = (digits: string): number => Number.isSafeInteger(Number(digits)) ? Number(digits) : invalid();
|
|
181
|
+
const timeOf = (value: string): SourceTime => value === "unknown" ? { basis: "unknown" } :
|
|
182
|
+
value.startsWith("journal:") ? { basis: "journal", utc: value.slice(8) } : { basis: "message", utc: value };
|
|
183
|
+
const located = take(LOCATOR), sourceIndex = located ? integer(located[1]!) : undefined;
|
|
184
|
+
const title = take(TITLE_TOKEN)?.[0], role = title === undefined ? take(ROLE)?.[0] : undefined;
|
|
185
|
+
const id = role === "custom" ? take(ID)?.[1] : undefined;
|
|
186
|
+
const omitted = title === undefined && take(role === undefined ? OMITTED : SPACE_OMITTED) !== undefined;
|
|
187
|
+
if (title === undefined && role === undefined && !omitted) invalid();
|
|
188
|
+
const common = { ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
|
|
189
|
+
let record: Record<string, unknown>, text: string;
|
|
190
|
+
const end = (): string => {
|
|
191
|
+
if (line[at++] !== ":") return invalid();
|
|
192
|
+
const rest = line.slice(at);
|
|
193
|
+
return rest === "" ? "" : rest[0] === " " && rest.length > 1 && !rest.includes("\r") ? unescapeText(rest.slice(1)) : invalid();
|
|
194
|
+
};
|
|
195
|
+
const count = omitted && role !== undefined && sourceIndex === undefined && id === undefined ? take(COUNT) : undefined;
|
|
196
|
+
if (title !== undefined) {
|
|
197
|
+
text = end();
|
|
198
|
+
record = { kind: "section", title, text, ...common };
|
|
199
|
+
} else if (count) {
|
|
200
|
+
const items: { sourceIndex: number; time?: SourceTime }[] = [];
|
|
201
|
+
for (let item; (item = take(LOCATOR_TIME));) items.push({ sourceIndex: integer(item[1]!), ...(item[2] !== undefined ? { time: timeOf(item[2]) } : {}) });
|
|
202
|
+
const stamped = items.filter(item => item.time).length;
|
|
203
|
+
if (stamped && stamped !== items.length) invalid();
|
|
204
|
+
record = { kind: "omission", role, count: integer(count[1]!), ...(items.length ? { sourceIndices: items.map(item => item.sourceIndex) } : {}), reason: end(),
|
|
205
|
+
...(stamped ? { sourceTimes: items.map(({ sourceIndex: n, time }) => ({ sourceIndex: n, ...time! })) } : {}) };
|
|
206
|
+
} else {
|
|
207
|
+
const time = take(TIME_TOKEN), span = take(SPAN);
|
|
208
|
+
if (time && sourceIndex === undefined) invalid();
|
|
209
|
+
const fields = {
|
|
210
|
+
...(span ? { span: { coordinateSpace: "sanitized-block" as const, start: integer(span[1]!), end: integer(span[2]!), totalCharacters: integer(span[3]!) } } : {}),
|
|
211
|
+
...(id !== undefined ? { sourceEntryId: JSON.parse(id) as string } : {}),
|
|
212
|
+
...(time ? { sourceTimes: [{ sourceIndex: sourceIndex!, ...timeOf(time[1]!) }] } : {}),
|
|
213
|
+
};
|
|
214
|
+
text = end();
|
|
215
|
+
record = omitted ? { kind: "omission", ...(role !== undefined ? { role } : {}), ...common, reason: text, ...fields } :
|
|
216
|
+
{ kind: "excerpt", role, ...common, text, ...fields };
|
|
217
|
+
}
|
|
218
|
+
return record;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** The structure of a record, whichever format it was read from: roles, source times, spans and, for the v1-only kinds, receipts. */
|
|
222
|
+
const validate = (r: unknown): TranscriptRecord => {
|
|
223
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) throw new Error("Invalid pi-tuck transcript record");
|
|
224
|
+
const v = r as TranscriptRecord;
|
|
225
|
+
const roles = ["user", "assistant", "custom", "bashExecution", "toolResult"];
|
|
226
|
+
const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
227
|
+
if (!["excerpt", "tool_call", "tool_receipt", "tool_excerpt", "bash", "omission", "section"].includes(v.kind) ||
|
|
228
|
+
(v.role !== undefined && !roles.includes(v.role)) ||
|
|
229
|
+
(v.sourceTime !== undefined && (v.sourceEntryId === undefined || !validSourceTime(v.sourceTime))) ||
|
|
230
|
+
(v.sourceTimes !== undefined && (!Array.isArray(v.sourceTimes) || !v.sourceTimes.length || v.sourceTimes.some(t => !integer(t.sourceIndex) || !validSourceTime(Object.fromEntries(Object.entries(t).filter(([key]) => key !== "sourceIndex")))))) ||
|
|
231
|
+
(v.sourceIndex !== undefined && !integer(v.sourceIndex)) ||
|
|
232
|
+
(v.sourceEntryId !== undefined && (typeof v.sourceEntryId !== "string" || !v.sourceEntryId || v.sourceEntryId.length > 200 ||
|
|
233
|
+
v.role !== "custom" || !["excerpt", "omission"].includes(v.kind))) ||
|
|
234
|
+
(v.sourceIndices !== undefined && (!Array.isArray(v.sourceIndices) || !v.sourceIndices.every(integer))) ||
|
|
235
|
+
(v.kind !== "omission" && typeof v.text !== "string") ||
|
|
236
|
+
(v.kind === "omission" && typeof v.reason !== "string") ||
|
|
237
|
+
(v.kind === "excerpt" && !["user", "assistant", "custom"].includes(v.role)) ||
|
|
238
|
+
(v.kind === "tool_call" && v.role !== "assistant") ||
|
|
239
|
+
(v.kind === "bash" && v.role !== "bashExecution") ||
|
|
240
|
+
(v.kind === "section" && typeof v.title !== "string") ||
|
|
241
|
+
(v.count !== undefined && !integer(v.count)) ||
|
|
242
|
+
(v.span !== undefined && (v.span.coordinateSpace !== "sanitized-block" || !integer(v.span.start) || !integer(v.span.end) || !integer(v.span.totalCharacters) ||
|
|
243
|
+
v.span.start > v.span.end || v.span.end > v.span.totalCharacters ||
|
|
244
|
+
(v.kind === "excerpt" && v.text!.length !== v.span.end - v.span.start))))
|
|
245
|
+
throw new Error("Invalid pi-tuck transcript record");
|
|
246
|
+
if (v.sourceTimes) {
|
|
247
|
+
const expected = v.sourceIndices ?? (v.sourceIndex === undefined ? [] : [v.sourceIndex]);
|
|
248
|
+
if (v.sourceTimes.length !== expected.length || v.sourceTimes.some((t, i) => t.sourceIndex !== expected[i])) throw new Error("Mismatched source times");
|
|
249
|
+
}
|
|
250
|
+
if (["arguments", "argumentsJSONPrefix", "completeArgumentMembers"].some(key => Object.hasOwn(v, key))) {
|
|
251
|
+
if (v.kind !== "tool_call" ||
|
|
252
|
+
(Object.hasOwn(v, "arguments") && (!smallOriginalArguments(v.arguments, Number.MAX_SAFE_INTEGER) ||
|
|
253
|
+
Object.hasOwn(v, "argumentsJSONPrefix") || Object.hasOwn(v, "completeArgumentMembers"))) ||
|
|
254
|
+
(Object.hasOwn(v, "argumentsJSONPrefix") && (typeof v.argumentsJSONPrefix !== "string" || !v.argumentsJSONPrefix)) ||
|
|
255
|
+
(Object.hasOwn(v, "completeArgumentMembers") && (!Object.hasOwn(v, "argumentsJSONPrefix") ||
|
|
256
|
+
!smallOriginalArguments(v.completeArgumentMembers, Number.MAX_SAFE_INTEGER) || !Object.keys(v.completeArgumentMembers).length)))
|
|
257
|
+
throw new Error("Invalid historical argument fields");
|
|
258
|
+
}
|
|
259
|
+
if (v.kind === "tool_receipt" || v.kind === "tool_excerpt") {
|
|
260
|
+
const p = v.producer;
|
|
261
|
+
const keys = [...(v.sourceTimes ? ["sourceTimes"] : []), "kind", "role", "sourceIndex", "sourceIndices", "historical", "isError", "text", "producer", ...(v.kind === "tool_excerpt" ? ["complete", "notice", "range"] : [])];
|
|
262
|
+
if (Object.keys(v).length !== keys.length || !Object.keys(v).every(k => keys.includes(k)) ||
|
|
263
|
+
v.role !== "toolResult" || v.historical !== true || typeof v.isError !== "boolean" ||
|
|
264
|
+
!v.text || v.text.length > MAX_RECEIPT_CHARACTERS || !integer(v.sourceIndex) ||
|
|
265
|
+
!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).length !== 4 ||
|
|
266
|
+
!Object.keys(p).every(k => ["sourceIndex", "toolName", "toolCallId", "arguments", "argumentsOmitted"].includes(k)) ||
|
|
267
|
+
!integer(p.sourceIndex) || p.sourceIndex >= v.sourceIndex ||
|
|
268
|
+
typeof p.toolName !== "string" || !p.toolName.trim() || p.toolName.length > 160 ||
|
|
269
|
+
typeof p.toolCallId !== "string" || !p.toolCallId || p.toolCallId.length > 160 ||
|
|
270
|
+
!(Object.hasOwn(p, "arguments") && !Object.hasOwn(p, "argumentsOmitted") && smallOriginalArguments(p.arguments) ||
|
|
271
|
+
p.argumentsOmitted === true && !Object.hasOwn(p, "arguments")) ||
|
|
272
|
+
!Array.isArray(v.sourceIndices) || v.sourceIndices.length !== 2 ||
|
|
273
|
+
v.sourceIndices[0] !== p.sourceIndex || v.sourceIndices[1] !== v.sourceIndex)
|
|
274
|
+
throw new Error("Invalid historical receipt record");
|
|
275
|
+
if (v.kind === "tool_excerpt" && (v.complete !== false || ![RECEIPT_RANGE_NOTICE, LEGACY_RECEIPT_RANGE_NOTICE].includes(v.notice) || !v.range ||
|
|
276
|
+
Object.keys(v.range).length !== 4 || !Object.keys(v.range).every(k => ["coordinateSpace", "start", "end", "totalCharacters"].includes(k)) ||
|
|
277
|
+
v.range.coordinateSpace !== "readable-entry-utf16" || !integer(v.range.start) || !integer(v.range.end) || !integer(v.range.totalCharacters) ||
|
|
278
|
+
v.range.start >= v.range.end || v.range.end > v.range.totalCharacters || v.range.totalCharacters <= MAX_RECEIPT_CHARACTERS ||
|
|
279
|
+
v.text.length !== v.range.end - v.range.start)) throw new Error("Invalid historical receipt range");
|
|
280
|
+
}
|
|
281
|
+
return v;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** Accept our explicit record protocol only. Never recover roles from payload text. `version` is the summary header's: v1 reads one JSON
|
|
285
|
+
* object per line, v2 the lines described above. Left out, it is taken from the first line, which a v1 record always begins with "{" and a v2 never. */
|
|
286
|
+
export const parseRecords = (text: string, version: TranscriptVersion = text.startsWith("{") ? 1 : 2): TranscriptRecord[] => {
|
|
70
287
|
if (!text) return [];
|
|
71
|
-
return text.split("\n").map(line =>
|
|
72
|
-
const r: unknown = JSON.parse(line);
|
|
73
|
-
if (!r || typeof r !== "object" || Array.isArray(r)) throw new Error("Invalid pi-tuck transcript record");
|
|
74
|
-
const v = r as TranscriptRecord;
|
|
75
|
-
const roles = ["user", "assistant", "custom", "bashExecution", "toolResult"];
|
|
76
|
-
const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
77
|
-
if (!["excerpt", "tool_call", "tool_receipt", "tool_excerpt", "bash", "omission", "section"].includes(v.kind) ||
|
|
78
|
-
(v.role !== undefined && !roles.includes(v.role)) ||
|
|
79
|
-
(v.sourceTime !== undefined && (v.sourceEntryId === undefined || !validSourceTime(v.sourceTime))) ||
|
|
80
|
-
(v.sourceTimes !== undefined && (!Array.isArray(v.sourceTimes) || !v.sourceTimes.length || v.sourceTimes.some(t => !integer(t.sourceIndex) || !validSourceTime(Object.fromEntries(Object.entries(t).filter(([key]) => key !== "sourceIndex")))))) ||
|
|
81
|
-
(v.sourceIndex !== undefined && !integer(v.sourceIndex)) ||
|
|
82
|
-
(v.sourceEntryId !== undefined && (typeof v.sourceEntryId !== "string" || !v.sourceEntryId || v.sourceEntryId.length > 200 ||
|
|
83
|
-
v.role !== "custom" || !["excerpt", "omission"].includes(v.kind))) ||
|
|
84
|
-
(v.sourceIndices !== undefined && (!Array.isArray(v.sourceIndices) || !v.sourceIndices.every(integer))) ||
|
|
85
|
-
(v.kind !== "omission" && typeof v.text !== "string") ||
|
|
86
|
-
(v.kind === "omission" && typeof v.reason !== "string") ||
|
|
87
|
-
(v.kind === "excerpt" && !["user", "assistant", "custom"].includes(v.role)) ||
|
|
88
|
-
(v.kind === "tool_call" && v.role !== "assistant") ||
|
|
89
|
-
(v.kind === "bash" && v.role !== "bashExecution") ||
|
|
90
|
-
(v.kind === "section" && typeof v.title !== "string") ||
|
|
91
|
-
(v.count !== undefined && !integer(v.count)) ||
|
|
92
|
-
(v.span !== undefined && (v.span.coordinateSpace !== "sanitized-block" || !integer(v.span.start) || !integer(v.span.end) || !integer(v.span.totalCharacters) ||
|
|
93
|
-
v.span.start > v.span.end || v.span.end > v.span.totalCharacters ||
|
|
94
|
-
(v.kind === "excerpt" && v.text!.length !== v.span.end - v.span.start))))
|
|
95
|
-
throw new Error("Invalid pi-tuck transcript record");
|
|
96
|
-
if (v.sourceTimes) {
|
|
97
|
-
const expected = v.sourceIndices ?? (v.sourceIndex === undefined ? [] : [v.sourceIndex]);
|
|
98
|
-
if (v.sourceTimes.length !== expected.length || v.sourceTimes.some((t, i) => t.sourceIndex !== expected[i])) throw new Error("Mismatched source times");
|
|
99
|
-
}
|
|
100
|
-
if (["arguments", "argumentsJSONPrefix", "completeArgumentMembers"].some(key => Object.hasOwn(v, key))) {
|
|
101
|
-
if (v.kind !== "tool_call" ||
|
|
102
|
-
(Object.hasOwn(v, "arguments") && (!smallOriginalArguments(v.arguments, Number.MAX_SAFE_INTEGER) ||
|
|
103
|
-
Object.hasOwn(v, "argumentsJSONPrefix") || Object.hasOwn(v, "completeArgumentMembers"))) ||
|
|
104
|
-
(Object.hasOwn(v, "argumentsJSONPrefix") && (typeof v.argumentsJSONPrefix !== "string" || !v.argumentsJSONPrefix)) ||
|
|
105
|
-
(Object.hasOwn(v, "completeArgumentMembers") && (!Object.hasOwn(v, "argumentsJSONPrefix") ||
|
|
106
|
-
!smallOriginalArguments(v.completeArgumentMembers, Number.MAX_SAFE_INTEGER) || !Object.keys(v.completeArgumentMembers).length)))
|
|
107
|
-
throw new Error("Invalid historical argument fields");
|
|
108
|
-
}
|
|
109
|
-
if (v.kind === "tool_receipt" || v.kind === "tool_excerpt") {
|
|
110
|
-
const p = v.producer;
|
|
111
|
-
const keys = [...(v.sourceTimes ? ["sourceTimes"] : []), "kind", "role", "sourceIndex", "sourceIndices", "historical", "isError", "text", "producer", ...(v.kind === "tool_excerpt" ? ["complete", "notice", "range"] : [])];
|
|
112
|
-
if (Object.keys(v).length !== keys.length || !Object.keys(v).every(k => keys.includes(k)) ||
|
|
113
|
-
v.role !== "toolResult" || v.historical !== true || typeof v.isError !== "boolean" ||
|
|
114
|
-
!v.text || v.text.length > MAX_RECEIPT_CHARACTERS || !integer(v.sourceIndex) ||
|
|
115
|
-
!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).length !== 4 ||
|
|
116
|
-
!Object.keys(p).every(k => ["sourceIndex", "toolName", "toolCallId", "arguments", "argumentsOmitted"].includes(k)) ||
|
|
117
|
-
!integer(p.sourceIndex) || p.sourceIndex >= v.sourceIndex ||
|
|
118
|
-
typeof p.toolName !== "string" || !p.toolName.trim() || p.toolName.length > 160 ||
|
|
119
|
-
typeof p.toolCallId !== "string" || !p.toolCallId || p.toolCallId.length > 160 ||
|
|
120
|
-
!(Object.hasOwn(p, "arguments") && !Object.hasOwn(p, "argumentsOmitted") && smallOriginalArguments(p.arguments) ||
|
|
121
|
-
p.argumentsOmitted === true && !Object.hasOwn(p, "arguments")) ||
|
|
122
|
-
!Array.isArray(v.sourceIndices) || v.sourceIndices.length !== 2 ||
|
|
123
|
-
v.sourceIndices[0] !== p.sourceIndex || v.sourceIndices[1] !== v.sourceIndex)
|
|
124
|
-
throw new Error("Invalid historical receipt record");
|
|
125
|
-
if (v.kind === "tool_excerpt" && (v.complete !== false || ![RECEIPT_RANGE_NOTICE, LEGACY_RECEIPT_RANGE_NOTICE].includes(v.notice) || !v.range ||
|
|
126
|
-
Object.keys(v.range).length !== 4 || !Object.keys(v.range).every(k => ["coordinateSpace", "start", "end", "totalCharacters"].includes(k)) ||
|
|
127
|
-
v.range.coordinateSpace !== "readable-entry-utf16" || !integer(v.range.start) || !integer(v.range.end) || !integer(v.range.totalCharacters) ||
|
|
128
|
-
v.range.start >= v.range.end || v.range.end > v.range.totalCharacters || v.range.totalCharacters <= MAX_RECEIPT_CHARACTERS ||
|
|
129
|
-
v.text.length !== v.range.end - v.range.start)) throw new Error("Invalid historical receipt range");
|
|
130
|
-
}
|
|
131
|
-
return v;
|
|
132
|
-
});
|
|
288
|
+
return text.split("\n").map(line => validate(version === 1 ? JSON.parse(line) : parseLine(line)));
|
|
133
289
|
};
|
package/src/tuck/types.ts
CHANGED
|
@@ -4,19 +4,6 @@ import type { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
export type CompilerMessage = Parameters<typeof convertToLlm>[0][number];
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
export interface FileOps {
|
|
8
|
-
readFiles?: string[];
|
|
9
|
-
modifiedFiles?: string[];
|
|
10
|
-
createdFiles?: string[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ReceiptLink {
|
|
14
|
-
status: "missing" | "ambiguous" | "error" | "non-error";
|
|
15
|
-
sourceIndices: number[];
|
|
16
|
-
/** Every original call/receipt contributing to the derived status. */
|
|
17
|
-
dependencyIndices: number[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
7
|
export interface HistoricalReceipt {
|
|
21
8
|
text: string;
|
|
22
9
|
isError: boolean;
|
|
@@ -26,10 +13,12 @@ export interface HistoricalReceipt {
|
|
|
26
13
|
);
|
|
27
14
|
}
|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
| { kind: "
|
|
32
|
-
| { kind: "
|
|
33
|
-
| { kind: "
|
|
34
|
-
|
|
35
|
-
| { kind: "
|
|
16
|
+
/** `startsSource`: the block's original text begins the message's readable text, so an offset into it is a tuck_recall offset into the source. */
|
|
17
|
+
export type NormalizedBlock = { sourceTimes?: Record<number, import("./core/source-time").SourceTime> } & (
|
|
18
|
+
| { kind: "user"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
|
|
19
|
+
| { kind: "assistant"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
|
|
20
|
+
| { kind: "notice"; text: string; originalText?: string; sourceIndex?: number; sourceEntryId?: string; startsSource?: true }
|
|
21
|
+
// Tool history renders only as one omission notice, so its blocks keep what lookup-echo filtering reads.
|
|
22
|
+
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
23
|
+
| { kind: "tool_result"; name: string; sourceIndex?: number; recallExport?: true }
|
|
24
|
+
| { kind: "bash"; sourceIndex?: number });
|
package/src/tuck.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { resolveSourceTime, type SourceTime } from "./tuck/core/source-time";
|
|
|
2
2
|
import { orderByOriginalSource } from "./tuck/core/source-order";
|
|
3
3
|
import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
|
|
4
4
|
import { parseRecords, serializeTranscript, splitSummary, type TranscriptRecord } from "./tuck/core/transcript";
|
|
5
|
-
import { DIALOGUE_SUFFIX
|
|
5
|
+
import { DIALOGUE_SUFFIX } from "./tuck/core/format";
|
|
6
6
|
import { isTextOnly, textOf } from "./tuck/core/content";
|
|
7
7
|
import { MAX_TASK_CONTRACT_CHARACTERS, actionItems, actionRecords, head, ledgerItems, ledgerRecords } from "./ledger.js";
|
|
8
8
|
import type { Message } from "@earendil-works/pi-ai";
|
|
@@ -15,8 +15,8 @@ type AgentMessage = Parameters<typeof convertToLlm>[0][number];
|
|
|
15
15
|
type Cut = import("./tuck/cut").OwnCutResult;
|
|
16
16
|
/** Counts a projection of the conversation exactly as the triggers, the post-cut checks and the guard do (budget.ts). */
|
|
17
17
|
export type Measure = (messages: AgentMessage[]) => number;
|
|
18
|
-
/** The summary view that produced a draft: the whole pre-cut dialogue, or the
|
|
19
|
-
export type DraftMode = "dialogue" | "
|
|
18
|
+
/** The summary view that produced a draft: the whole pre-cut dialogue, or the same dialogue shrunk to the allowance. Both carry the file ledger. */
|
|
19
|
+
export type DraftMode = "dialogue" | "shrunk";
|
|
20
20
|
type Draft = { compaction: Pick<Extract<SessionEntry, { type: "compaction" }>, "summary" | "firstKeptEntryId" | "tokensBefore" | "details">; after: number; mode: DraftMode; unavailable?: never; capacityRejected?: never }
|
|
21
21
|
| { compaction?: never; mode?: never; unavailable: string; capacityRejected?: boolean };
|
|
22
22
|
interface SearchHit { index: number; role: string; summary: string }
|
|
@@ -29,12 +29,12 @@ export interface Tuck {
|
|
|
29
29
|
applyTailBudget(entries: SessionEntry[], cut: Extract<Cut, { ok: true }>, options: { charsPerToken: number; maxTokens?: number; oversizedFactor?: number }): Cut;
|
|
30
30
|
calibrateCharsPerToken(chars: number, tokens: number): { charsPerToken: number };
|
|
31
31
|
buildGlobalIndexById(entries: readonly SessionEntry[]): Map<string, number>;
|
|
32
|
-
|
|
32
|
+
/** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first; "" when not even the least of it fits. */
|
|
33
|
+
compileShrunkDialogue(input: { messages: AgentMessage[]; sourceIndices: (number | undefined)[]; sourceEntryIds?: readonly string[]; sourceTimes?: readonly SourceTime[];
|
|
33
34
|
receiptContext?: { messages: AgentMessage[]; sourceIndices: (number | undefined)[] };
|
|
34
|
-
ranking: { maxBriefChars: number;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
compileDialogue(input: Parameters<Tuck["compileRanked"]>[0]): string;
|
|
35
|
+
ranking: { maxBriefChars: number; mandatorySourceIndices?: ReadonlySet<number> } }): string;
|
|
36
|
+
/** The whole pre-cut dialogue view; "" offers none, and the shrunk view is used. */
|
|
37
|
+
compileDialogue(input: Parameters<Tuck["compileShrunkDialogue"]>[0]): string;
|
|
38
38
|
prepareKeywordQuery(query: string): PreparedKeywordQuery;
|
|
39
39
|
searchEntriesDetailed(entries: SearchHit[], messages: Message[], query: string,
|
|
40
40
|
tuning: { relativeFloor: number; cap: number }): {
|
|
@@ -44,20 +44,20 @@ export interface Tuck {
|
|
|
44
44
|
import { buildOwnCut, applyTailBudget, MAX_TAIL_TOKENS, OVERSIZED_TAIL_FACTOR } from "./tuck/cut";
|
|
45
45
|
import { calibrateCharsPerToken, estimateSourceMessageChars } from "./tuck/core/token-estimate";
|
|
46
46
|
import { buildGlobalIndexById } from "./tuck/core/global-indices";
|
|
47
|
-
import {
|
|
47
|
+
import { compileShrunkDialogue, compileDialogue } from "./tuck/core/summarize";
|
|
48
48
|
import { prepareKeywordQuery } from "./tuck/core/keyword-query";
|
|
49
49
|
import { searchEntriesDetailed } from "./tuck/core/search-entries";
|
|
50
|
-
export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById,
|
|
50
|
+
export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileShrunkDialogue, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
|
|
51
51
|
/** The one historical allowance: at most 25% of remaining estimated room, for both views.
|
|
52
52
|
* The whole dialogue is offered only within it (plus actual preview/headroom guards);
|
|
53
|
-
* otherwise the
|
|
53
|
+
* otherwise the same dialogue is shrunk to fit it. The 4-character conversion matches pinned Pi's
|
|
54
54
|
* summary estimator, not a provider-token guarantee. Mandatory sources (current input,
|
|
55
55
|
* initiating prompt, file ledger) are already charged in base. */
|
|
56
56
|
export function historicalAllowance(window: number, reserve: number, base: number, before: number): number {
|
|
57
57
|
const free = Math.max(0, window - reserve - base - 1);
|
|
58
58
|
return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
|
|
59
59
|
}
|
|
60
|
-
/** Uses pi-tuck's own cut, calibrated maximum tail and
|
|
60
|
+
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
61
61
|
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false) {
|
|
62
62
|
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
63
63
|
}
|
|
@@ -81,9 +81,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
81
81
|
const chars = (calibration.ok ? calibration.messages.reduce((n, m) => n + estimateSourceMessageChars(m), 0) : 0)
|
|
82
82
|
+ (input.previousSummary?.length ?? 0);
|
|
83
83
|
const { charsPerToken } = tuck.calibrateCharsPerToken(chars, input.tokensBefore);
|
|
84
|
-
// A preferred tail
|
|
85
|
-
// Current/fresh-source floors below may still require an explicit rejection.
|
|
86
|
-
const inputRoom = Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1);
|
|
84
|
+
// A preferred tail takes at most half the current input allowance, so a cut leaves room to grow: a tail that fills the room lands the
|
|
85
|
+
// request just under the band and the next round cuts again. Current/fresh-source floors below may still require an explicit rejection.
|
|
86
|
+
const inputRoom = Math.floor(Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1) / 2);
|
|
87
87
|
const maxTokens = Math.min(MAX_TAIL_TOKENS, inputRoom);
|
|
88
88
|
// Keep the current user turn native; older conversation competes in the
|
|
89
89
|
// existing exact historical view instead of boosting a minimum native tail.
|
|
@@ -183,13 +183,13 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
183
183
|
if (index === undefined) throw new Error("Missing initiating user source index");
|
|
184
184
|
const quoted = head(text, MAX_TASK_CONTRACT_CHARACTERS), sourceTimes = [{ sourceIndex: index, ...resolveSourceTime(initiating) }];
|
|
185
185
|
if (text.trim()) {
|
|
186
|
-
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: index,
|
|
187
|
-
if (quoted.length < text.length) mandatory.push({ kind: "omission", role: "user", sourceIndex: index,
|
|
188
|
-
reason: `Initiating prompt continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}
|
|
186
|
+
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: index, text: quoted, sourceTimes });
|
|
187
|
+
if (quoted.length < text.length) mandatory.push({ kind: "omission", role: "user", sourceIndex: index,
|
|
188
|
+
reason: `Initiating prompt continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}`, sourceTimes });
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
if (protectedUser?.type === "message" && protectedUser.message.role === "user")
|
|
192
|
-
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, sourceTimes: [{ sourceIndex: currentIndex!, ...resolveSourceTime(protectedUser) }]
|
|
192
|
+
mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, text: textOf(protectedUser.message.content), sourceTimes: [{ sourceIndex: currentIndex!, ...resolveSourceTime(protectedUser) }] });
|
|
193
193
|
// The file ledger and the action trail of the compacted history are mandatory, so they are charged in base like the quotes above.
|
|
194
194
|
// A locator is given only where tuck_recall {index} resolves it: a unique global index of a readable entry.
|
|
195
195
|
const compacted = entries.slice(0, boundary), resolves = (entry: SessionEntry) => readable(entry) !== undefined;
|
|
@@ -207,8 +207,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
207
207
|
const model = ctx.model;
|
|
208
208
|
const floorAt = (keep: number) => {
|
|
209
209
|
const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
|
|
210
|
-
|
|
211
|
-
return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + RANKED_SUFFIX)) };
|
|
210
|
+
return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX)) };
|
|
212
211
|
};
|
|
213
212
|
const fits = (base: number) => Boolean(model) && base + reserveTokens < model!.contextWindow && base < before;
|
|
214
213
|
// When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
|
|
@@ -221,13 +220,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
221
220
|
const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id)!)), receiptContext,
|
|
222
221
|
ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) },
|
|
223
222
|
};
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
const assemble = (compiled: string, closing: string) => {
|
|
223
|
+
// Both views use the same parser, mandatory insertion, native preview and guard, and end with the same closing section.
|
|
224
|
+
const assemble = (compiled: string) => {
|
|
227
225
|
if (typeof compiled !== "string") throw new Error("pi-tuck returned an invalid summary");
|
|
228
|
-
const split = compiled ? splitSummary(compiled) : { body: "" };
|
|
226
|
+
const split = compiled ? splitSummary(compiled) : { body: "", version: 2 as const };
|
|
229
227
|
if (!split) throw new Error("pi-tuck returned an invalid summary");
|
|
230
|
-
const records = parseRecords(split.body);
|
|
228
|
+
const records = parseRecords(split.body, split.version);
|
|
231
229
|
for (const record of mandatory) {
|
|
232
230
|
const insertion = records.findIndex(row => row.sourceIndex !== undefined && row.sourceIndex > record.sourceIndex!);
|
|
233
231
|
records.splice(insertion < 0 ? records.length : insertion, 0, record);
|
|
@@ -235,29 +233,29 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
235
233
|
orderByOriginalSource(records, sourceIndices, originalCut.selectedIds);
|
|
236
234
|
// The ledger always has its heading, so no summary is empty.
|
|
237
235
|
records.push(...ledger);
|
|
238
|
-
const summary = serializeTranscript(records) +
|
|
236
|
+
const summary = serializeTranscript(records) + DIALOGUE_SUFFIX;
|
|
239
237
|
const projection = preview(summary);
|
|
240
238
|
assertNativeToolRounds(projection);
|
|
241
239
|
return { summary, after: measure(projection) };
|
|
242
240
|
};
|
|
243
241
|
const free = Math.max(0, model.contextWindow - reserveTokens - base - 1);
|
|
244
242
|
const headroom = Math.ceil(free / 4);
|
|
245
|
-
// The whole pre-cut dialogue, when it fits the allowance; otherwise the
|
|
243
|
+
// The whole pre-cut dialogue, when it fits the allowance; otherwise the same dialogue shrunk to the same allowance.
|
|
246
244
|
// With no allowance only an operator's manual cut can use it.
|
|
247
245
|
const dialogue = maxBriefChars > 0 || input.operatorManual ? tuck.compileDialogue(compileInput) : "";
|
|
248
246
|
const dialogueFits = dialogue.length <= maxBriefChars;
|
|
249
|
-
const candidate = dialogue && (dialogueFits || input.operatorManual) ? assemble(dialogue
|
|
247
|
+
const candidate = dialogue && (dialogueFits || input.operatorManual) ? assemble(dialogue) : undefined;
|
|
250
248
|
const offer = candidate && dialogueFits && candidate.after < before && candidate.after + reserveTokens + headroom < model.contextWindow ? candidate : undefined;
|
|
251
249
|
// Manual no-work preserves native originals; final request admission still owns full overhead.
|
|
252
250
|
if (!offer && input.operatorManual && candidate && candidate.after >= before &&
|
|
253
251
|
before + reserveTokens < model.contextWindow)
|
|
254
252
|
return { unavailable: "Manual compaction preserved original dialogue: the complete-dialogue view does not reduce context" };
|
|
255
|
-
const { summary, after } = offer ?? assemble(tuck.
|
|
253
|
+
const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
|
|
256
254
|
// Draft reduction concerns conversation. Final request admission owns the effective system/tools.
|
|
257
255
|
if (after + reserveTokens >= model.contextWindow || after >= before)
|
|
258
256
|
return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
|
|
259
|
-
const mode: DraftMode = offer ? "dialogue" : "
|
|
260
|
-
return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "
|
|
257
|
+
const mode: DraftMode = offer ? "dialogue" : "shrunk";
|
|
258
|
+
return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
|
|
261
259
|
};
|
|
262
260
|
const preferred = compileAtCut(cut);
|
|
263
261
|
if (preferred.mode === "dialogue") return preferred;
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { NormalizedBlock } from "../types.js";
|
|
2
|
-
import { type ExcerptCache } from "./brief.js";
|
|
3
|
-
/** A set of integers in [0, size) with ordered neighbour queries, in a tree of 32-bit words. */
|
|
4
|
-
export declare class IndexSet {
|
|
5
|
-
private readonly levels;
|
|
6
|
-
constructor(size: number);
|
|
7
|
-
has(i: number): boolean;
|
|
8
|
-
add(i: number): void;
|
|
9
|
-
remove(i: number): void;
|
|
10
|
-
/** The smallest member above `i`, or -1. */
|
|
11
|
-
next(i: number): number;
|
|
12
|
-
/** The largest member below `i`, or -1. */
|
|
13
|
-
prev(i: number): number;
|
|
14
|
-
}
|
|
15
|
-
export interface BriefSize {
|
|
16
|
-
chars: number;
|
|
17
|
-
units: number;
|
|
18
|
-
}
|
|
19
|
-
export interface BriefMeter {
|
|
20
|
-
/** Block `i` joins the selection, or leaves it again. */
|
|
21
|
-
select(i: number): void;
|
|
22
|
-
unselect(i: number): void;
|
|
23
|
-
/** Block `i` is rendered whole instead of as an excerpt, or no longer. */
|
|
24
|
-
promote(i: number): void;
|
|
25
|
-
demote(i: number): void;
|
|
26
|
-
/** The rendered size of the selection, were the unselected blocks `added` selected too, with and without the omission notices. */
|
|
27
|
-
size(added: readonly number[]): {
|
|
28
|
-
withNotices: BriefSize;
|
|
29
|
-
withoutNotices: BriefSize;
|
|
30
|
-
};
|
|
31
|
-
/** Settles the omission notices for blocks selected for good. */
|
|
32
|
-
commit(added: readonly number[]): void;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* The size of `TRANSCRIPT_HEADER + "\n" + compileBrief(selected)` as blocks join, kept up to date by recomputing only the records a change
|
|
36
|
-
* can affect, which are those of the block, its neighbours and the identical calls merged with them. This is the admission loop's yardstick,
|
|
37
|
-
* so it must equal what the full render measures; the final render in `selectRankedBriefBlocks` remains the authority.
|
|
38
|
-
*
|
|
39
|
-
* It relies on what `normalize` guarantees for the blocks of one brief: they are distinct objects; all carry the same `sourceTimes` or none
|
|
40
|
-
* does; and notices that share a `sourceEntryId` share a `sourceTime`.
|
|
41
|
-
*/
|
|
42
|
-
export declare const createBriefMeter: (blocks: NormalizedBlock[], mandatory: ReadonlySet<number> | undefined, cache: ExcerptCache) => BriefMeter;
|