pi-zip 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -34,14 +34,25 @@ Known limits:
34
34
 
35
35
  ## What you see
36
36
 
37
- At most one line per turn, only when something was folded:
37
+ At most one line per turn in the transcript, only when the context was folded or summarized. The numbers come first, and the bar shows how much is left:
38
38
 
39
39
  ```
40
- pi-zip · folded 12 old outputs · 84.2K → 31.5K tokens · 0.9 ms · originals recallable
41
- pi-zip · summarized 64 requests · 182K → 41K tokens · 8.4 s (done while you were away)
40
+ ▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
41
+ originals are kept; the model can recall any of them with zip_recall
42
+ ▸ pi-zip 182K → 41K ▰▰▱▱▱▱▱▱▱▱ summarized 64 requests · ready while you were away
42
43
  ```
43
44
 
44
- The line goes to the UI only; it never enters the model's context. Nothing else is added to the screen.
45
+ The second line appears once per session. Expand tool output (ctrl+o) to see why it happened now and what was folded:
46
+
47
+ ```
48
+ ▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
49
+ cache cold (away 47 min): this request rewrites it anyway, so editing is free
50
+ bash npm test turn 1 14K k3x9q2m7ab
51
+ read src/payment.ts turn 1 9K p8d2x1qa0m
52
+ … 10 more
53
+ ```
54
+
55
+ These lines are saved in the session, so they are still there after a restart, but they are never sent to the model. On narrow terminals the words go first, then the bar; the numbers always stay. A summary still shows up as Pi's own `[compaction]` block as well; the pi-zip line next to it tells you who made it. Recalls show up as ordinary `zip_recall` tool calls.
45
56
 
46
57
  ## The three rules
47
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zip",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Keeps long Pi sessions cheap without losing anything: folds old tool output only when the prompt cache has already gone cold, and every fold can be recalled byte for byte. Zero config.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,15 +1,42 @@
1
1
  // pi-zip: keeps long Pi sessions cheap without losing anything. Wiring only; the logic lives in the sibling modules.
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { registerZipCommand } from "./notice.ts";
3
+ import type { Any } from "./util.ts";
4
+ import { type NoticeData, registerZipCommand, renderNotice } from "./notice.ts";
4
5
  import { RECALL_TOOL } from "./placeholder.ts";
5
6
  import { registerRecallTool } from "./recall.ts";
6
- import { Zip } from "./run.ts";
7
+ import { NOTICE_CUSTOM, Zip } from "./run.ts";
7
8
 
8
9
  export default function piZip(pi: ExtensionAPI) {
9
10
  if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
10
11
  const zip = new Zip(pi);
11
12
  registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
12
13
  registerZipCommand(pi, zip);
14
+ try {
15
+ // fold/summary notices stay in the transcript as one dim line (a custom entry: never part of the model's context)
16
+ let vw: (s: string) => number = (s) => s.length;
17
+ import("@earendil-works/pi-tui").then((m: Any) => {
18
+ if (typeof m?.visibleWidth === "function") vw = m.visibleWidth;
19
+ }, () => {});
20
+ pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
21
+ const d = entry?.data;
22
+ if (!d?.text) return undefined;
23
+ const data: NoticeData = d.v === 2 ? d : { v: 2, text: d.text, before: 0, after: 0, desc: "" };
24
+ return {
25
+ render: (width: number) => {
26
+ if (d.v !== 2) return [theme.fg("dim", vw(d.text) > width ? d.text.slice(0, Math.max(0, width - 1)) + "…" : d.text)];
27
+ try {
28
+ return renderNotice(data, !!o?.expanded, width, theme, vw);
29
+ } catch {
30
+ return [];
31
+ }
32
+ },
33
+ invalidate: () => {},
34
+ };
35
+ });
36
+ zip.entryRenderer = typeof pi.registerEntryRenderer === "function";
37
+ } catch {
38
+ /* older Pi: notices fall back to the status line */
39
+ }
13
40
  // A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
14
41
  // zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
15
42
  const checkRecall = () => {
package/src/notice.ts CHANGED
@@ -12,19 +12,90 @@ export interface NoticeAction {
12
12
  pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
13
13
  }
14
14
 
15
+ /** One folded output, for the expanded (ctrl+o) view of a notice. */
16
+ export interface NoticeItem {
17
+ label: string; // tool + short args
18
+ turn?: number;
19
+ tokens: number;
20
+ handle: string;
21
+ }
22
+
23
+ /** What a transcript notice stores (custom entry data; never sent to the model). `text` is the plain one-line form (status-line fallback, ledger). */
24
+ export interface NoticeData {
25
+ v: 2;
26
+ text: string;
27
+ before: number;
28
+ after: number;
29
+ desc: string; // "folded 12 old outputs · summarized 64 requests · ready while you were away"
30
+ why?: string; // expanded view: why now
31
+ items?: NoticeItem[];
32
+ more?: number; // folded outputs not listed
33
+ first?: boolean; // the session's first notice: say once that originals are kept
34
+ }
35
+
15
36
  export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
16
37
 
38
+ const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
39
+ const secs = (ms: number) => `${(Math.round(ms / 100) / 10).toFixed(1)} s`;
40
+
41
+ /** The words after the numbers: what happened, plus how long the user waited for a summary (if at all). */
42
+ export function noticeDesc(actions: NoticeAction[]): string {
43
+ return actions
44
+ .map((a) => {
45
+ if (a.kind === "fold") return `folded ${plural(a.count, "old output")}`;
46
+ return `summarized ${plural(a.count, "request")} · ${a.prepared ? "ready while you were away" : `waited ${secs(a.ms)}`}`;
47
+ })
48
+ .join(" · ");
49
+ }
50
+
17
51
  export function noticeText(actions: NoticeAction[]): string {
18
- const segs = actions.map((a) => {
19
- const sizes = `${fmtK(a.tokensBefore)} → ${fmtK(a.tokensAfter)} tokens`;
20
- if (a.kind === "fold") {
21
- const ms = a.ms < 10 ? (Math.round(a.ms * 10) / 10).toFixed(1) : String(Math.round(a.ms));
22
- return `folded ${a.count} old output${a.count === 1 ? "" : "s"} · ${sizes} · ${ms} ms · originals recallable${a.pressure ? " · context over the warm-cache limit" : ""}`;
23
- }
24
- const s = (Math.round(a.ms / 100) / 10).toFixed(1);
25
- return `summarized ${a.count} request${a.count === 1 ? "" : "s"} · ${sizes} · ${a.prepared ? `${s} s (done while you were away)` : `waited ${s} s`}`;
26
- });
27
- return `${PRODUCT} · ${segs.join(" · ")}`;
52
+ const first = actions[0], last = actions[actions.length - 1];
53
+ return `${PRODUCT} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)} ${noticeDesc(actions)}`;
54
+ }
55
+
56
+ /** Ten cells, filled in proportion to what is left: length is read before any digit is. */
57
+ export function ratioBar(before: number, after: number, cells = 10): string {
58
+ const f = before > 0 ? Math.min(cells, Math.max(1, Math.round((cells * after) / before))) : cells;
59
+ return "▰".repeat(f) + "▱".repeat(cells - f);
60
+ }
61
+
62
+ export interface NoticeTheme {
63
+ fg(color: string, text: string): string;
64
+ }
65
+
66
+ const MARK = "▸ ";
67
+ const INDENT = " ".repeat(MARK.length + PRODUCT.length + 2);
68
+
69
+ /** Lines for the transcript. Narrow terminals drop whole segments in order (description, then bar); the numbers always stay.
70
+ * `w` measures display width (Pi's visibleWidth in the renderer; string length in tests). Every line fits `width`. */
71
+ export function renderNotice(d: NoticeData, expanded: boolean, width: number, th: NoticeTheme, w: (s: string) => number = (s) => s.length): string[] {
72
+ const nums = `${fmtK(d.before)} → ${fmtK(d.after)}`;
73
+ const bar = ratioBar(d.before, d.after);
74
+ const tries: [string, string][] = [
75
+ [`${MARK}${PRODUCT} ${nums} ${bar} ${d.desc}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)} ${th.fg("dim", d.desc)}`],
76
+ [`${MARK}${PRODUCT} ${nums} ${bar}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)}`],
77
+ [`${MARK}${PRODUCT} ${nums}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)}`],
78
+ [nums, th.fg("text", nums)],
79
+ ];
80
+ const head = tries.find(([plain]) => w(plain) <= width);
81
+ if (!head) return [];
82
+ const out = [head[1]];
83
+ const sub = (plain: string) => {
84
+ const line = INDENT + plain;
85
+ if (w(line) <= width) out.push(th.fg("dim", line));
86
+ else if (w(plain) <= width) out.push(th.fg("dim", plain));
87
+ };
88
+ if (d.first) sub("originals are kept; the model can recall any of them with zip_recall");
89
+ if (!expanded) return out;
90
+ if (d.why) sub(d.why);
91
+ const items = d.items ?? [];
92
+ const lw = Math.min(28, Math.max(0, ...items.map((i) => i.label.length)));
93
+ for (const i of items) {
94
+ const label = i.label.length > lw ? i.label.slice(0, lw - 1) + "…" : i.label.padEnd(lw);
95
+ sub(`${label} ${(i.turn ? `turn ${i.turn}` : "").padEnd(8)}${fmtK(i.tokens).padStart(6)} ${i.handle}`);
96
+ }
97
+ if (d.more) sub(`… ${d.more} more`);
98
+ return out;
28
99
  }
29
100
 
30
101
  export class Stats {
package/src/run.ts CHANGED
@@ -6,7 +6,7 @@ import { dirname } from "node:path";
6
6
  import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
7
7
  import { describe, lawPrices, loadStats, pWarm, record, sample, GAP_EDGES, type Entry } from "./learn.ts";
8
8
  import { validateEdits, repairPayload } from "./guard.ts";
9
- import { fmtK, Stats, noticeText, type NoticeAction, type ZipControl } from "./notice.ts";
9
+ import { fmtK, Stats, noticeDesc, noticeText, type NoticeAction, type NoticeData, type ZipControl } from "./notice.ts";
10
10
  import { applyPlanToMessages, buildBlocks, calibrate, countUserTurns, G0, planContext, reserveTokensFor, untouchedEst, type Block, type Calibration, type Cut, type FoldTarget, type Law, type PlanOpts, type PlanResult, type RunPlan } from "./plan.ts";
11
11
  import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
12
12
  import { recalledHandlesFromBranch } from "./recall.ts";
@@ -16,6 +16,8 @@ import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
16
16
  export const PLAN_CUSTOM = "pi-zip/plan";
17
17
  export const STATE_CUSTOM = "pi-zip/state";
18
18
  export const STEER_CUSTOM = "pi-zip/steer";
19
+ const fmtAway = (s: number) => (s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(Math.round(s / 360) / 10).toFixed(1)} h`);
20
+ export const NOTICE_CUSTOM = "pi-zip/notice"; // a fold/summary notice kept in the transcript (rendered by index.ts, never sent to the model)
19
21
  export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
20
22
  export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
21
23
  // Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
@@ -95,6 +97,10 @@ export class Zip implements ZipControl {
95
97
 
96
98
  /** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
97
99
  recallOk = true;
100
+ /** index.ts registered the transcript renderer for NOTICE_CUSTOM (Pi versions without registerEntryRenderer fall back to a status line). */
101
+ entryRenderer = false;
102
+ private coldWhy = ""; // the expanded notice's "why now" for this run
103
+ private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
98
104
  /** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
99
105
  summarizedWarm = false;
100
106
  setRecallOk(ok: boolean) {
@@ -111,9 +117,19 @@ export class Zip implements ZipControl {
111
117
  } catch {}
112
118
  }
113
119
 
114
- private notify(ctx: Any, text: string) {
120
+ /** keep = a fold/summary notice: written to the session as a custom entry, so it stays in the transcript (also after a restart)
121
+ * next to Pi's own "[compaction]" block instead of a status line the next status overwrites. Custom entries never reach the model. */
122
+ private notify(ctx: Any, text: string, keep?: NoticeData) {
115
123
  this.stats.notices++;
116
124
  this.ledger({ type: "notice", text });
125
+ if (keep && ctx?.mode === "tui" && this.entryRenderer) {
126
+ setTimeout(() => { // after Pi has appended this turn's edits
127
+ try {
128
+ this.pi.appendEntry(NOTICE_CUSTOM, keep);
129
+ } catch {}
130
+ }, 0);
131
+ return;
132
+ }
117
133
  if (ctx?.ui?.notify && (ctx.mode === "tui" || ctx.mode === "rpc")) {
118
134
  try {
119
135
  ctx.ui.notify(text, "info");
@@ -243,6 +259,7 @@ export class Zip implements ZipControl {
243
259
  this.survSrc = src;
244
260
  this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
245
261
  this.coldReason = reason;
262
+ this.coldWhy = src === "model switch" ? "model switched: its cache starts empty, so this request rewrites it anyway" : cold && gapS !== null ? `cache cold (away ${fmtAway(gapS)}): this request rewrites it anyway, so editing is free` : "";
246
263
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
247
264
  for (let i = branch.length - 1; i >= 0; i--) {
248
265
  const en = branch[i];
@@ -565,7 +582,14 @@ export class Zip implements ZipControl {
565
582
  const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
566
583
  notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: ctxTokens - (before - after) - cutSaved, ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
567
584
  }
568
- if (notices.length && !this.quiet) this.notify(ctx, noticeText(notices));
585
+ if (notices.length && !this.quiet) {
586
+ const ITEMS = 8;
587
+ const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, turn: byId.get(t.entryId)?.userTurn, tokens: Math.round(k * t.entryTokens), handle: handleFor(t.entryId) }));
588
+ const why = valve ? "cache still warm, but the context passed the warm-cache limit" : this.cold ? this.coldWhy : "cache warm: the reads saved pay for the rewrite";
589
+ const data: NoticeData = { v: 2, text: noticeText(notices), before: Math.round(notices[0].tokensBefore), after: Math.round(notices[notices.length - 1].tokensAfter), desc: noticeDesc(notices), why: why || undefined, items, more: Math.max(0, live.length - ITEMS) || undefined, first: !this.noticed && !this.branch(ctx).some((en: Any) => en?.type === "custom" && en.customType === NOTICE_CUSTOM) };
590
+ this.noticed = true;
591
+ this.notify(ctx, data.text, data);
592
+ }
569
593
  return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
570
594
  }
571
595