pi-zip 0.0.0-stage → 0.2.1

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/src/summary.ts ADDED
@@ -0,0 +1,500 @@
1
+ // Summary (F11, F12): deterministic skeleton (user words verbatim + files/commands) + model-written narrative + handle table.
2
+ // Thinking is never quoted or paraphrased: it is stripped before the model sees the prefix.
3
+ import { randomUUID } from "node:crypto";
4
+ import { handleFor, outcomeHint, pickKeyLines, RECALL_TOOL, shortArgs } from "./placeholder.ts";
5
+ import { type Block, type Cut, type PlanResult, settings, toolCallIndex } from "./plan.ts";
6
+ import { type Any, clamp, clip, textOf, tok4 } from "./util.ts";
7
+
8
+ export const SUMMARY_MARK = "[summary of earlier conversation by pi-zip]";
9
+ const POLICY_BLOCK_RE = /violate|terms of service|usage polic|content polic|acceptable use/i;
10
+ const NARRATIVE_SYSTEM =
11
+ "You are a context summarization assistant. Read the conversation and write ONLY the requested summary sections. " +
12
+ "Do NOT continue the conversation, do NOT answer questions in it, do NOT call tools.";
13
+
14
+ export interface HandleRow {
15
+ handle: string;
16
+ tool: string;
17
+ args: string;
18
+ hint: string;
19
+ turn?: number; // user turn of the output, so look-alike runs stay apart
20
+ outcome?: string; // exit status / test counts (outcomeHint)
21
+ }
22
+
23
+ const TABLE_HEAD = "## Folded outputs (originals recallable with zip_recall; handles stay valid after later summaries or compaction)";
24
+ const TABLE_ROWS = 40;
25
+ const tableLine = (r: HandleRow) => `- ${r.handle} · ${r.tool}${r.args ? " " + r.args : ""}${r.turn ? ` · turn ${r.turn}` : ""}${r.outcome ? ` · ${r.outcome}` : ""} · ${clip(r.hint, 90)}`;
26
+
27
+ /**
28
+ * Compact table appended to every summary so handles survive it: this prefix's rows merged with the rows of the previous summary's
29
+ * table(s) (hints and outcomes kept), newest TABLE_ROWS rows, each handle once, and a count of the older rows left out.
30
+ */
31
+ export function handleTable(rows: HandleRow[], previous: string | null = null): string | null {
32
+ const prev = parseSummary(previous);
33
+ const merged = mergeItems(prev.table.items, rows.map(tableLine), prev.table.omitted, TABLE_ROWS, Infinity, (l) => TABLE_ROW.exec(l)?.[1]);
34
+ if (!merged.kept.length) return null;
35
+ const lines = merged.omitted ? [`[… ${merged.omitted} older folded outputs omitted from this table …]`, ...merged.kept] : merged.kept;
36
+ return TABLE_HEAD + "\n" + lines.join("\n");
37
+ }
38
+
39
+ /** Keep the newest `n` lines and say how many older ones were left out (never a silent drop). */
40
+ const newest = (xs: string[], n: number): string[] => (xs.length > n ? [`[… ${xs.length - n} older omitted …]`, ...xs.slice(-n)] : xs);
41
+
42
+ const H = "[0-9a-z]{10}"; // a handle, see handleFor
43
+ const CMD_ROW = new RegExp(`^\`(.*)\` -> [^(]*\\(turn (\\d+), (${H})\\)$`);
44
+ const OTHER_ROW = new RegExp(`^(\\S+) (.*) \\(turn (\\d+), (${H})\\)$`);
45
+ const FILE_ROW = /^- (.*) \(([^()]*)\)$/;
46
+ const FILE_OP = new RegExp(`^(\\S+): (${H})$`);
47
+ const TABLE_ROW = new RegExp(`^- (${H}) · (\\S+)((?: [^·]*?)?)(?: · turn (\\d+))?(?: · .*)?$`);
48
+ const INDEX_HEAD = "## Handle index";
49
+ /** Budget of the "Handle index" section, in tokens. */
50
+ export const INDEX_TOKENS = 1500;
51
+
52
+ interface IndexRow {
53
+ handle: string;
54
+ tool: string;
55
+ args: string;
56
+ turn?: number;
57
+ }
58
+
59
+ const indexLine = (r: IndexRow) => `- ${r.handle} · ${r.tool}${r.args ? " " + r.args : ""}${r.turn ? ` · turn ${r.turn}` : ""}`;
60
+
61
+ /**
62
+ * Handle rows (oldest first) recorded in an earlier summary, read from every section that carries them: the commands, files and
63
+ * other-calls lists, the folded-outputs table and an earlier handle index. Works on the whole text, nested carried-forward blocks
64
+ * included, so the clip applied to the carried-forward text cannot lose them.
65
+ */
66
+ export function handleRowsOf(text: string | null): IndexRow[] {
67
+ const rows: IndexRow[] = [];
68
+ let sec = "";
69
+ for (const line of (text ?? "").split("\n")) {
70
+ if (line.startsWith("## ")) {
71
+ sec = line;
72
+ continue;
73
+ }
74
+ let m: RegExpExecArray | null;
75
+ if (sec.startsWith("## Commands run")) {
76
+ if ((m = CMD_ROW.exec(line))) rows.push({ handle: m[3], tool: "bash", args: m[1], turn: Number(m[2]) });
77
+ } else if (sec.startsWith("## Other tool calls")) {
78
+ if ((m = OTHER_ROW.exec(line))) rows.push({ handle: m[4], tool: m[1], args: m[2], turn: Number(m[3]) });
79
+ } else if (sec.startsWith("## Files touched")) {
80
+ if ((m = FILE_ROW.exec(line)))
81
+ for (const op of m[2].split(", ")) {
82
+ const h = FILE_OP.exec(op);
83
+ if (h) rows.push({ handle: h[2], tool: h[1], args: m[1] });
84
+ }
85
+ } else if (sec.startsWith("## Folded outputs") || sec.startsWith(INDEX_HEAD)) {
86
+ if ((m = TABLE_ROW.exec(line))) rows.push({ handle: m[1], tool: m[2], args: m[3].trim(), turn: m[4] ? Number(m[4]) : undefined });
87
+ }
88
+ }
89
+ return rows;
90
+ }
91
+
92
+ /**
93
+ * Handles of the tool results the summary would otherwise lose: this prefix's results and the earlier summary's rows, newest first,
94
+ * minus handles already written elsewhere in the summary, under a token budget. Says how many rows did not fit.
95
+ */
96
+ function handleIndex(now: IndexRow[], previous: string | null, listed: string, budgetTokens: number): string | null {
97
+ const seen = new Set<string>(listed.match(new RegExp(`\\b${H}\\b`, "g")) ?? []);
98
+ const rows: IndexRow[] = [];
99
+ for (const r of [...now].reverse().concat(handleRowsOf(previous).reverse())) {
100
+ if (seen.has(r.handle)) continue;
101
+ seen.add(r.handle);
102
+ rows.push({ ...r, args: clip(r.args, 60) });
103
+ }
104
+ if (!rows.length) return null;
105
+ const head = `${INDEX_HEAD} (older outputs, newest first; zip_recall with a handle returns the original)`;
106
+ let used = tok4(head) + 12; // 12: the "not listed" line
107
+ const lines: string[] = [];
108
+ for (const r of rows) {
109
+ const l = indexLine(r);
110
+ if (used + tok4(l) + 1 > budgetTokens) break;
111
+ used += tok4(l) + 1;
112
+ lines.push(l);
113
+ }
114
+ if (lines.length < rows.length) lines.push(`[… ${rows.length - lines.length} older handles not listed …]`);
115
+ return head + "\n" + lines.join("\n");
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------------------------------------------
119
+ // Sectioned carry-forward. A summary is parsed into its sections; each section of the previous summary is merged with the same
120
+ // section of the new prefix, deduplicated, and the OLDEST items are dropped first under a per-section budget, with a running
121
+ // count of what was left out. (The earlier design clipped the whole carried text to its first 12,000 characters, which kept the
122
+ // oldest nested content and cut the newest requests and handles.)
123
+ // ---------------------------------------------------------------------------------------------------------------
124
+ interface Items {
125
+ items: string[]; // oldest first
126
+ omitted: number; // older items already left out by earlier merges
127
+ }
128
+ interface FileRow {
129
+ path: string;
130
+ ops: Map<string, string | null>; // tool -> handle of its latest result
131
+ }
132
+ export interface ParsedSummary {
133
+ structured: boolean; // at least one section of our own format was found (false: a native Pi compaction summary, free text)
134
+ users: Items;
135
+ files: { rows: FileRow[]; omitted: number };
136
+ cmds: Items;
137
+ others: Items;
138
+ errors: Items;
139
+ table: Items;
140
+ narrative: string[]; // model-written text and free text, oldest first
141
+ }
142
+
143
+ const SECTIONS: Array<[RegExp, string]> = [
144
+ [/^## User requests/, "users"],
145
+ [/^## Files touched/, "files"],
146
+ [/^## Commands run/, "cmds"],
147
+ [/^## Other tool calls/, "others"],
148
+ [/^## Errors/, "errors"],
149
+ [/^## Handle index/, "index"],
150
+ [/^## Folded outputs/, "table"],
151
+ [/^## Narrative/, "narr"],
152
+ [/^## Earlier narrative/, "narr"],
153
+ [/^## Earlier summary/, "free"], // the carried-forward block of the older format
154
+ ];
155
+ /** Heading written for the user section. Its request bodies indent every continuation line by USER_INDENT, so a numbered list or a `## ` heading inside a request cannot be taken for an item or section start. Older summaries have no such marker and are parsed heuristically. */
156
+ const USERS_HEAD = "## User requests (verbatim, oldest first, continuation lines indented)";
157
+ const USER_INDENT = " ";
158
+ const OMIT_ROW = /^\[… (\d+) [^\]]*?(?:omitted|not listed)[^\]]*…\]$/;
159
+ const COVERS_ROW = /^Covers \d+ earlier messages\./;
160
+ const NONE_ROW = "(none)";
161
+
162
+ /** Parse any summary text: pi-zip's current and older formats (carried-forward blocks nested inside carried-forward blocks included), or free text. */
163
+ export function parseSummary(text: string | null): ParsedSummary {
164
+ const out: ParsedSummary = { structured: false, users: { items: [], omitted: 0 }, files: { rows: [], omitted: 0 }, cmds: { items: [], omitted: 0 }, others: { items: [], omitted: 0 }, errors: { items: [], omitted: 0 }, table: { items: [], omitted: 0 }, narrative: [] };
165
+ if (!text) return out;
166
+ // occurrences in text order; in the older nested format the nested (older) copy of a section comes first, so order = oldest first
167
+ const occ: Array<{ key: string; lines: string[]; indented?: boolean }> = [{ key: "free", lines: [] }];
168
+ for (const line of text.split("\n")) {
169
+ if ((line.trim() === SUMMARY_MARK && !/^\s/.test(line)) || COVERS_ROW.test(line)) continue;
170
+ const hit = line.startsWith("## ") ? SECTIONS.find(([re]) => re.test(line)) : undefined;
171
+ if (hit) {
172
+ occ.push({ key: hit[1], lines: [], indented: hit[1] === "users" && line.trim() === USERS_HEAD });
173
+ if (hit[1] !== "free" && hit[1] !== "narr") out.structured = true;
174
+ continue;
175
+ }
176
+ occ[occ.length - 1].lines.push(line);
177
+ }
178
+ const files = new Map<string, FileRow>();
179
+ for (const { key, lines, indented } of occ) {
180
+ const body = lines.filter((l) => l.trim() !== "" && l.trim() !== NONE_ROW);
181
+ if (key === "users") {
182
+ const r = indented ? parseIndentedUsers(lines) : parseUsers(lines);
183
+ out.users.items.push(...r.items);
184
+ out.users.omitted += r.omitted;
185
+ continue;
186
+ }
187
+ const take = (dst: Items, keep: (l: string) => boolean) => {
188
+ for (const l of body) {
189
+ const m = OMIT_ROW.exec(l);
190
+ if (m) dst.omitted += Number(m[1]);
191
+ else if (keep(l)) dst.items.push(l);
192
+ }
193
+ };
194
+ if (key === "files") {
195
+ for (const l of body) {
196
+ const m = OMIT_ROW.exec(l);
197
+ if (m) { out.files.omitted += Number(m[1]); continue; }
198
+ const f = FILE_ROW.exec(l);
199
+ if (!f) continue;
200
+ const ops = new Map<string, string | null>(files.get(f[1])?.ops ?? []);
201
+ for (const op of f[2].split(", ")) {
202
+ const h = FILE_OP.exec(op);
203
+ if (h) ops.set(h[1], h[2]);
204
+ else if (op.trim() && !ops.has(op.trim())) ops.set(op.trim(), null);
205
+ }
206
+ files.delete(f[1]);
207
+ files.set(f[1], { path: f[1], ops });
208
+ }
209
+ } else if (key === "cmds") take(out.cmds, (l) => l.startsWith("`"));
210
+ else if (key === "others") take(out.others, () => true);
211
+ else if (key === "errors") take(out.errors, (l) => l.startsWith("- "));
212
+ else if (key === "table") take(out.table, (l) => TABLE_ROW.test(l));
213
+ else if (key === "narr" || key === "free") {
214
+ const t = lines.join("\n").trim();
215
+ if (t && !t.startsWith("(unavailable")) out.narrative.push(t);
216
+ }
217
+ }
218
+ out.files.rows = [...files.values()];
219
+ return out;
220
+ }
221
+
222
+ /** Current format: an unindented `N. ` line starts a request, indented lines continue it (number values are ignored: they are display only). */
223
+ function parseIndentedUsers(lines: string[]): Items {
224
+ const out: Items = { items: [], omitted: 0 };
225
+ let cur: string[] | null = null;
226
+ const close = () => {
227
+ if (cur) out.items.push(cur.join("\n").replace(/\s+$/, ""));
228
+ cur = null;
229
+ };
230
+ for (const line of lines) {
231
+ const om = OMIT_ROW.exec(line);
232
+ if (om && /requests omitted/.test(line)) {
233
+ close();
234
+ out.omitted += Number(om[1]);
235
+ continue;
236
+ }
237
+ const m = /^\d+\. (.*)$/.exec(line);
238
+ if (m) {
239
+ close();
240
+ cur = [m[1]];
241
+ } else if (cur) cur.push(line.startsWith(USER_INDENT) ? line.slice(USER_INDENT.length) : line.trim() === "" ? "" : line);
242
+ }
243
+ close();
244
+ out.items = out.items.filter((t) => t !== NONE_ROW && t !== "");
245
+ return out;
246
+ }
247
+
248
+ /** Older format: numbered, possibly multi-line requests, heuristic. Numbers run 1, 2, 3...; after an omission marker (or a gap in the older format) any larger number resumes. */
249
+ function parseUsers(lines: string[]): Items {
250
+ const out: Items = { items: [], omitted: 0 };
251
+ let cur: string[] | null = null;
252
+ let last = 0;
253
+ let anyNumber = true;
254
+ const close = () => {
255
+ if (cur) out.items.push(cur.join("\n").replace(/\s+$/, ""));
256
+ cur = null;
257
+ };
258
+ for (const line of lines) {
259
+ const om = OMIT_ROW.exec(line);
260
+ if (om && /requests omitted/.test(line)) {
261
+ close();
262
+ out.omitted += Number(om[1]);
263
+ anyNumber = true;
264
+ continue;
265
+ }
266
+ const m = /^(\d+)\. (.*)$/.exec(line);
267
+ if (m && (anyNumber ? Number(m[1]) > last || last === 0 : Number(m[1]) === last + 1)) {
268
+ close();
269
+ cur = [m[2]];
270
+ last = Number(m[1]);
271
+ anyNumber = false;
272
+ } else if (cur) cur.push(line);
273
+ }
274
+ close();
275
+ out.items = out.items.filter((t) => t !== NONE_ROW && t !== "");
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * older + newer, oldest first. Deduplicated by `key` (default: the item itself; the newest copy wins and keeps the newest position;
281
+ * `null` = no dedup, for repeated user requests), then the newest items that fit `maxItems` and `maxChars` are kept (always at least the newest one). `omitted` counts every older
282
+ * item left out so far, this merge's drops included.
283
+ */
284
+ function mergeItems(older: string[], newer: string[], omitted: number, maxItems: number, maxChars: number, key: ((s: string) => string | undefined) | null = (s) => s): { kept: string[]; omitted: number } {
285
+ let all = [...older, ...newer];
286
+ if (key) {
287
+ const seen = new Set<string>();
288
+ all = all.reverse().filter((x) => { const k = key(x) ?? x; return seen.has(k) ? false : (seen.add(k), true); }).reverse();
289
+ }
290
+ let from = all.length;
291
+ let used = 0;
292
+ while (from > 0 && all.length - from < maxItems && used + all[from - 1].length + 1 <= maxChars) used += all[--from].length + 1;
293
+ if (from === all.length && all.length) from--; // the newest item always stays
294
+ return { kept: all.slice(from), omitted: omitted + from };
295
+ }
296
+
297
+ /** Per-section budgets (characters unless noted); the total stays near the old design's 12,000-char carry plus the new prefix's own lists. */
298
+ const BUDGET = { users: 16000, files: 6000, cmds: 10000, others: 5000, errors: 4000, lines: 60, errorLines: 15 };
299
+ /** The previous narrative is kept as a tail excerpt: the newest decisions, open todos and key facts sit at its end. */
300
+ export const PREV_NARRATIVE_CHARS = 3000;
301
+ /** A previous summary that is only free text (Pi's own compaction) has no lists to merge: its excerpt keeps a head (the goal) and a longer tail. */
302
+ export const FOREIGN_SUMMARY_CHARS = 6000;
303
+
304
+ function tailExcerpt(text: string, max: number, head = 0): string {
305
+ if (text.length <= max) return text;
306
+ const cutAtLine = (t: string) => {
307
+ const nl = t.indexOf("\n"); // start on a line boundary, else on a word boundary: never mid-word
308
+ if (nl >= 0 && nl < 200) return t.slice(nl + 1);
309
+ const sp = t.indexOf(" ");
310
+ return sp >= 0 && sp < 40 ? t.slice(sp + 1) : t;
311
+ };
312
+ const h = head ? text.slice(0, head).replace(/\s+\S*$/, "") : "";
313
+ const t = cutAtLine(text.slice(-(max - h.length)));
314
+ return (h ? h + "\n" : "") + "[… middle of the earlier narrative omitted …]\n" + t;
315
+ }
316
+
317
+ const list = (title: string, m: { kept: string[]; omitted: number }, marker: string): string[] => [title, m.kept.length ? [...(m.omitted ? [`[… ${m.omitted} ${marker} …]`] : []), ...m.kept].join("\n") : NONE_ROW];
318
+
319
+ /**
320
+ * The deterministic part of a summary: user words verbatim, files, commands, other calls, errors and a handle index, each section
321
+ * merged from the previous summary `previous` (any format) and this prefix. `tableText` = the folded-outputs table the caller will
322
+ * append, so the index does not repeat its handles.
323
+ */
324
+ export function skeleton(prefix: Block[], previous: string | null, tableText = ""): { text: string; users: number } {
325
+ const calls = toolCallIndex(prefix);
326
+ const results = new Map<string, Block>();
327
+ for (const b of prefix) if (b.kind === "toolResult") results.set(b.msg.toolCallId, b);
328
+ const users: string[] = [];
329
+ const files = new Map<string, Map<string, string | null>>(); // path -> tool -> handle of its latest result
330
+ const cmds: string[] = [];
331
+ const others: string[] = []; // tools with neither a command nor a path (web fetch, pathless grep, custom tools)
332
+ const errors: string[] = [];
333
+ const now: IndexRow[] = []; // every recallable result of this prefix, oldest first
334
+ for (const b of prefix) {
335
+ if (b.kind === "user") {
336
+ const t = textOf(b.msg.content).trim();
337
+ if (t) users.push(t.length > 3000 ? t.slice(0, 2000) + "\n[… middle of this request omitted …]\n" + t.slice(-800) : t);
338
+ }
339
+ if (b.kind !== "assistant") continue;
340
+ for (const c of b.msg.content ?? []) {
341
+ if (c?.type !== "toolCall") continue;
342
+ const a = c.arguments ?? {};
343
+ const res = results.get(c.id);
344
+ const resText = res ? textOf((res.raw ?? res.msg).content) : "";
345
+ if (c.name === "bash") {
346
+ const m = /Command exited with code (\d+)/.exec(resText);
347
+ const status = m ? `exit ${m[1]}` : res?.msg.isError ? "error" : res ? "exit 0" : "no result";
348
+ const tag = res ? ` (turn ${res.userTurn}${res.entryId ? ", " + handleFor(res.entryId) : ""})` : ""; // every result is recallable, so every one gets its handle
349
+ cmds.push(`\`${clip(String(a.command ?? ""), 160)}\` -> ${status}${tag}`);
350
+ } else if (typeof a.path === "string" || typeof a.file_path === "string") {
351
+ const p = String(a.path ?? a.file_path);
352
+ if (!files.has(p)) files.set(p, new Map());
353
+ const ops = files.get(p)!;
354
+ ops.set(c.name, res?.entryId ? handleFor(res.entryId) : (ops.get(c.name) ?? null));
355
+ } else if (c.name !== RECALL_TOOL) {
356
+ const tag = res ? ` (turn ${res.userTurn}${res.entryId ? ", " + handleFor(res.entryId) : ""})` : " (no result)";
357
+ others.push(`${c.name} ${clip(shortArgs(a), 160)}${tag}`);
358
+ }
359
+ if (res?.entryId && c.name !== RECALL_TOOL) now.push({ handle: handleFor(res.entryId), tool: c.name, args: clip(shortArgs(a), 60), turn: res.userTurn });
360
+ if (res?.msg.isError) errors.push(`- ${c.name} ${shortArgs(a)}: ${clip(resText, 200)}`);
361
+ }
362
+ }
363
+ const prev = parseSummary(previous);
364
+ // user requests: previous (older) then new; the oldest go first, numbering continues over what was left out
365
+ const mu = mergeItems(prev.users.items, users, prev.users.omitted, Infinity, BUDGET.users, null); // no dedup: a repeated "yes" is a different request each time
366
+ const userLines = mu.kept.map((u, i) => `${mu.omitted + i + 1}. ${u.replace(/\n/g, "\n" + USER_INDENT)}`);
367
+ // files: a path touched again moves to the newest end with its newest handles
368
+ const merged = new Map<string, FileRow>(prev.files.rows.map((r) => [r.path, r]));
369
+ for (const [path, ops] of files) {
370
+ const ex = merged.get(path);
371
+ merged.delete(path);
372
+ merged.set(path, { path, ops: new Map([...(ex?.ops ?? []), ...[...ops].filter(([t, h]) => h !== null || !ex?.ops.has(t))]) });
373
+ }
374
+ const fileText = [...merged.values()].map((r) => `- ${r.path} (${[...r.ops].map(([t, h]) => (h ? `${t}: ${h}` : t)).join(", ")})`);
375
+ const mf = mergeItems(fileText, [], prev.files.omitted, BUDGET.lines, BUDGET.files, (l) => FILE_ROW.exec(l)?.[1]);
376
+ const mc = mergeItems(prev.cmds.items, cmds, prev.cmds.omitted, BUDGET.lines, BUDGET.cmds);
377
+ const mo = mergeItems(prev.others.items, others, prev.others.omitted, BUDGET.lines, BUDGET.others);
378
+ const me = mergeItems(prev.errors.items, errors, prev.errors.omitted, BUDGET.errorLines, BUDGET.errors);
379
+ const out: string[] = [SUMMARY_MARK];
380
+ out.push(`Covers ${prefix.length} earlier messages. Tool outputs in the kept part of the conversation may have been folded; call zip_recall with a handle to get one back exactly.`);
381
+ out.push(USERS_HEAD);
382
+ if (mu.omitted) out.push(`[… ${mu.omitted} older requests omitted …]`);
383
+ out.push(...(userLines.length ? userLines : [NONE_ROW]));
384
+ out.push(...list("## Files touched", mf, "older omitted"));
385
+ out.push(...list("## Commands run (with exit status)", mc, "older omitted"));
386
+ if (mo.kept.length) out.push(...list("## Other tool calls (turn, handle)", mo, "older omitted"));
387
+ out.push(...list("## Errors", me, "older omitted"));
388
+ const index = handleIndex(now, previous, out.join("\n") + "\n" + tableText, INDEX_TOKENS);
389
+ if (index) out.push(index);
390
+ const foreign = !prev.structured;
391
+ const narr = prev.narrative.join("\n\n");
392
+ if (narr) out.push(`## Earlier narrative (excerpt of the previous summary, newest part)`, foreign ? tailExcerpt(narr, FOREIGN_SUMMARY_CHARS, 1500) : tailExcerpt(narr, PREV_NARRATIVE_CHARS));
393
+ return { text: out.join("\n"), users: users.length };
394
+ }
395
+
396
+ /** Pi's own serializer (lazy import: only needed when a summary is actually written); a plain-text fallback if it is unavailable. */
397
+ async function serialize(msgs: Any[]): Promise<string> {
398
+ try {
399
+ const pi: Any = await import("@earendil-works/pi-coding-agent");
400
+ return pi.serializeConversation(pi.convertToLlm(msgs));
401
+ } catch {
402
+ return msgs.map((m) => `[${m.role}]: ${typeof m.content === "string" ? m.content : textOf(m.content) || JSON.stringify(m.content ?? "")}`).join("\n\n");
403
+ }
404
+ }
405
+
406
+ export interface Narrative {
407
+ text: string | null;
408
+ ok: boolean;
409
+ ms: number;
410
+ costUsd: number;
411
+ usage?: Any;
412
+ error?: string;
413
+ }
414
+
415
+ /** Narrative sections written by the current model in a separate, uncached call. Never throws. */
416
+ export async function narrative(prefix: Block[], ctx: Any, budgetTokens: number, ownSignal?: AbortSignal): Promise<Narrative> {
417
+ const t0 = Date.now();
418
+ const model = ctx.model;
419
+ if (!model) return { text: null, ok: false, ms: 0, costUsd: 0, error: "no current model" };
420
+ const msgs = prefix
421
+ .filter((b) => b.kind !== "summary")
422
+ .map((b) => {
423
+ const m = b.raw && !(b.edited && !b.ours) ? b.raw : b.msg; // the summariser reads the unfolded originals
424
+ return m.role === "assistant" ? { ...m, content: (m.content ?? []).filter((c: Any) => c?.type !== "thinking") } : m;
425
+ });
426
+ let lastErr = "";
427
+ try {
428
+ const conv = await serialize(msgs);
429
+ const words = Math.round(clamp(budgetTokens * 0.6, 120, 1500));
430
+ const prompt =
431
+ `<conversation>\n${conv}\n</conversation>\n\n` +
432
+ `Write the NARRATIVE part of a context summary for this conversation. A separate deterministic section already lists the user's ` +
433
+ `requests, files touched, commands with exit codes and errors, so do NOT repeat those. When you refer to a specific tool output, name it by its ` +
434
+ `tool and exact command or path (and turn) so that look-alike runs stay distinguishable; never merge similar runs into one. Output only these markdown sections:\n` +
435
+ `## Decisions and rationale\n## Current state of the work\n## Open todos / next steps\n## Key facts to remember (exact values, paths, identifiers, results the work still depends on)\n` +
436
+ `Be concrete and keep exact paths, names and numbers. Stay under about ${words} words. Do not call tools.`;
437
+ const signals: AbortSignal[] = [AbortSignal.timeout(240_000)];
438
+ // a background summary has its own signal (nothing is running, so the run's signal would be stale); a summary the user waits for follows the run's (Esc)
439
+ const outer = ownSignal ?? ctx.signal;
440
+ if (outer) signals.push(outer);
441
+ const signal = AbortSignal.any(signals);
442
+ for (let attempt = 0; attempt < 2; attempt++) {
443
+ try {
444
+ const res: Any = await ctx.modelRegistry.complete(
445
+ model,
446
+ { systemPrompt: NARRATIVE_SYSTEM, messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] },
447
+ { maxTokens: 8192, signal, cacheRetention: "none", sessionId: randomUUID() },
448
+ );
449
+ if (res.stopReason === "error") throw new Error(res.errorMessage || "provider error");
450
+ if (res.stopReason === "length") throw new Error("narrative hit the token cap");
451
+ if ((res.content ?? []).some((c: Any) => c.type === "toolCall")) throw new Error("summariser attempted a tool call");
452
+ const text = textOf(res.content).trim();
453
+ if (!text) throw new Error("empty narrative");
454
+ return { text, ok: true, ms: Date.now() - t0, costUsd: Number(res.usage?.cost?.total) || 0, usage: res.usage };
455
+ } catch (err) {
456
+ lastErr = err instanceof Error ? err.message : String(err);
457
+ if (POLICY_BLOCK_RE.test(lastErr) || signal.aborted) break; // an identical retry fails the same way
458
+ }
459
+ }
460
+ } catch (err) {
461
+ lastErr = err instanceof Error ? err.message : String(err);
462
+ }
463
+ return { text: null, ok: false, ms: Date.now() - t0, costUsd: 0, error: lastErr };
464
+ }
465
+
466
+ /** The cold summary (skeleton + narrative + handle table) for a planned cut. */
467
+ export async function buildCut(p: PlanResult, ctx: Any, signal?: AbortSignal): Promise<Cut> {
468
+ const { foldMin, keepLines } = settings();
469
+ const t0 = performance.now();
470
+ const prefix = p.blocks.slice(0, p.cutIdx!);
471
+ const prev = prefix[0]?.kind === "summary" ? String(prefix[0].msg.summary ?? "") || null : null;
472
+ const rows: HandleRow[] = prefix
473
+ .filter((b) => b.kind === "toolResult" && b.entryId && (b.ours || b.tokens > foldMin))
474
+ .map((b) => {
475
+ const call = p.calls.get(b.msg.toolCallId);
476
+ const keys = pickKeyLines(textOf((b.raw ?? b.msg).content), keepLines);
477
+ const tool = call?.name ?? b.msg.toolName ?? "tool";
478
+ return { handle: handleFor(b.entryId!), tool, args: call ? shortArgs(call.args) : "", turn: b.userTurn, outcome: outcomeHint(textOf((b.raw ?? b.msg).content), tool, !!b.msg.isError), hint: (keys.find((k) => k.why === "error" || k.why === "id") ?? keys[0])?.text ?? "" };
479
+ });
480
+ const table = handleTable(rows, prev);
481
+ const sk = skeleton(prefix.filter((b) => b.kind !== "summary"), prev, table ?? "");
482
+ const nb = clamp(p.summaryTokensPlanned - tok4(sk.text), 300, 4000);
483
+ const nar = await narrative(prefix, ctx, nb, signal);
484
+ let text = sk.text;
485
+ text += nar.ok && nar.text ? `\n## Narrative (model-written)\n${nar.text.slice(0, nb * 6)}` : `\n## Narrative\n(unavailable: ${nar.error ?? "n/a"}; rely on the sections above and re-read files as needed)`;
486
+ if (table) text += "\n" + table;
487
+ return {
488
+ firstKeptEntryId: p.blocks[p.cutIdx!].entryId!,
489
+ text,
490
+ trigger: p.sumTrigger ?? "cold",
491
+ count: sk.users,
492
+ prefixTokens: p.prefixTokens,
493
+ summaryTokens: tok4(text),
494
+ llmOk: nar.ok,
495
+ llmError: nar.error ?? null,
496
+ costUsd: nar.costUsd,
497
+ usage: nar.usage,
498
+ ms: Math.round(performance.now() - t0),
499
+ };
500
+ }
package/src/util.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Small shared helpers (no Pi imports, so every pure module stays testable without Pi installed).
2
+ export type Any = any;
3
+
4
+ export const PRODUCT = "pi-zip";
5
+
6
+ export const textOf = (c: Any): string =>
7
+ typeof c === "string" ? c : Array.isArray(c) ? c.filter((b: Any) => b?.type === "text").map((b: Any) => b.text ?? "").join("\n") : "";
8
+
9
+ export const tok4 = (s: string) => Math.ceil(s.length / 4);
10
+ export const clamp = (x: number, lo: number, hi: number) => Math.min(Math.max(x, lo), hi);
11
+
12
+ export function clip(s: string, n: number): string {
13
+ s = s.replace(/\s+/g, " ").trim();
14
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
15
+ }
16
+
17
+ /** Test-only numeric override: PI_ZIP_<NAME>. */
18
+ export function envInt(name: string, fallback: number): number {
19
+ const raw = process.env[`PI_ZIP_${name}`];
20
+ const v = Number(raw);
21
+ return raw !== undefined && raw !== "" && Number.isFinite(v) ? v : fallback;
22
+ }
23
+
24
+ const IMAGE_CHARS = 4800;
25
+ const contentChars = (c: Any): number =>
26
+ typeof c === "string" ? c.length : Array.isArray(c) ? c.reduce((a: number, b: Any) => a + (b?.type === "text" ? (b.text?.length ?? 0) : b?.type === "image" ? IMAGE_CHARS : 0), 0) : 0;
27
+
28
+ /** chars/4 estimate, same rules as Pi's estimateTokens (kept local so plan.ts has no runtime Pi dependency). */
29
+ export function tokensOf(m: Any): number {
30
+ let chars = 0;
31
+ switch (m?.role) {
32
+ case "assistant":
33
+ for (const b of m.content ?? []) {
34
+ if (b?.type === "text") chars += b.text?.length ?? 0;
35
+ else if (b?.type === "thinking") chars += b.thinking?.length ?? 0;
36
+ else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + JSON.stringify(b.arguments ?? {}).length;
37
+ }
38
+ break;
39
+ case "bashExecution":
40
+ chars = (m.command?.length ?? 0) + (m.output?.length ?? 0);
41
+ break;
42
+ case "branchSummary":
43
+ case "compactionSummary":
44
+ chars = m.summary?.length ?? 0;
45
+ break;
46
+ default:
47
+ chars = contentChars(m?.content);
48
+ }
49
+ return Math.ceil(chars / 4);
50
+ }