pi-zip 0.2.2 → 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
 
@@ -61,8 +72,8 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
61
72
 
62
73
  | Command | Effect |
63
74
  |---|---|
64
- | `/zip status` | on / off / paused, cache TTL, totals |
65
- | `/zip stats` | tokens folded, estimated $ saved versus doing nothing, summaries, recalls |
75
+ | `/zip status` | on / off / paused, cache TTL, what this session has folded, summarised and recalled, learned cache survival |
76
+ | `/zip stats` | this session's folds (with tokens removed), summaries and recalls; persisted in the session, so a restart does not reset them |
66
77
  | `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
67
78
  | `/zip on` | resume |
68
79
  | `/zip quiet` | toggle the per-turn notice (folding continues) |
@@ -109,9 +120,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
109
120
 
110
121
  ## FAQ
111
122
 
112
- **Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip stats` shows an estimate based on the model's declared prices: avoided cache writes and reads, minus summary calls, minus the cost of content you recalled. It is an estimate (token counts are chars/4, scaled by the calibration above), and it can be negative.
123
+ **Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip stats` shows what this session has folded (and roughly how many tokens that removed), its summaries with what their model calls cost, and its recalls. The numbers live in the session file, so a restart does not reset them.
113
124
 
114
- **Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip stats`. A background summary you never use (you came back while the cache was warm) is counted too. Recalled content re-enters the context at normal prices.
125
+ **Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip stats`. A background summary you never use (you came back while the cache was warm) is counted there too. Recalled content re-enters the context at normal prices.
115
126
 
116
127
  **Can the model lose information?** Folded outputs are replaced by a placeholder with key lines and a handle, and the placeholder tells the model not to guess. Summaries quote your requests verbatim and never paraphrase the model's reasoning. If the model ignores the handle and guesses, that is a model failure pi-zip cannot catch; this is why only re-readable outputs of the previous turn are folded at a cold return; other outputs of the protected turns wait until they are 60 requests old.
117
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zip",
3
- "version": "0.2.2",
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,25 +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;
28
64
  }
29
65
 
30
- /** Prices are $ per million tokens, as declared by the model. */
31
- export interface Prices {
32
- cacheWrite: number;
33
- cacheRead: number;
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;
34
99
  }
35
100
 
36
101
  export class Stats {
@@ -49,37 +114,11 @@ export class Stats {
49
114
  activeSaved = 0; // tokens currently removed from the context by our edits
50
115
  notices = 0;
51
116
 
52
- /** Estimated $ saved versus doing nothing: avoided cache writes and reads, minus summary calls, recalled content re-entering the context, and warm-valve rewrites. */
53
- savedUsd(p: Prices | null): number | null {
54
- if (!p) return null;
55
- const gross = (this.writeSavedTokens * p.cacheWrite + this.readSavedTokens * p.cacheRead) / 1e6;
56
- const recall = (this.recallChars / 4) * p.cacheWrite / 1e6;
57
- const pressure = this.pressureRewriteTokens * Math.max(p.cacheWrite - p.cacheRead, 0) / 1e6;
58
- return gross - this.summaryUsd - recall - pressure;
59
- }
60
- }
61
-
62
- export const pricesOf = (model: Any): Prices | null => {
63
- const c = model?.cost;
64
- const cw = c?.cacheWrite > 0 ? c.cacheWrite : c?.input > 0 ? c.input : 0;
65
- const cr = c?.cacheRead > 0 ? c.cacheRead : cw * 0.1;
66
- return cw > 0 ? { cacheWrite: cw, cacheRead: cr } : null;
67
- };
68
-
69
- export function statsText(s: Stats, model: Any): string {
70
- const usd = s.savedUsd(pricesOf(model));
71
- const money = usd === null ? "n/a (model declares no prices)" : `${usd < 0 ? "-" : ""}$${Math.abs(usd).toFixed(usd !== 0 && Math.abs(usd) < 0.1 ? 4 : 2)}`;
72
- return (
73
- `${PRODUCT} stats (since pi started): folded ${s.folds} output${s.folds === 1 ? "" : "s"} (${fmtK(s.foldedTokens)} tokens), ` +
74
- `${s.summaries} summar${s.summaries === 1 ? "y" : "ies"} (${fmtK(s.summarizedTokens)} tokens, ${(s.summaryMs / 1000).toFixed(1)} s waited, $${s.summaryUsd.toFixed(4)}), ` +
75
- `${s.recalls} recall${s.recalls === 1 ? "" : "s"} (~${fmtK(s.recallChars / 4)} tokens re-read). ` +
76
- `Estimated saved vs doing nothing: ${money}, counting avoided cache writes and reads minus summary calls, recalled content and warm-valve rewrites; token counts are chars/4 estimates.`
77
- );
78
117
  }
79
118
 
80
119
  export interface ZipControl {
81
- status(): string;
82
- statsLine(): string;
120
+ status(ctx?: Any): string;
121
+ statsLine(ctx?: Any): string;
83
122
  setOff(off: boolean): string;
84
123
  toggleQuiet(): string;
85
124
  }
@@ -90,8 +129,8 @@ export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
90
129
  handler: async (args: string, cctx: Any) => {
91
130
  const sub = (args ?? "").trim().toLowerCase() || "status";
92
131
  const text =
93
- sub === "status" ? zip.status()
94
- : sub === "stats" ? zip.statsLine()
132
+ sub === "status" ? zip.status(cctx)
133
+ : sub === "stats" ? zip.statsLine(cctx)
95
134
  : sub === "off" ? zip.setOff(true)
96
135
  : sub === "on" ? zip.setOff(false)
97
136
  : sub === "quiet" ? zip.toggleQuiet()
package/src/run.ts CHANGED
@@ -6,16 +6,19 @@ 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 { Stats, noticeText, statsText, 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
- import { handleFor } from "./placeholder.ts";
11
+ import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
12
12
  import { recalledHandlesFromBranch } from "./recall.ts";
13
13
  import { buildCut } from "./summary.ts";
14
- import { type Any, PRODUCT, tok4 } from "./util.ts";
14
+ import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
15
15
 
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)
21
+ export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
19
22
  export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
20
23
  // Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
21
24
  const CONFLICT_RE = /billion-context|bc-pi|magic-context|smart-compact|hot-compact|context-prune|pi-vcc|context-mode|prefix-cache/i;
@@ -94,6 +97,10 @@ export class Zip implements ZipControl {
94
97
 
95
98
  /** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
96
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)
97
104
  /** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
98
105
  summarizedWarm = false;
99
106
  setRecallOk(ok: boolean) {
@@ -110,9 +117,19 @@ export class Zip implements ZipControl {
110
117
  } catch {}
111
118
  }
112
119
 
113
- 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) {
114
123
  this.stats.notices++;
115
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
+ }
116
133
  if (ctx?.ui?.notify && (ctx.mode === "tui" || ctx.mode === "rpc")) {
117
134
  try {
118
135
  ctx.ui.notify(text, "info");
@@ -242,6 +259,7 @@ export class Zip implements ZipControl {
242
259
  this.survSrc = src;
243
260
  this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
244
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` : "";
245
263
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
246
264
  for (let i = branch.length - 1; i >= 0; i--) {
247
265
  const en = branch[i];
@@ -270,7 +288,13 @@ export class Zip implements ZipControl {
270
288
  this.bg = null;
271
289
  if (!b) return;
272
290
  if (!b.done) b.controller.abort();
273
- b.promise.then((c) => { if (c) this.stats.summaryUsd += c.costUsd; });
291
+ b.promise.then((c) => {
292
+ if (!c) return;
293
+ this.stats.summaryUsd += c.costUsd;
294
+ try {
295
+ this.pi.appendEntry(UNUSED_SUMMARY_CUSTOM, { usd: c.costUsd });
296
+ } catch {}
297
+ });
274
298
  }
275
299
 
276
300
  async context(e: Any, ctx: Any): Promise<Any> {
@@ -558,7 +582,14 @@ export class Zip implements ZipControl {
558
582
  const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
559
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 });
560
584
  }
561
- 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
+ }
562
593
  return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
563
594
  }
564
595
 
@@ -636,16 +667,35 @@ export class Zip implements ZipControl {
636
667
  this.pi.appendEntry(STATE_CUSTOM, { off: this.off, quiet: this.quiet });
637
668
  } catch {}
638
669
  }
639
- status(): string {
670
+ /** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
671
+ private sessionTotals(ctx: Any): string {
672
+ const branch = ctx ? this.branch(ctx) : [];
673
+ const byId = new Map(branch.map((e: Any) => [e?.id, e]));
674
+ let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
675
+ for (const e of branch) {
676
+ const ph = String(e?.replacement?.content?.[0]?.text ?? "");
677
+ if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
678
+ folds++;
679
+ folded += Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4;
680
+ } else if (e?.type === "compaction" && e.details?.by === PRODUCT) {
681
+ summaries++;
682
+ usd += Number(e.usage?.cost?.total) || 0;
683
+ } else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
684
+ else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
685
+ }
686
+ const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
687
+ return `this session: ${n(folds, "folded output", "folded outputs")} (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "recall", "recalls")}`;
688
+ }
689
+ status(ctx?: Any): string {
640
690
  const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
641
691
  const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
642
692
  const pr = lawPrices(ent?.cls, ttl >= 3_600_000);
643
693
  const cache = !key ? "" : `\ncache ${key}: ${pr ? `${pr.cls} (from usage)` : "class unknown until the first response"}` +
644
694
  `, ${pr ? `read ${+pr.r.toFixed(4)} / write ${+pr.w.toFixed(4)} / output ${+pr.out.toFixed(4)} x input (class ratios)` : "no prices (legacy rule)"}` +
645
695
  `, ${describe(ent, ttl / 1000)}, growth ${(this.g / 1000).toFixed(1)}K/request`;
646
- return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}folded ${this.stats.folds} output${this.stats.folds === 1 ? "" : "s"}, ${this.stats.summaries} summar${this.stats.summaries === 1 ? "y" : "ies"} (/zip stats for details)${cache}`;
696
+ return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}${this.sessionTotals(ctx)}${cache}`;
647
697
  }
648
- statsLine = () => statsText(this.stats, this.model);
698
+ statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (token counts are chars/4 estimates)`;
649
699
  setOff(off: boolean): string {
650
700
  this.off = off;
651
701
  if (off) { this.runPlan = null; this.runEdits = false; this.cancelTimer(); this.discardBg(); }