pi-zip 0.2.8 → 0.3.0-rc.2

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,25 +37,31 @@ 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 ------------------------------------------------------------------------------------------------------
47
51
 
52
+ /** One learned gap bin of the cache chart: how many times you came back after a gap in [lo, hi) s and the cache was still there / gone.
53
+ * Counts are rounded decayed weights (learn.ts keeps no raw per-bin count: old evidence halves after HALF_LIFE newer ones of the bin). */
54
+ export interface ChartBin { lo: number; hi: number; alive: number; gone: number }
55
+
48
56
  export interface CardData {
49
57
  state: "on" | "off" | "paused" | "reread-only";
50
58
  stateNote?: string; // why paused / reread-only
51
59
  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
60
+ life?: string; // "~5 min" | "~1 h" | "2 h+": how long the cache outlives the last reply (the declared TTL until something is learned)
61
+ bins?: ChartBin[]; // learned bins that round to at least one return; none = nothing learned yet: one line, no chart
62
+ s1?: number; // seconds: the cache is (nearly) always still there under this (learned curve, never the prior)
63
+ s2?: number; // seconds: it is mostly gone from here on; between s1 and s2 it is not sure yet
64
+ usually?: boolean; // some bin under s1 has P(still there) < 0.95: "usually still there" instead of "still there"
55
65
  folds: number;
56
66
  foldedTokens: string; // "310K"
57
67
  summaries: number;
@@ -59,41 +69,191 @@ export interface CardData {
59
69
  recalls: number;
60
70
  last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
61
71
  quiet: boolean;
72
+ unzip?: string; // how the model gets folded outputs back, when it is not zip_recall (file recall under a tool allowlist)
62
73
  }
63
74
 
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
75
  /** "~5 min", "~1 h", "~2.5 h" */
70
76
  export function fmtLife(s: number): string {
71
77
  if (s < 90) return `~${Math.round(s)} s`;
72
- if (s < 5400) return `~${Math.round(s / 60)} min`;
78
+ if (Math.round(s / 60) < 60) return `~${Math.round(s / 60)} min`;
73
79
  return `~${+(s / 3600).toFixed(1)} h`;
74
80
  }
75
81
 
82
+ // ---- the cache chart: one dot per time you came back ----------------------------------------------------------------------
83
+ // Across: how long you were away, 30 s to 2 h on a log scale. Above the axis (accent): the times the cache was still there, below
84
+ // (dim): the times it was gone. The axis itself says what to expect: solid while it is (nearly) always there, dashed while not sure,
85
+ // thin after it is gone. A column shows at most STACK_CAP marks; beyond that STACK_CAP - 1 dots and then the count.
86
+
87
+ export const CHART_W = 60; // columns 0..CHART_W
88
+ const CT0 = 30, CT1 = 7200;
89
+ export const STACK_CAP = 4;
90
+ const IND = 11; // the values column of the card: " " + the 9-wide label
91
+ export const colW = (s: number): number => Math.round((CHART_W * Math.log(s / CT0)) / Math.log(CT1 / CT0));
92
+ const gapW = (c: number): number => CT0 * (CT1 / CT0) ** (c / CHART_W);
93
+ const AXIS_TICKS: [number, string][] = [[30, "30s"], [60, "1m"], [120, "2m"], [300, "5m"], [600, "10m"], [1800, "30m"], [3600, "1h"], [7200, "2h"]];
94
+ const fmtGap = (s: number): string => (s < 60 ? `${Math.round(s)} s` : s < 3600 ? `${+(s / 60).toFixed(1)} min` : `${+(s / 3600).toFixed(1)} h`);
95
+
96
+ /** What the marks of one side of the axis look like, as rows from the axis outwards: row r (1-based) holds, per column, a dot, a digit of
97
+ * the count or a blank. `items` are (column, times); items on the same column merge. A count is drawn on the row of the stack's end,
98
+ * centred on its column; when that would touch another mark in its row (neighbouring columns, other counts) it moves, the nearest free
99
+ * place first, right before left, and keeps one blank on each side, so two counts never read as one number. Deterministic. */
100
+ export function stackRows(items: { col: number; n: number }[], dot: string = "\u25cf", cap: number = STACK_CAP, width: number = CHART_W + 1): { rows: string[][]; height: number } {
101
+ const merged = new Map<number, number>();
102
+ for (const it of items) {
103
+ const col = Math.max(0, Math.min(width - 1, it.col));
104
+ if (it.n > 0) merged.set(col, (merged.get(col) ?? 0) + it.n);
105
+ }
106
+ const cols = [...merged.entries()].sort((a, b) => a[0] - b[0]);
107
+ const height = Math.max(1, ...cols.map(([, n]) => Math.min(n, cap)));
108
+ const rows = Array.from({ length: height }, () => Array<string>(width).fill(" "));
109
+ const counts: { col: number; text: string; row: number }[] = [];
110
+ for (const [col, n] of cols) {
111
+ const dots = n <= cap ? n : cap - 1;
112
+ for (let r = 0; r < dots; r++) rows[r][col] = dot;
113
+ if (n > cap) counts.push({ col, text: String(n), row: cap - 1 });
114
+ }
115
+ for (const { col, text, row } of counts) {
116
+ const len = text.length, want = col - Math.floor((len - 1) / 2), line = rows[row];
117
+ const free = (st: number, gap: boolean) => {
118
+ if (st < 0 || st + len > width) return false;
119
+ for (let x = st - (gap ? 1 : 0); x < st + len + (gap ? 1 : 0); x++) if (x >= 0 && x < width && line[x] !== " ") return false;
120
+ return true;
121
+ };
122
+ let at = -1;
123
+ for (const gap of [true, false]) {
124
+ for (let d = 0; d < width && at < 0; d++) for (const st of d ? [want + d, want - d] : [want]) if (at < 0 && free(st, gap)) at = st;
125
+ if (at >= 0) break;
126
+ }
127
+ if (at >= 0) for (let k = 0; k < len; k++) line[at + k] = text[k];
128
+ }
129
+ return { rows, height };
130
+ }
131
+
132
+ /** Colours the cells of a row (one string per column, null = blank) with as few runs as possible. */
133
+ function paint(cells: { ch: string; color: string }[], th: Th): string {
134
+ let out = "", run = "", color = "";
135
+ const flush = () => { if (run) out += color ? th.fg(color, run) : run; run = ""; };
136
+ for (const c of cells) {
137
+ if (c.color !== color) flush(), (color = c.color);
138
+ run += c.ch;
139
+ }
140
+ flush();
141
+ return out.trimEnd();
142
+ }
143
+
144
+ interface Seg { lead: string; word: string; color: string }
145
+ /** "under 5 min: usually still there · 5–10 min: not sure yet · over 10 min: gone", as segments that wrap whole. */
146
+ function captionSegs(c: CardData): Seg[] {
147
+ const s1 = c.s1 ?? CT0, s2 = c.s2 ?? CT1;
148
+ const range = s1 >= 60 && s2 < 3600 ? `${+(s1 / 60).toFixed(1)}–${fmtGap(s2)}` : `${fmtGap(s1)}–${fmtGap(s2)}`; // "5–10 min"
149
+ return [
150
+ { lead: `under ${fmtGap(s1)}: `, word: c.usually ? "usually still there" : "still there", color: "accent" },
151
+ ...(s2 > s1 ? [{ lead: `${range}: `, word: "not sure yet", color: "warning" }] : []),
152
+ { lead: `over ${fmtGap(s2)}: `, word: "gone", color: "muted" },
153
+ ];
154
+ }
155
+ const SEP = " \u00b7 ";
156
+ const segW = (g: Seg) => g.lead.length + g.word.length;
157
+ /** The caption as lines of at most `room` columns, wrapped between segments (a segment wider than the room is cut). */
158
+ function captionLines(segs: Seg[], room: number, th: Th, m: Measure): string[] {
159
+ if (room < 2) return []; // no room for even a cut sentence
160
+ const lines: Seg[][] = [[]];
161
+ for (const g of segs) {
162
+ const cur = lines[lines.length - 1], w = cur.reduce((a, x) => a + segW(x), 0) + SEP.length * cur.length + segW(g);
163
+ if (cur.length && w > room) lines.push([g]);
164
+ else cur.push(g);
165
+ }
166
+ return lines.map((l) => {
167
+ const plain = l.map((g) => g.lead + g.word).join(SEP);
168
+ if (plain.length > room) return th.fg("dim", m.cut(plain, room));
169
+ return l.map((g) => th.fg("dim", g.lead) + th.fg(g.color, g.word)).join(th.fg("dim", SEP));
170
+ });
171
+ }
172
+ const captionPlain = (c: CardData) => captionSegs(c).map((g) => g.lead + g.word).join(SEP);
173
+
174
+ const idle = (c: CardData): string => (c.life ? `expires after ${c.life} idle` : "");
175
+ const NO_DATA = "a chart appears once you come back after a break";
176
+ const hasChart = (c: CardData): boolean => !!c.model && !!c.bins?.length;
177
+
76
178
  export function cardText(c: CardData): string[] {
77
179
  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`]);
180
+ if (c.model) rows.push(["model", c.model]);
181
+ if (c.model) rows.push(["cache", hasChart(c) ? `each dot is one time you came back · ${captionPlain(c)}` : `${idle(c) || "expires after ~5 min idle"} · ${NO_DATA}`]);
80
182
  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")}`]);
183
+ 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
184
  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" : ""}`]);
185
+ if (c.unzip) rows.push(["unzip", c.unzip]);
186
+ if (c.quiet) rows.push(["notices", "off (/zip quiet turns them back on)"]); // the default says nothing, so it is not shown
85
187
  return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
86
188
  }
87
189
 
190
+ /** The chart rows (without the model row): header, marks above, axis, marks below, axis labels, [legend], caption. */
191
+ function chartRows(c: CardData, width: number, th: Th, m: Measure): string[] {
192
+ const indent = " ", label = (k: string) => th.fg("dim", (indent + k).padEnd(indent.length + LABEL_W)), pad = " ".repeat(IND);
193
+ const bins = c.bins ?? [];
194
+ const na = bins.reduce((a, b) => a + b.alive, 0), nd = bins.reduce((a, b) => a + b.gone, 0);
195
+ const up = stackRows(bins.map((b) => ({ col: colW(Math.sqrt(b.lo * b.hi)), n: b.alive })));
196
+ const down = stackRows(bins.map((b) => ({ col: colW(Math.sqrt(b.lo * b.hi)), n: b.gone })), "\u25cb");
197
+ const legUp = ["\u25cf", ` still there ${na}`], legDown = ["\u25cb", ` gone ${nd}`];
198
+ const legW = (l: string[]) => l[0].length + l[1].length;
199
+ const beside = width >= IND + CHART_W + 1 + 3 + Math.max(legW(legUp), legW(legDown));
200
+ const s1 = c.s1 ?? CT0, s2 = c.s2 ?? CT1;
201
+ const out: string[] = [label("cache") + th.fg("dim", "each dot is one time you came back")];
202
+ const side = (rows: string[][], color: string, leg: string[], first: number) =>
203
+ rows.map((cells, r) => {
204
+ const line = paint(cells.map((ch) => ({ ch, color: ch === " " ? "" : color })), th);
205
+ return pad + line + (beside && r === first ? " " + th.fg(color, leg[0]) + th.fg("muted", leg[1]) : "");
206
+ });
207
+ out.push(...side(up.rows.slice().reverse(), "accent", legUp, up.height - 1)); // outermost row first; the legend sits on the row next to the axis
208
+ out.push(pad + paint(Array.from({ length: CHART_W + 1 }, (_, i) => ({ ch: gapW(i) < s1 ? "\u2501" : gapW(i) < s2 ? "\u2505" : "\u2500", color: gapW(i) < s1 ? "accent" : gapW(i) < s2 ? "warning" : "dim" })), th));
209
+ out.push(...side(down.rows, "dim", legDown, 0));
210
+ const ax = Array<string>(CHART_W + 3).fill(" ");
211
+ for (const [t, lab] of AXIS_TICKS) {
212
+ const col = Math.min(colW(t), CHART_W - lab.length + 1);
213
+ for (let k = 0; k < lab.length; k++) ax[col + k] = lab[k];
214
+ }
215
+ out.push(label("away") + th.fg("dim", ax.join("").trimEnd()));
216
+ if (!beside) out.push(pad + th.fg("accent", legUp[0]) + th.fg("muted", legUp[1]) + " " + th.fg("dim", legDown[0]) + th.fg("muted", legDown[1]));
217
+ for (const l of captionLines(captionSegs(c), width - IND, th, m)) out.push(pad + l);
218
+ return out;
219
+ }
220
+
88
221
  export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
89
222
  const out = renderState(c.state, c.stateNote ?? "", width, th, m);
90
- const indent = " ";
91
- for (const row of cardText(c)) {
92
- const line = m.cut(indent + row, width);
223
+ const indent = " "; // under the words after the monogram
224
+ const head = (k: string) => th.fg("dim", (indent + k).padEnd(indent.length + LABEL_W));
225
+ const row = (k: string, v: string) => {
226
+ const line = m.cut(indent + k.padEnd(LABEL_W) + v, width);
227
+ if (!line) return;
228
+ const kk = line.slice(0, indent.length + LABEL_W);
229
+ out.push(th.fg("dim", kk) + th.fg("dim", line.slice(kk.length)));
230
+ };
231
+ if (c.model) {
232
+ row("model", c.model);
233
+ if (!hasChart(c)) {
234
+ // nothing learned yet: one line, the chart appears with the first return after a break
235
+ const room = width - indent.length - LABEL_W;
236
+ const v = [`${idle(c) || "expires after ~5 min idle"} \u00b7 ${NO_DATA}`, idle(c) || "expires after ~5 min idle"].find((x) => m.vw(x) <= room);
237
+ if (v) out.push(head("cache") + th.fg("dim", v));
238
+ else row("cache", idle(c) || "expires after ~5 min idle");
239
+ } else if (width >= IND + CHART_W + 1) out.push(...chartRows(c, width, th, m));
240
+ else {
241
+ // too narrow for the chart: the caption sentence alone says the same thing
242
+ const lines = captionLines(captionSegs(c), width - IND, th, m);
243
+ lines.forEach((l, i) => out.push((i ? " ".repeat(IND) : head("cache")) + l));
244
+ }
245
+ }
246
+ for (const r of cardText({ ...c, model: undefined })) {
247
+ if (r.startsWith("unzip ")) {
248
+ // Narrow: the bracketed reason goes first, so where the files are stays whole.
249
+ const h = head("unzip"), room = width - m.vw(h);
250
+ const v = [r.slice(LABEL_W), r.slice(LABEL_W).replace(/ \([^()]*\)$/, "")].find((x) => m.vw(x) <= room);
251
+ if (v) { out.push(th.fg("dim", h + v)); continue; }
252
+ }
253
+ const line = m.cut(indent + r, width);
93
254
  if (!line) continue;
94
- const warn = row.startsWith("mode") && c.state === "reread-only";
95
255
  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)));
256
+ out.push(th.fg("dim", k) + (r.startsWith("session") ? th.fg("text", v) : th.fg("dim", v)));
97
257
  }
98
258
  return out;
99
259
  }
@@ -110,35 +270,42 @@ export interface RecallSectionInfo {
110
270
  missing?: boolean;
111
271
  }
112
272
 
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[] {
273
+ /** "ƶ unzip src/learn.ts:1-2000 /HALF_LIFE/": the same shape as Pi's own tool rows (bold name, accent argument) after the monogram;
274
+ * the argument says WHAT comes back (the folded output's label), not its handle. `labelOf` maps a handle to that label when known. */
275
+ 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
276
  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))];
277
+ const label = (h: string) => (labelOf(h.trim()) ?? h).replace(/^read /, ""); // "read src/a.ts" reads as "unzip src/a.ts"
278
+ const what = hs.length > 1 ? `${hs.length} outputs` : hs[0] ? label(hs[0]) : args?.grep ? "all outputs" : ""; // grep without a handle searches every output
279
+ const opt = [args?.grep ? `/${args.grep}/` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
280
+ const head = `${SYM} unzip`;
281
+ const plain = m.cut(`${head} ${what}${opt ? " " + opt : ""}`, width);
282
+ const bold = (t: string) => (th.bold ? th.bold(t) : t);
283
+ if (!plain.startsWith(head + " ")) return [th.fg("dim", plain)];
284
+ const w = plain.slice(head.length + 1, head.length + 1 + what.length);
285
+ return [brand(th) + " " + th.fg("toolTitle", bold("unzip")) + " " + th.fg("accent", w) + th.fg("dim", plain.slice(head.length + 1 + w.length))];
122
286
  }
123
287
 
124
- export function sectionLine(s: RecallSectionInfo): string {
288
+ export function sectionLine(s: RecallSectionInfo, many = false): string {
125
289
  if (s.missing) return `${s.handle} not found`;
126
- const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
127
290
  const total = s.totalLines ?? 0;
128
291
  const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
129
- return `${where || s.handle} ${got}`;
292
+ return many ? `${s.label || s.handle} ${got}` : got;
130
293
  }
131
294
 
132
- /** Collapsed: one line per handle ("bash npm test · turn 1 3 of 812 lines"). Expanded: the recalled text, at most maxLines. */
295
+ const RECALL_FRAME = /^\[(handle \S+( · |\]| not found)|grep( \(|: | over )|range: lines |chars \d+-\d+ of |… \d+ more chars: )/;
296
+
297
+ /** Collapsed: what came back ("2 of 142 lines"; one line per output for a batch). Expanded: the recalled text, at most maxLines. */
133
298
  export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
134
299
  const out: string[] = [];
135
300
  for (const s of sections) {
136
- const l = m.cut(sectionLine(s), width);
301
+ const l = m.cut(sectionLine(s, sections.length > 1), width);
137
302
  if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
138
303
  }
139
304
  if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
140
305
  if (!expanded || !text) return out;
141
- const lines = clean(text).split("\n");
306
+ // 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
307
+ const lines = clean(text).split("\n").filter((l) => !RECALL_FRAME.test(l));
308
+ if (!lines.some((l) => l.trim())) return out;
142
309
  for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
143
310
  if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
144
311
  return out;
@@ -146,13 +313,14 @@ export function recallResultLines(sections: RecallSectionInfo[], text: string, e
146
313
 
147
314
  // ---- the mark on a folded output ---------------------------------------------------------------------------------------
148
315
 
149
- /** Right-aligns "▸ folded · <handle>" on the first line of a tool call row, if it fits; otherwise leaves the row alone. */
316
+ /** "$ npm test ƶ zipped": the mark follows the call row's own text, if it fits; otherwise the row is left alone. */
150
317
  export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
151
318
  if (!lines.length) return lines;
152
- const mark = `${MARK}folded · ${handle}`;
319
+ // the handle is in the notice (click / ctrl+o) and in the model's view; here it would only be noise
153
320
  // renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
154
321
  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)];
322
+ const markW = 2 + m.vw(`${SYM} zipped`);
323
+ if (m.vw(first) + markW > width) return lines;
324
+ const pad = width - m.vw(first) - markW; // keep the row as wide as it was (Pi pads rows to the full width)
325
+ return [first + " " + brand(th) + th.fg("dim", " zipped") + " ".repeat(pad), ...lines.slice(1)];
158
326
  }
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;
@@ -48,3 +103,8 @@ export function tokensOf(m: Any): number {
48
103
  }
49
104
  return Math.ceil(chars / 4);
50
105
  }
106
+
107
+ /** Custom session entry pi-zip appends right before the prompt of a cold return. A warm plan holds back what that return protected
108
+ * (plan.ts FOLD_HOLD); the entry makes the return a fact of the session, so a restart reads it instead of guessing from timestamps at
109
+ * the TTL's edge. Custom entries never reach the model. */
110
+ export const COLD_CUSTOM = "pi-zip/cold";