pi-zip 0.2.1 → 0.2.4

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
@@ -61,8 +61,8 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
61
61
 
62
62
  | Command | Effect |
63
63
  |---|---|
64
- | `/zip status` | on / off / paused, cache TTL, totals |
65
- | `/zip stats` | tokens folded, estimated $ saved versus doing nothing, summaries, recalls |
64
+ | `/zip status` | on / off / paused, cache TTL, what this session has folded, summarised and recalled, learned cache survival |
65
+ | `/zip stats` | this session's folds (with tokens removed), summaries and recalls; persisted in the session, so a restart does not reset them |
66
66
  | `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
67
67
  | `/zip on` | resume |
68
68
  | `/zip quiet` | toggle the per-turn notice (folding continues) |
@@ -109,9 +109,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
109
109
 
110
110
  ## FAQ
111
111
 
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.
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 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
113
 
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.
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 there too. Recalled content re-enters the context at normal prices.
115
115
 
116
116
  **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
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zip",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
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/notice.ts CHANGED
@@ -27,12 +27,6 @@ export function noticeText(actions: NoticeAction[]): string {
27
27
  return `${PRODUCT} · ${segs.join(" · ")}`;
28
28
  }
29
29
 
30
- /** Prices are $ per million tokens, as declared by the model. */
31
- export interface Prices {
32
- cacheWrite: number;
33
- cacheRead: number;
34
- }
35
-
36
30
  export class Stats {
37
31
  folds = 0; // outputs folded
38
32
  foldedTokens = 0; // tokens removed by folds (estimate: chars/4)
@@ -49,37 +43,11 @@ export class Stats {
49
43
  activeSaved = 0; // tokens currently removed from the context by our edits
50
44
  notices = 0;
51
45
 
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
46
  }
79
47
 
80
48
  export interface ZipControl {
81
- status(): string;
82
- statsLine(): string;
49
+ status(ctx?: Any): string;
50
+ statsLine(ctx?: Any): string;
83
51
  setOff(off: boolean): string;
84
52
  toggleQuiet(): string;
85
53
  }
@@ -90,8 +58,8 @@ export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
90
58
  handler: async (args: string, cctx: Any) => {
91
59
  const sub = (args ?? "").trim().toLowerCase() || "status";
92
60
  const text =
93
- sub === "status" ? zip.status()
94
- : sub === "stats" ? zip.statsLine()
61
+ sub === "status" ? zip.status(cctx)
62
+ : sub === "stats" ? zip.statsLine(cctx)
95
63
  : sub === "off" ? zip.setOff(true)
96
64
  : sub === "on" ? zip.setOff(false)
97
65
  : sub === "quiet" ? zip.toggleQuiet()
package/src/plan.ts CHANGED
@@ -282,6 +282,7 @@ export interface PlanOpts {
282
282
  relax?: boolean; // default RELAX_PREV_TURN
283
283
  minGain?: number;
284
284
  inturnAge?: number; // default settings().inturnAge (PI_ZIP_INTURN_AGE, 60); 0 = outputs of the protected turns never fold on age
285
+ noSummary?: boolean; // a summary was already made while this cache stayed warm: no second one unless the context is at/above the compaction room
285
286
  rereadOnly?: boolean; // zip_recall is not available to the model: fold only outputs that can be re-read (classify "rereadable")
286
287
  pastTtl?: boolean; // a user return after the declared TTL: a warm plan may relax into the previous user turn too (RELAX_PREV_TURN)
287
288
  }
@@ -417,7 +418,13 @@ export function planContext(entries: Any[], o: PlanOpts): PlanResult | null {
417
418
  }
418
419
  }
419
420
  let ctxAfterFolds = ctxEst - savings();
420
- const sumTrigger: "cold" | "valve" | null = ctxAfterFolds > coldCap ? trig : null;
421
+ // what no summary can remove: the system prompt, the protected turns (after their folds) and the previous summary, which the next
422
+ // one carries forward; the cap is never chased below it (a context that is mostly this floor would be re-summarised for nothing)
423
+ const prevSum = blocks[0]?.kind === "summary" ? blocks[0].tokens : 0;
424
+ const floor = o.sys + prevSum + blocks.reduce((a, b) => a + (protectedTurn(b) ? (tokOverride.get(b.idx) ?? b.tokens) : 0), 0);
425
+ const target = Math.max(coldCap, floor + SUMMARY_FLOOR);
426
+ const atRoom = room !== null && k * ctxAfterFolds >= room;
427
+ const sumTrigger: "cold" | "valve" | null = ctxAfterFolds > target && (!o.noSummary || atRoom) ? trig : null;
421
428
  let cutIdx: number | null = null;
422
429
  let prefixTokens = 0;
423
430
  let summaryTokensPlanned = 0;
@@ -436,7 +443,7 @@ export function planContext(entries: Any[], o: PlanOpts): PlanResult | null {
436
443
  acc += tokOverride.get(i) ?? blocks[i].tokens;
437
444
  }
438
445
  const total = o.sys + acc;
439
- const S = (x: number) => clamp(SUMMARY_RATIO * x, SUMMARY_FLOOR, SUMMARY_CAP);
446
+ const S = (x: number) => Math.max(clamp(SUMMARY_RATIO * x, SUMMARY_FLOOR, SUMMARY_CAP), prevSum); // the next summary carries the previous one forward
440
447
  if (cuts.length) {
441
448
  let pick = cuts[cuts.length - 1];
442
449
  for (const c of cuts) if (total - pre[c] + S(pre[c]) <= coldCap) { pick = c; break; }
package/src/run.ts CHANGED
@@ -6,16 +6,17 @@ 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, noticeText, type NoticeAction, 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
+ export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
19
20
  export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
20
21
  // Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
21
22
  const CONFLICT_RE = /billion-context|bc-pi|magic-context|smart-compact|hot-compact|context-prune|pi-vcc|context-mode|prefix-cache/i;
@@ -94,6 +95,8 @@ export class Zip implements ZipControl {
94
95
 
95
96
  /** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
96
97
  recallOk = true;
98
+ /** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
99
+ summarizedWarm = false;
97
100
  setRecallOk(ok: boolean) {
98
101
  if (ok !== this.recallOk) this.ledger({ type: "recall_available", ok });
99
102
  this.recallOk = ok;
@@ -146,7 +149,7 @@ export class Zip implements ZipControl {
146
149
  private opts(ctx: Any, pending: boolean, mode: "cold" | "warm", entries: Any[], pWarm = mode === "cold" ? 0 : 1): { o: PlanOpts; cal: Calibration } {
147
150
  const sys = this.sysTokens(ctx);
148
151
  const cal = calibrate(entries, sys);
149
- return { cal, o: { mode, sys, k: cal.k, cwd: (ctx?.cwd as string) ?? process.cwd(), recalled: this.recalled, model: ctx?.model, promptPending: pending, rereadOnly: !this.recallOk, reserve: this.reserve(ctx), steerIds: this.steerIds, law: this.law(ctx, pWarm), trace: [] } };
152
+ return { cal, o: { mode, sys, k: cal.k, cwd: (ctx?.cwd as string) ?? process.cwd(), recalled: this.recalled, model: ctx?.model, promptPending: pending, rereadOnly: !this.recallOk, noSummary: mode === "warm" && this.summarizedWarm, reserve: this.reserve(ctx), steerIds: this.steerIds, law: this.law(ctx, pWarm), trace: [] } };
150
153
  }
151
154
 
152
155
  private law(ctx: Any, pWarm: number): Law {
@@ -234,6 +237,7 @@ export class Zip implements ZipControl {
234
237
  if (!this.prevKey) ({ key: this.prevKey, total: this.prevTotal } = lastPrompt(branch)); // fresh process: the session's newest response
235
238
  const { cold, reason, ttl, pWarm: p, src, gapS, pastTtl } = detectCold(ctx.model, this.lastReqMs, branch, Date.now(), this.lastModelKey, (g, prior) => pWarm(this.ent, g, prior));
236
239
  this.cold = cold;
240
+ if (cold) this.summarizedWarm = false; // a cold return may summarise again
237
241
  this.pastTtl = pastTtl;
238
242
  this.pWarm = p;
239
243
  this.survSrc = src;
@@ -267,7 +271,13 @@ export class Zip implements ZipControl {
267
271
  this.bg = null;
268
272
  if (!b) return;
269
273
  if (!b.done) b.controller.abort();
270
- b.promise.then((c) => { if (c) this.stats.summaryUsd += c.costUsd; });
274
+ b.promise.then((c) => {
275
+ if (!c) return;
276
+ this.stats.summaryUsd += c.costUsd;
277
+ try {
278
+ this.pi.appendEntry(UNUSED_SUMMARY_CUSTOM, { usd: c.costUsd });
279
+ } catch {}
280
+ });
271
281
  }
272
282
 
273
283
  async context(e: Any, ctx: Any): Promise<Any> {
@@ -512,6 +522,7 @@ export class Zip implements ZipControl {
512
522
  return undefined;
513
523
  }
514
524
  const bad = checkEdits(o.blocks, o.userTurns, live, cut);
525
+ if (!bad && cut) this.summarizedWarm = true; // one summary per warm stretch (two in a row cost two uncached calls; the second gains little)
515
526
  if (bad) {
516
527
  this.ledger({ type: "guard_drop", turnIndex: e.turnIndex, reason: bad, folds: live.length, source: plan.source });
517
528
  plan.persisted = false; // keep the request-local view: never switch the fold set mid-run
@@ -632,16 +643,35 @@ export class Zip implements ZipControl {
632
643
  this.pi.appendEntry(STATE_CUSTOM, { off: this.off, quiet: this.quiet });
633
644
  } catch {}
634
645
  }
635
- status(): string {
646
+ /** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
647
+ private sessionTotals(ctx: Any): string {
648
+ const branch = ctx ? this.branch(ctx) : [];
649
+ const byId = new Map(branch.map((e: Any) => [e?.id, e]));
650
+ let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
651
+ for (const e of branch) {
652
+ const ph = String(e?.replacement?.content?.[0]?.text ?? "");
653
+ if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
654
+ folds++;
655
+ folded += Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4;
656
+ } else if (e?.type === "compaction" && e.details?.by === PRODUCT) {
657
+ summaries++;
658
+ usd += Number(e.usage?.cost?.total) || 0;
659
+ } else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
660
+ else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
661
+ }
662
+ const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
663
+ 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")}`;
664
+ }
665
+ status(ctx?: Any): string {
636
666
  const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
637
667
  const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
638
668
  const pr = lawPrices(ent?.cls, ttl >= 3_600_000);
639
669
  const cache = !key ? "" : `\ncache ${key}: ${pr ? `${pr.cls} (from usage)` : "class unknown until the first response"}` +
640
670
  `, ${pr ? `read ${+pr.r.toFixed(4)} / write ${+pr.w.toFixed(4)} / output ${+pr.out.toFixed(4)} x input (class ratios)` : "no prices (legacy rule)"}` +
641
671
  `, ${describe(ent, ttl / 1000)}, growth ${(this.g / 1000).toFixed(1)}K/request`;
642
- 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}`;
672
+ return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}${this.sessionTotals(ctx)}${cache}`;
643
673
  }
644
- statsLine = () => statsText(this.stats, this.model);
674
+ statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (token counts are chars/4 estimates)`;
645
675
  setOff(off: boolean): string {
646
676
  this.off = off;
647
677
  if (off) { this.runPlan = null; this.runEdits = false; this.cancelTimer(); this.discardBg(); }