pi-zip 0.2.7 → 0.3.0-rc.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/state.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Planner state that is a function of the session: rebuilt from the branch at session start (a restart, a resume, a fork), so a fresh
2
+ // process plans exactly like one that never left (formal/TRIAGE.md (a) 1: growth g, "the newest response carried an edit", and
3
+ // "a summary was made in this warm stretch" lived only in memory).
4
+ import { COMMIT_CUSTOM, PRODUCT, type Any } from "./util.ts";
5
+
6
+ /** A pi-zip summary that went in through Pi's compaction before a run (the run's first request is the first to carry it). */
7
+ const nativeSummary = (e: Any): boolean => e?.type === "compaction" && e.details?.by === PRODUCT && e.details?.via === "native";
8
+
9
+ /** For each assistant message of the branch (by branch index): was it the first response to carry a pi-zip edit? A run plan is sent from
10
+ * the run's first request and persisted after its response (the commit entry says `carried`); a warm-valve plan, or a summary that went
11
+ * in through Pi's compaction, is first carried by the next request. Errors and aborts count: the request was made. */
12
+ export function carriedEdits(branch: Any[]): Set<number> {
13
+ const edited = new Set<number>();
14
+ let next = false, last = -1;
15
+ branch.forEach((e, i) => {
16
+ if (e?.type === "message" && e.message?.role === "assistant") {
17
+ if (next) edited.add(i);
18
+ next = false;
19
+ last = i;
20
+ } else if (nativeSummary(e)) next = true;
21
+ else if (e?.type === "custom" && e.customType === COMMIT_CUSTOM) {
22
+ if (e.data?.carried && last >= 0) edited.add(last);
23
+ else if (!e.data?.carried) next = true;
24
+ }
25
+ });
26
+ return edited;
27
+ }
28
+
29
+ /** Growth per request, as run.ts `learn` accumulates it: EWMA(0.1) of the growth between consecutive responses of one model that
30
+ * carried no edit of ours, each capped at the p90 of the last 20 deltas; and whether the newest response was the first to carry an edit. */
31
+ export function replayGrowth(branch: Any[], g0: number): { g: number; deltas: number[]; justEdited: boolean } {
32
+ const edited = carriedEdits(branch);
33
+ let g = g0, deltas: number[] = [], prevKey = "", prevTotal = 0, justEdited = false;
34
+ branch.forEach((e, i) => {
35
+ const m = e?.type === "message" ? e.message : null;
36
+ if (m?.role !== "assistant") return;
37
+ justEdited = edited.has(i);
38
+ const u = m.usage;
39
+ if (!u || m.stopReason === "error" || m.stopReason === "aborted") return;
40
+ const total = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
41
+ if (!(total > 0)) return;
42
+ const key = `${m.provider ?? ""}/${m.model ?? ""}`;
43
+ if (!edited.has(i) && prevKey === key && prevTotal > 0 && total >= prevTotal) {
44
+ const d = total - prevTotal;
45
+ deltas = [...deltas.slice(-19), d];
46
+ const sorted = [...deltas].sort((a, b) => a - b);
47
+ g = 0.9 * g + 0.1 * Math.min(d, sorted[Math.ceil(0.9 * sorted.length) - 1]);
48
+ }
49
+ prevTotal = total;
50
+ prevKey = key;
51
+ });
52
+ return { g, deltas, justEdited };
53
+ }
package/src/summary.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { handleFor, outcomeHint, pickKeyLines, RECALL_TOOL, shortArgs } from "./placeholder.ts";
5
5
  import { type Block, type Cut, type PlanResult, settings, toolCallIndex } from "./plan.ts";
6
+ import { type FileRecall, fileRecallHow } from "./recallfile.ts";
6
7
  import { type Any, clamp, clip, textOf, tok4 } from "./util.ts";
7
8
 
8
9
  export const SUMMARY_MARK = "[summary of earlier conversation by pi-zip]";
@@ -21,6 +22,8 @@ export interface HandleRow {
21
22
  }
22
23
 
23
24
  const TABLE_HEAD = "## Folded outputs (originals recallable with zip_recall; handles stay valid after later summaries or compaction)";
25
+ /** The same heading when zip_recall is hidden and the originals are read from recall files (parseSummary keys on "## Folded outputs"). */
26
+ const tableHead = (file?: FileRecall) => (file ? `## Folded outputs (originals in ${file.dir}/<handle>.txt; handles stay valid after later summaries or compaction)` : TABLE_HEAD);
24
27
  const TABLE_ROWS = 40;
25
28
  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
29
 
@@ -28,12 +31,12 @@ const tableLine = (r: HandleRow) => `- ${r.handle} · ${r.tool}${r.args ? " " +
28
31
  * Compact table appended to every summary so handles survive it: this prefix's rows merged with the rows of the previous summary's
29
32
  * table(s) (hints and outcomes kept), newest TABLE_ROWS rows, each handle once, and a count of the older rows left out.
30
33
  */
31
- export function handleTable(rows: HandleRow[], previous: string | null = null): string | null {
34
+ export function handleTable(rows: HandleRow[], previous: string | null = null, file?: FileRecall): string | null {
32
35
  const prev = parseSummary(previous);
33
36
  const merged = mergeItems(prev.table.items, rows.map(tableLine), prev.table.omitted, TABLE_ROWS, Infinity, (l) => TABLE_ROW.exec(l)?.[1]);
34
37
  if (!merged.kept.length) return null;
35
38
  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");
39
+ return tableHead(file) + "\n" + lines.join("\n");
37
40
  }
38
41
 
39
42
  /** Keep the newest `n` lines and say how many older ones were left out (never a silent drop). */
@@ -93,7 +96,7 @@ export function handleRowsOf(text: string | null): IndexRow[] {
93
96
  * Handles of the tool results the summary would otherwise lose: this prefix's results and the earlier summary's rows, newest first,
94
97
  * minus handles already written elsewhere in the summary, under a token budget. Says how many rows did not fit.
95
98
  */
96
- function handleIndex(now: IndexRow[], previous: string | null, listed: string, budgetTokens: number): string | null {
99
+ function handleIndex(now: IndexRow[], previous: string | null, listed: string, budgetTokens: number, file?: FileRecall): string | null {
97
100
  const seen = new Set<string>(listed.match(new RegExp(`\\b${H}\\b`, "g")) ?? []);
98
101
  const rows: IndexRow[] = [];
99
102
  for (const r of [...now].reverse().concat(handleRowsOf(previous).reverse())) {
@@ -102,7 +105,7 @@ function handleIndex(now: IndexRow[], previous: string | null, listed: string, b
102
105
  rows.push({ ...r, args: clip(r.args, 60) });
103
106
  }
104
107
  if (!rows.length) return null;
105
- const head = `${INDEX_HEAD} (older outputs, newest first; zip_recall with a handle returns the original)`;
108
+ const head = `${INDEX_HEAD} (older outputs, newest first; ${file ? `${file.dir}/<handle>.txt holds the original` : "zip_recall with a handle returns the original"})`;
106
109
  let used = tok4(head) + 12; // 12: the "not listed" line
107
110
  const lines: string[] = [];
108
111
  for (const r of rows) {
@@ -321,7 +324,7 @@ const list = (title: string, m: { kept: string[]; omitted: number }, marker: str
321
324
  * merged from the previous summary `previous` (any format) and this prefix. `tableText` = the folded-outputs table the caller will
322
325
  * append, so the index does not repeat its handles.
323
326
  */
324
- export function skeleton(prefix: Block[], previous: string | null, tableText = ""): { text: string; users: number } {
327
+ export function skeleton(prefix: Block[], previous: string | null, tableText = "", file?: FileRecall): { text: string; users: number } {
325
328
  const calls = toolCallIndex(prefix);
326
329
  const results = new Map<string, Block>();
327
330
  for (const b of prefix) if (b.kind === "toolResult") results.set(b.msg.toolCallId, b);
@@ -377,7 +380,13 @@ export function skeleton(prefix: Block[], previous: string | null, tableText = "
377
380
  const mo = mergeItems(prev.others.items, others, prev.others.omitted, BUDGET.lines, BUDGET.others);
378
381
  const me = mergeItems(prev.errors.items, errors, prev.errors.omitted, BUDGET.errorLines, BUDGET.errors);
379
382
  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.`);
383
+ out.push(
384
+ file
385
+ ? `Covers ${prefix.length} earlier messages. Their tool outputs are no longer in your context but are kept exactly: the 10-character code next to a command, file or call below is its handle, and ${file.dir}/<handle>.txt holds that output. ` +
386
+ `To use what an output said, ${fileRecallHow(file)}${file.grep || file.bash ? ` (grep over ${file.dir} searches every earlier output)` : ""} rather than re-running or re-reading (results may differ). Folded blocks later in the conversation work the same way.`
387
+ : `Covers ${prefix.length} earlier messages. Their tool outputs are no longer in your context but are kept exactly: the 10-character code next to a command, file or call below is its handle. ` +
388
+ `To use what an output said, ${RECALL_TOOL} it (grep/range for a part; grep alone searches every earlier output) rather than re-running or re-reading (results may differ). Folded blocks later in the conversation work the same way.`,
389
+ );
381
390
  out.push(USERS_HEAD);
382
391
  if (mu.omitted) out.push(`[… ${mu.omitted} older requests omitted …]`);
383
392
  out.push(...(userLines.length ? userLines : [NONE_ROW]));
@@ -385,7 +394,7 @@ export function skeleton(prefix: Block[], previous: string | null, tableText = "
385
394
  out.push(...list("## Commands run (with exit status)", mc, "older omitted"));
386
395
  if (mo.kept.length) out.push(...list("## Other tool calls (turn, handle)", mo, "older omitted"));
387
396
  out.push(...list("## Errors", me, "older omitted"));
388
- const index = handleIndex(now, previous, out.join("\n") + "\n" + tableText, INDEX_TOKENS);
397
+ const index = handleIndex(now, previous, out.join("\n") + "\n" + tableText, INDEX_TOKENS, file);
389
398
  if (index) out.push(index);
390
399
  const foreign = !prev.structured;
391
400
  const narr = prev.narrative.join("\n\n");
@@ -477,12 +486,14 @@ export async function buildCut(p: PlanResult, ctx: Any, signal?: AbortSignal): P
477
486
  const tool = call?.name ?? b.msg.toolName ?? "tool";
478
487
  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
488
  });
480
- const table = handleTable(rows, prev);
481
- const sk = skeleton(prefix.filter((b) => b.kind !== "summary"), prev, table ?? "");
489
+ const file = p.fileRecall; // zip_recall hidden by an allowlist: the summary names the recall files instead
490
+ const table = handleTable(rows, prev, file);
491
+ const sk = skeleton(prefix.filter((b) => b.kind !== "summary"), prev, table ?? "", file);
482
492
  const nb = clamp(p.summaryTokensPlanned - tok4(sk.text), 300, 4000);
483
493
  const nar = await narrative(prefix, ctx, nb, signal);
484
494
  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)`;
495
+ const how = file ? `read an output's file by its handle (${file.dir}/<handle>.txt)` : `${RECALL_TOOL} an output by its handle`;
496
+ 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 ${how} rather than re-running or re-reading)`;
486
497
  if (table) text += "\n" + table;
487
498
  return {
488
499
  firstKeptEntryId: p.blocks[p.cutIdx!].entryId!,
@@ -496,5 +507,6 @@ export async function buildCut(p: PlanResult, ctx: Any, signal?: AbortSignal): P
496
507
  costUsd: nar.costUsd,
497
508
  usage: nar.usage,
498
509
  ms: Math.round(performance.now() - t0),
510
+ ...(p.ahead ? { ahead: true } : {}),
499
511
  };
500
512
  }
package/src/ui.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  // Transcript surfaces (pure, testable): the status card, state lines, the zip_recall row and the fold mark on a folded output.
2
- // One visual grammar everywhere: "▸ pi-zip" in the accent colour, numbers in the text colour, everything else dim, no boxes;
3
- // collapsed = the conclusion, ctrl+o = the detail. Every returned line fits the given width.
2
+ // One visual grammar everywhere: the monogram ƶ (bold, accent) says whose line it is, the words zip / zipped / unzip say what
3
+ // happened, the one bright thing is the size now; everything else is dim, no boxes, no other symbols.
4
+ // Collapsed = the conclusion, click or ctrl+o = the detail. Every returned line fits the given width.
5
+ // ƶ (U+01B6) is a Latin letter: East Asian width N, so one column in every terminal and locale; fonts without it fall back.
4
6
  import { PRODUCT } from "./util.ts";
5
7
 
6
8
  export interface Th {
7
9
  fg(color: string, text: string): string;
10
+ bold?(text: string): string;
8
11
  }
9
12
  /** Display width and truncation: Pi's visibleWidth / truncateToWidth in the renderer, string length in tests. */
10
13
  export interface Measure {
@@ -21,8 +24,9 @@ export function setMeasure(m: Measure) {
21
24
  measure = m;
22
25
  }
23
26
 
24
- export const MARK = "▸ ";
25
- const HEAD = MARK + PRODUCT;
27
+ /** The pi-zip monogram: z for zip, the stroke is the slider. */
28
+ export const SYM = "ƶ";
29
+ export const brand = (th: Th): string => th.fg("accent", th.bold ? th.bold(SYM) : SYM);
26
30
  const LABEL_W = 9;
27
31
 
28
32
  /** Plain text without control characters (tabs become two spaces): recalled output goes into the TUI as text, never as escape codes. */
@@ -33,14 +37,14 @@ export const clean = (s: string): string => s.replace(/\t/g, " ").replace(/\x1b
33
37
  export type StateWord = "on" | "off" | "paused" | "reread-only" | "quiet" | "notices on" | "cache";
34
38
  const WARN = new Set<StateWord>(["off", "paused", "reread-only"]);
35
39
 
36
- /** "▸ pi-zip paused billion-context also manages context · run: pi remove npm:billion-context" */
40
+ /** "ƶ pi-zip paused billion-context also manages context · run: pi remove npm:billion-context" */
37
41
  export function renderState(word: StateWord, reason: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
38
- const wordC = th.fg(WARN.has(word) ? "warning" : "accent", word);
39
- const base = `${HEAD} ${word}`;
42
+ const wordC = th.fg(WARN.has(word) ? "warning" : word === "on" ? "success" : "muted", word);
43
+ const base = `${SYM} ${PRODUCT} ${word}`;
40
44
  if (m.vw(base) > width) return m.vw(word) <= width ? [wordC] : [];
41
45
  const room = width - m.vw(base) - 2;
42
46
  const r = reason && room >= 12 ? m.cut(reason, room) : "";
43
- return [`${th.fg("accent", HEAD)} ${wordC}${r ? " " + th.fg("dim", r) : ""}`];
47
+ return [`${brand(th)} ${th.fg("muted", PRODUCT)} ${wordC}${r ? " " + th.fg("dim", r) : ""}`];
44
48
  }
45
49
 
46
50
  // ---- status card ------------------------------------------------------------------------------------------------------
@@ -49,9 +53,9 @@ export interface CardData {
49
53
  state: "on" | "off" | "paused" | "reread-only";
50
54
  stateNote?: string; // why paused / reread-only
51
55
  model?: string; // provider/id
52
- cls?: string; // explicit | automatic
53
- life?: string; // "~5 min (declared)" | "~1 h (learned from 42 returns)"
54
- alive?: number[]; // P(warm) at SPARK_GAPS
56
+ life?: string; // "~5 min" | "~1 h" | "2 h+": how long the cache outlives the last reply
57
+ measured?: boolean; // life comes from this provider's own replies, not the declared TTL
58
+ alive?: number[]; // P(cache still there) after being away chartGap(i), one per chart column
55
59
  folds: number;
56
60
  foldedTokens: string; // "310K"
57
61
  summaries: number;
@@ -59,42 +63,93 @@ export interface CardData {
59
63
  recalls: number;
60
64
  last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
61
65
  quiet: boolean;
66
+ unzip?: string; // how the model gets folded outputs back, when it is not zip_recall (file recall under a tool allowlist)
62
67
  }
63
68
 
64
- /** Gaps (seconds) the "alive" sparkline samples: 30 s to 2 h, denser where caches usually expire. */
65
- export const SPARK_GAPS = [30, 60, 120, 180, 240, 300, 360, 420, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200];
66
- const SPARK = "▁▂▃▄▅▆▇█";
67
- export const sparkline = (ps: number[]): string => ps.map((p) => SPARK[Math.max(0, Math.min(7, Math.round(p * 7)))]).join("");
68
-
69
69
  /** "~5 min", "~1 h", "~2.5 h" */
70
70
  export function fmtLife(s: number): string {
71
71
  if (s < 90) return `~${Math.round(s)} s`;
72
- if (s < 5400) return `~${Math.round(s / 60)} min`;
72
+ if (Math.round(s / 60) < 60) return `~${Math.round(s / 60)} min`;
73
73
  return `~${+(s / 3600).toFixed(1)} h`;
74
74
  }
75
75
 
76
+ /** The cache chart: one column per time away, log-spaced from 30 s to 2 h. The row labels are the axes ("cache" up, "away" across),
77
+ * so it reads like any chart; the bars still there are in the accent colour, the dead ones dim. */
78
+ export const CHART_COLS = 32;
79
+ const CHART_T0 = 30, CHART_T1 = 7200;
80
+ export const chartGap = (i: number): number => CHART_T0 * (CHART_T1 / CHART_T0) ** (i / (CHART_COLS - 1));
81
+ const chartCol = (s: number): number => Math.round(((CHART_COLS - 1) * Math.log(s / CHART_T0)) / Math.log(CHART_T1 / CHART_T0));
82
+ const BARS = "▁▂▃▄▅▆▇█";
83
+ const TICKS: [number, string][] = [[60, "1m"], [300, "5m"], [900, "15m"], [3600, "1h"], [7200, "2h"]];
84
+ const bar = (p: number): string => BARS[Math.max(0, Math.min(7, Math.round(p * 7)))];
85
+ const ALIVE = 0.5; // a bar at or above this is drawn as still there
86
+
87
+ /** "1m 5m 15m 1h 2h": tick labels start at their column. */
88
+ export function chartAxis(): string {
89
+ const s = Array<string>(CHART_COLS + 2).fill(" ");
90
+ for (const [t, lab] of TICKS) {
91
+ const c = Math.min(chartCol(t), CHART_COLS - lab.length + 1);
92
+ if (s.slice(Math.max(0, c - 1), c + lab.length + 1).every((x) => x === " ")) s.splice(c, lab.length, ...lab);
93
+ }
94
+ return s.join("").trimEnd();
95
+ }
96
+
97
+ const lifeText = (c: CardData): string => (c.life ? `expires after ${c.life} idle${c.measured ? " (measured)" : ""}` : "");
98
+
76
99
  export function cardText(c: CardData): string[] {
77
100
  const rows: [string, string][] = [];
78
- if (c.model) rows.push(["cache", [c.model, c.cls ?? "class unknown until the first reply", c.life ? `lives ${c.life}` : ""].filter(Boolean).join(" · ")]);
79
- if (c.alive?.length) rows.push(["alive", `${sparkline(c.alive)} 30 s → 2 h`]);
101
+ // One fact the user can act on ("come back within ~5 min and nothing is touched"), drawn and said: no classes, no jargon.
102
+ if (c.model && c.alive?.length) rows.push(["model", c.model], ["cache", `${c.alive.map(bar).join("")} ${lifeText(c)}`.trimEnd()], ["away", chartAxis()]);
103
+ else if (c.model) rows.push(["cache", `${c.model}${c.life ? ` · ${lifeText(c)}` : ""}`]);
80
104
  const n = (x: number, one: string, many = one + "s") => `${x} ${x === 1 ? one : many}`;
81
- rows.push(["session", `${n(c.folds, "fold")} ~${c.foldedTokens} · ${n(c.summaries, "summary", "summaries")}${c.summaryUsd > 0 ? ` $${c.summaryUsd.toFixed(2)}` : ""} · ${n(c.recalls, "recall")}`]);
105
+ rows.push(["session", `${c.folds} zipped ~${c.foldedTokens} · ${n(c.summaries, "summary", "summaries")}${c.summaryUsd > 0 ? ` $${c.summaryUsd.toFixed(2)}` : ""} · ${n(c.recalls, "unzip")}`]);
82
106
  if (c.last) rows.push(["last", c.last]);
83
- const mode = c.state === "reread-only" ? "reread-only (zip_recall hidden by a tool allowlist)" : "full (zip_recall available)";
84
- rows.push(["mode", `${mode}${c.quiet ? " · notices off" : ""}`]);
107
+ if (c.unzip) rows.push(["unzip", c.unzip]);
108
+ if (c.quiet) rows.push(["notices", "off (/zip quiet turns them back on)"]); // the default says nothing, so it is not shown
85
109
  return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
86
110
  }
87
111
 
88
112
  export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
89
113
  const out = renderState(c.state, c.stateNote ?? "", width, th, m);
90
- const indent = " ";
114
+ const indent = " "; // under the words after the monogram
115
+ // Too narrow for the chart and its axis: the sentence alone says the same thing.
116
+ if (c.alive?.length && width < indent.length + LABEL_W + Math.max(c.alive.length, chartAxis().length)) c = { ...c, alive: undefined };
117
+ let under = "";
91
118
  for (const row of cardText(c)) {
119
+ if (under && !row.startsWith("away ")) out.push(under), (under = "");
120
+ if (c.alive?.length && row.startsWith("cache ")) {
121
+ const runs: string[] = [];
122
+ for (let i = 0; i < c.alive.length; ) {
123
+ const on = c.alive[i] >= ALIVE;
124
+ let j = i;
125
+ while (j < c.alive.length && c.alive[j] >= ALIVE === on) j++;
126
+ runs.push(th.fg(on ? "accent" : "dim", c.alive.slice(i, j).map(bar).join("")));
127
+ i = j;
128
+ }
129
+ // The sentence goes beside the bars when it fits there whole, else on its own line under the axis: never cut mid-fact.
130
+ const say = lifeText(c), beside = m.vw(say) <= width - indent.length - LABEL_W - c.alive.length - 2;
131
+ out.push(th.fg("dim", (indent + "cache").padEnd(indent.length + LABEL_W)) + runs.join("") + (say && beside ? " " + th.fg("dim", say) : ""));
132
+ if (say && !beside) under = th.fg("dim", m.cut(" ".repeat(indent.length + LABEL_W) + say, width));
133
+ continue;
134
+ }
135
+ if (!c.alive?.length && c.model && c.life && row.startsWith("cache ")) {
136
+ // Narrow: keep the fact whole, dropping the model name first, then "(measured)".
137
+ const head = (indent + "cache").padEnd(indent.length + LABEL_W), room = width - m.vw(head);
138
+ const v = [row.slice(LABEL_W), lifeText(c), lifeText({ ...c, measured: false })].find((x) => m.vw(x) <= room);
139
+ if (v) { out.push(th.fg("dim", head + v)); continue; }
140
+ }
141
+ if (row.startsWith("unzip ")) {
142
+ // Narrow: the bracketed reason goes first, so where the files are stays whole.
143
+ const head = (indent + "unzip").padEnd(indent.length + LABEL_W), room = width - m.vw(head);
144
+ const v = [row.slice(LABEL_W), row.slice(LABEL_W).replace(/ \([^()]*\)$/, "")].find((x) => m.vw(x) <= room);
145
+ if (v) { out.push(th.fg("dim", head + v)); continue; }
146
+ }
92
147
  const line = m.cut(indent + row, width);
93
148
  if (!line) continue;
94
- const warn = row.startsWith("mode") && c.state === "reread-only";
95
149
  const k = line.slice(0, indent.length + LABEL_W), v = line.slice(indent.length + LABEL_W);
96
- out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg(warn ? "warning" : "dim", v)));
150
+ out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg("dim", v)));
97
151
  }
152
+ if (under) out.push(under);
98
153
  return out;
99
154
  }
100
155
 
@@ -110,35 +165,42 @@ export interface RecallSectionInfo {
110
165
  missing?: boolean;
111
166
  }
112
167
 
113
- /** "↺ recall k3x9q2m7ab grep "Expected"" */
114
- export function recallCallLine(args: { handle?: string; handles?: string[]; grep?: string; range?: string; offset?: number }, width: number, th: Th, m: Measure = plainMeasure): string[] {
168
+ /** "ƶ unzip src/learn.ts:1-2000 /HALF_LIFE/": the same shape as Pi's own tool rows (bold name, accent argument) after the monogram;
169
+ * the argument says WHAT comes back (the folded output's label), not its handle. `labelOf` maps a handle to that label when known. */
170
+ export function recallCallLine(args: { handle?: string; handles?: string[]; grep?: string; range?: string; offset?: number }, width: number, th: Th, m: Measure = plainMeasure, labelOf: (h: string) => string | undefined = () => undefined): string[] {
115
171
  const hs = [...(typeof args?.handle === "string" ? [args.handle] : []), ...(Array.isArray(args?.handles) ? args.handles.filter((h) => typeof h === "string") : [])];
116
- const what = hs.length > 1 ? `${hs.length} handles` : (hs[0] ?? "");
117
- const opt = [args?.grep ? `grep "${args.grep}"` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
118
- const plain = m.cut(`↺ recall ${what}${opt ? " " + opt : ""}`, width);
119
- const head = "↺ recall";
120
- if (!plain.startsWith(head)) return [th.fg("toolTitle", plain)];
121
- return [th.fg("toolTitle", head) + th.fg("accent", plain.slice(head.length, head.length + 1 + what.length)) + th.fg("dim", plain.slice(head.length + 1 + what.length))];
172
+ const label = (h: string) => (labelOf(h.trim()) ?? h).replace(/^read /, ""); // "read src/a.ts" reads as "unzip src/a.ts"
173
+ const what = hs.length > 1 ? `${hs.length} outputs` : hs[0] ? label(hs[0]) : args?.grep ? "all outputs" : ""; // grep without a handle searches every output
174
+ const opt = [args?.grep ? `/${args.grep}/` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
175
+ const head = `${SYM} unzip`;
176
+ const plain = m.cut(`${head} ${what}${opt ? " " + opt : ""}`, width);
177
+ const bold = (t: string) => (th.bold ? th.bold(t) : t);
178
+ if (!plain.startsWith(head + " ")) return [th.fg("dim", plain)];
179
+ const w = plain.slice(head.length + 1, head.length + 1 + what.length);
180
+ return [brand(th) + " " + th.fg("toolTitle", bold("unzip")) + " " + th.fg("accent", w) + th.fg("dim", plain.slice(head.length + 1 + w.length))];
122
181
  }
123
182
 
124
- export function sectionLine(s: RecallSectionInfo): string {
183
+ export function sectionLine(s: RecallSectionInfo, many = false): string {
125
184
  if (s.missing) return `${s.handle} not found`;
126
- const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
127
185
  const total = s.totalLines ?? 0;
128
186
  const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
129
- return `${where || s.handle} ${got}`;
187
+ return many ? `${s.label || s.handle} ${got}` : got;
130
188
  }
131
189
 
132
- /** Collapsed: one line per handle ("bash npm test · turn 1 3 of 812 lines"). Expanded: the recalled text, at most maxLines. */
190
+ const RECALL_FRAME = /^\[(handle \S+( · |\]| not found)|grep( \(|: | over )|range: lines |chars \d+-\d+ of |… \d+ more chars: )/;
191
+
192
+ /** Collapsed: what came back ("2 of 142 lines"; one line per output for a batch). Expanded: the recalled text, at most maxLines. */
133
193
  export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
134
194
  const out: string[] = [];
135
195
  for (const s of sections) {
136
- const l = m.cut(sectionLine(s), width);
196
+ const l = m.cut(sectionLine(s, sections.length > 1), width);
137
197
  if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
138
198
  }
139
199
  if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
140
200
  if (!expanded || !text) return out;
141
- const lines = clean(text).split("\n");
201
+ // the model's framing lines ("[handle … · 117 lines]", "[grep: 0 matching line(s) of 117]", page notes) repeat the row above: the user sees only the text
202
+ const lines = clean(text).split("\n").filter((l) => !RECALL_FRAME.test(l));
203
+ if (!lines.some((l) => l.trim())) return out;
142
204
  for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
143
205
  if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
144
206
  return out;
@@ -146,13 +208,14 @@ export function recallResultLines(sections: RecallSectionInfo[], text: string, e
146
208
 
147
209
  // ---- the mark on a folded output ---------------------------------------------------------------------------------------
148
210
 
149
- /** Right-aligns "▸ folded · <handle>" on the first line of a tool call row, if it fits; otherwise leaves the row alone. */
211
+ /** "$ npm test ƶ zipped": the mark follows the call row's own text, if it fits; otherwise the row is left alone. */
150
212
  export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
151
213
  if (!lines.length) return lines;
152
- const mark = `${MARK}folded · ${handle}`;
214
+ // the handle is in the notice (click / ctrl+o) and in the model's view; here it would only be noise
153
215
  // renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
154
216
  const first = lines[0].replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
155
- const gap = width - m.vw(first) - m.vw(mark);
156
- if (gap < 2) return lines;
157
- return [first + " ".repeat(gap) + th.fg("dim", mark), ...lines.slice(1)];
217
+ const markW = 2 + m.vw(`${SYM} zipped`);
218
+ if (m.vw(first) + markW > width) return lines;
219
+ const pad = width - m.vw(first) - markW; // keep the row as wide as it was (Pi pads rows to the full width)
220
+ return [first + " " + brand(th) + th.fg("dim", " zipped") + " ".repeat(pad), ...lines.slice(1)];
158
221
  }
package/src/util.ts CHANGED
@@ -3,6 +3,13 @@ export type Any = any;
3
3
 
4
4
  export const PRODUCT = "pi-zip";
5
5
 
6
+ /** Custom session entry pi-zip appends after the edits (context_edit / compaction) it persists at a turn_end: `{ carried }` says whether
7
+ * the request(s) of the run had already sent them (a run plan: persisted after the response that first carried it) or whether the next
8
+ * request is the first to carry them (a warm-valve plan). The edits alone do not say which, and the planner's state (growth, "the
9
+ * newest response carried an edit", the size scale) is read from the branch, so a restart plans like a process that never left.
10
+ * Custom entries never reach the model. */
11
+ export const COMMIT_CUSTOM = "pi-zip/commit";
12
+
6
13
  export const textOf = (c: Any): string =>
7
14
  typeof c === "string" ? c : Array.isArray(c) ? c.filter((b: Any) => b?.type === "text").map((b: Any) => b.text ?? "").join("\n") : "";
8
15
 
@@ -25,17 +32,65 @@ const IMAGE_CHARS = 4800;
25
32
  const contentChars = (c: Any): number =>
26
33
  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
34
 
28
- /** chars/4 estimate, same rules as Pi's estimateTokens (kept local so plan.ts has no runtime Pi dependency). */
35
+ /** Pi's own estimateTokens (core/compaction/compaction.js), rule for rule: no tool framing, system messages counted. Used only to predict
36
+ * what Pi's compaction will decide (plan.ts piCanCompact); every pi-zip size uses tokensOf. */
37
+ export function piTokensOf(m: Any): number {
38
+ let chars = 0;
39
+ switch (m?.role) {
40
+ case "system":
41
+ chars = contentChars(m.content);
42
+ for (const s of Object.values(m.sections ?? {})) if (typeof s === "string" && s) chars += s.length;
43
+ if (m.toolsAdded) chars += JSON.stringify(m.toolsAdded).length;
44
+ break;
45
+ case "user":
46
+ case "custom":
47
+ case "toolResult":
48
+ chars = contentChars(m.content);
49
+ break;
50
+ case "assistant":
51
+ for (const b of m.content ?? []) {
52
+ if (b?.type === "text") chars += b.text?.length ?? 0;
53
+ else if (b?.type === "thinking") chars += b.thinking?.length ?? 0;
54
+ else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + (JSON.stringify(b.arguments ?? {}) ?? "").length;
55
+ }
56
+ break;
57
+ case "bashExecution":
58
+ chars = (m.command?.length ?? 0) + (m.output?.length ?? 0);
59
+ break;
60
+ case "branchSummary":
61
+ case "compactionSummary":
62
+ chars = m.summary?.length ?? 0;
63
+ break;
64
+ default:
65
+ return 0;
66
+ }
67
+ return Math.ceil(chars / 4);
68
+ }
69
+
70
+ /** What every tool call puts on the wire that Pi's chars/4 rule does not see, in chars: the call id (in the call and again in its
71
+ * result) and this much framing per block (the provider's tool-use / tool-result markup). It does not shrink when the output is
72
+ * folded, so without it the size after a deep fold came out low: Tier1 fold plans that removed over half the content, median
73
+ * -9.5% / -7.0% / -10.9% (Claude / GLM / GPT) -> -0.2% / -2.7% / -2.0% with 120 (research round5/affine.md section 8). */
74
+ export const TOOL_FRAME_CHARS = 120;
75
+
76
+ /** chars/4 estimate of a message's CONTENT as the wire carries it: Pi's estimateTokens rules (kept local so plan.ts has no runtime
77
+ * Pi dependency) plus each tool block's id and framing (TOOL_FRAME_CHARS). A system message counts 0: the system prompt and the tools
78
+ * are the fixed prefix O of plan.ts, never content. */
29
79
  export function tokensOf(m: Any): number {
30
80
  let chars = 0;
31
81
  switch (m?.role) {
82
+ case "system":
83
+ return 0;
32
84
  case "assistant":
33
85
  for (const b of m.content ?? []) {
34
86
  if (b?.type === "text") chars += b.text?.length ?? 0;
35
87
  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;
88
+ else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + JSON.stringify(b.arguments ?? {}).length + String(b.id ?? "").length + TOOL_FRAME_CHARS;
37
89
  }
38
90
  break;
91
+ case "toolResult":
92
+ chars = contentChars(m.content) + String(m.toolCallId ?? "").length + TOOL_FRAME_CHARS;
93
+ break;
39
94
  case "bashExecution":
40
95
  chars = (m.command?.length ?? 0) + (m.output?.length ?? 0);
41
96
  break;