pi-zip 0.2.7 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  // The per-session state machine behind index.ts: cold detection, the run plan (F8), the warm valve (F10), persistence at turn_end,
2
2
  // settle preparation and the away-timer for the summary (F12).
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
- import { dirname } from "node:path";
6
- import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
7
- import { describe, lawPrices, loadStats, pWarm, record, sample, statsPath, GAP_EDGES, type Entry } from "./learn.ts";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { detectCold, lastMessageMs, lastPrompt, modelKey, payloadRetention, refused, resolveTtl, ttlFor } from "./cache.ts";
7
+ import { agentDir, describe, lawPrices, loadStats, pWarm, record, sample, statsPath, GAP_EDGES, type Entry } from "./learn.ts";
8
8
  import { validateEdits, repairPayload } from "./guard.ts";
9
9
  import { fmtK, Stats, noticeDesc, noticeText, type NoticeAction, type NoticeData, type ZipControl } from "./notice.ts";
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";
10
+ import { applyPlanToMessages, buildBlocks, calibrate, countUserTurns, G0, keepRecentTokensFor, piCanCompact, planContext, rebaseFolds, reserveTokensFor, sizeOf, spliceNative, untouchedEst, UNIT, type Block, type Cut, type FoldTarget, type Law, type PlanOpts, type PlanResult, type RunPlan, type Scale, type SysInfo } from "./plan.ts";
11
11
  import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
12
- import { recalledHandlesFromBranch } from "./recall.ts";
12
+ import { branchOutputs, recalledHandlesFromBranch, resolveHandlesInBranch } from "./recall.ts";
13
+ import { cleanupRecall, commandNamesRecall, fileRecallFor, materialize, parseRecallPath, recallRoot, recallRootShown, rewriteCommand, sidDir, type FileRecall } from "./recallfile.ts";
13
14
  import { buildCut } from "./summary.ts";
14
- import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
15
- import { fmtLife, SPARK_GAPS, type CardData, type StateWord } from "./ui.ts";
15
+ import { type Any, COMMIT_CUSTOM, envInt, PRODUCT, textOf, tok4 } from "./util.ts";
16
+ import { replayGrowth } from "./state.ts";
17
+ import { CHART_COLS, chartGap, fmtLife, type CardData, type StateWord } from "./ui.ts";
16
18
 
17
19
  export const PLAN_CUSTOM = "pi-zip/plan";
18
20
  export const STATE_CUSTOM = "pi-zip/state";
@@ -21,6 +23,13 @@ const fmtAway = (s: number) => (s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Ma
21
23
  export const NOTICE_CUSTOM = "pi-zip/notice"; // a fold/summary notice kept in the transcript (rendered by index.ts, never sent to the model)
22
24
  export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
23
25
  export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
26
+ /** Pi's idle cache warmer decides before each refresh at 0.9 x TTL, at least 10 s before expiry (core/cache-warmer.js getCacheWarmingDelayMs);
27
+ * undefined: a lifetime of 10 s or less, which Pi never warms. */
28
+ export const warmingDelayMs = (ttlMs: number): number | undefined => (ttlMs <= 10_000 ? undefined : Math.max(1, Math.floor(Math.min(ttlMs * 0.9, ttlMs - 10_000))));
29
+ /** How long after a due warming decision pi-zip concludes that none is coming (15 s at a 5-minute TTL). */
30
+ const warmingMargin = (ttlMs: number): number => Math.max(1_000, Math.min(15_000, 0.05 * ttlMs));
31
+ /** A refresh lands this soon after a "warm" decision (it is one tiny request): 10 s at a 5-minute TTL. */
32
+ const warmCheckMs = (ttlMs: number): number => Math.min(10_000, ttlMs / 8);
24
33
  // Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
25
34
  const CONFLICT_RE = /billion-context|bc-pi|magic-context|smart-compact|hot-compact|context-prune|pi-vcc|context-mode|prefix-cache/i;
26
35
 
@@ -30,7 +39,7 @@ export function checkEdits(blocks: Block[], userTurns: number, folds: FoldTarget
30
39
  const live = folds.filter((t) => byId.has(t.entryId));
31
40
  const cutBlock = cut ? byId.get(cut.firstKeptEntryId) : undefined;
32
41
  if (cut && !cutBlock) return `cut target ${cut.firstKeptEntryId} is not in the projection`;
33
- return validateEdits(blocks, { folds: new Map(live.map((t) => [byId.get(t.entryId)!.idx, t.ph])), recover: new Map(live.map((t) => [byId.get(t.entryId)!.idx, t.recover])), cut: cutBlock ? cutBlock.idx : null }, userTurns);
42
+ return validateEdits(blocks, { folds: new Map(live.map((t) => [byId.get(t.entryId)!.idx, t.ph])), recover: new Map(live.map((t) => [byId.get(t.entryId)!.idx, t.recover])), cut: cutBlock ? cutBlock.idx : null, ahead: !!cut?.ahead }, userTurns);
34
43
  }
35
44
 
36
45
  /** A summary being prepared (or finished) while the user is away. It has its own AbortController: no run owns it. */
@@ -41,7 +50,7 @@ interface Bg {
41
50
  controller: AbortController;
42
51
  }
43
52
 
44
- /** Resolves with the promise's value, or null as soon as the signal aborts (Esc cancels the waiting, not the work). */
53
+ /** Resolves with the promise's value, or null as soon as the signal aborts (Esc: Pi cancels the prompt; the work goes on). */
45
54
  function waitFor<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T | null> {
46
55
  return new Promise((resolve) => {
47
56
  if (signal?.aborted) return resolve(null);
@@ -52,6 +61,23 @@ function waitFor<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T | null
52
61
  }
53
62
 
54
63
  const errText = (err: unknown) => (err instanceof Error ? (err.stack ?? err.message) : String(err));
64
+ /** A compaction another extension supplied (through session_before_compact): not Pi's own summary, not pi-zip's. */
65
+ const foreignCompaction = (e: Any): boolean => e?.type === "compaction" && !!e.fromHook && e.details?.by !== PRODUCT;
66
+
67
+ /** pi-zip's own compaction takes milliseconds: the summary is done. Pi runs every extension's session_before_compact handler for it, so
68
+ * one that is not finished by this deadline has another handler at work (writing its own summary, say): pi-zip cancels it then, so it
69
+ * can never land during the run, and the draft path takes the summary. */
70
+ const nativeDeadlineMs = () => envInt("NATIVE_MS", 2_000);
71
+ /** A compaction slower than this (or half the deadline) had another handler at work too: the native path ends for the process, so its
72
+ * work (a model call, say) is not paid for, and waited for, at every cold return. */
73
+ const NATIVE_SLOW_MS = 250;
74
+
75
+ /** The prompt Pi appends right after before_agent_start, as a projected entry without an id: the plan made before it counts its size. */
76
+ function pendingPrompt(e: Any): Any | null {
77
+ if (typeof e?.prompt !== "string") return null;
78
+ const m = { role: "user", content: [{ type: "text", text: e.prompt }, ...(Array.isArray(e.images) ? e.images : [])], timestamp: Date.now() };
79
+ return { sourceEntry: { type: "message", message: m }, messages: [m] };
80
+ }
55
81
 
56
82
  export class Zip implements ZipControl {
57
83
  off = false;
@@ -65,6 +91,7 @@ export class Zip implements ZipControl {
65
91
  private runEdits = false; // this run carries an edit plan that turn_end has yet to persist
66
92
  private runPlan: RunPlan | null = null;
67
93
  private lastReqMs = 0; // the real-time cache clock: newest request start or end
94
+ private reqMsBefore = 0; // the clock before the request in flight started: what a refused request gives back
68
95
  private lastModelKey = ""; // provider/model of the newest request: a cache entry belongs to one model
69
96
  private settleIds: string[] = []; // entries the settle plan folded: only used to label a plan as prepared
70
97
  private bg: Bg | null = null;
@@ -91,37 +118,132 @@ export class Zip implements ZipControl {
91
118
  private nextEdit: number | null = null; // the next request is the first to carry a new edit: its untouched prefix (real tokens)
92
119
  private pending: { gapS: number; expect: number; edited: boolean; key: string } | null = null;
93
120
  private justEdited = false; // the newest response answered a request that first carried an edit: the next request gets no warm edit
121
+ // pi-zip's own ctx.compact() in flight: the only compaction session_before_compact answers (compaction null = cancel a late ask)
122
+ private nativeAsk: { compaction: Any | null; answered: boolean; askedAt: number; until: number } | null = null;
123
+ // the native path ended for this process, and why: another extension took part in pi-zip's compaction (answered, cancelled or
124
+ // replaced it, or took its time), or Pi's compaction failed. Summaries then ride along with the first request (the draft path).
125
+ private nativeOff: string | null = null;
126
+ // this run's return went in through another extension's compaction (it answered pi-zip's): the run plan adds no summary on top of
127
+ // it (folds only, a summary only at Pi's compaction room), and the summary prepared for the context it replaced is discarded
128
+ private foreignRun = false;
129
+ // this run's away summary went in through Pi's compaction before the run: the projection just before it, for the run plan (I1)
130
+ private nativeRun: { key: string; cut: Cut; before: number; after: number; pre: Any[]; entryId: string } | null = null;
131
+ // the cache tier (PI_CACHE_RETENTION) of the current model's requests: the payload of its newest request when seen, else the
132
+ // provider-scoped env resolved once per run (research pi-tricks §3.1); null = nothing known, the process env decides (cache.ts)
133
+ private retention: { key: string; tier: "short" | "long"; src: "env" | "provider env" | "payload" } | null = null;
94
134
 
95
135
  constructor(private pi: ExtensionAPI) {}
96
136
 
97
137
  active = () => !this.off && !this.conflict;
98
138
 
99
- /** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
100
- recallOk = true;
139
+ /** How the model gets a folded output back, checked at session start and before every run (index.ts): "tool" = zip_recall is declared;
140
+ * "file" = a `--tools` allowlist hides it but read, grep or bash is active (placeholders name a recall file, written on demand);
141
+ * "reread" = none of those either: only re-readable outputs fold. */
142
+ recallMode: "tool" | "file" | "reread" = "tool";
143
+ private recallTools = { read: false, grep: false, bash: false };
144
+ get recallOk(): boolean {
145
+ return this.recallMode === "tool";
146
+ }
101
147
  /** index.ts registered the transcript renderer for NOTICE_CUSTOM (Pi versions without registerEntryRenderer fall back to a status line). */
102
148
  entryRenderer = false;
103
149
  private coldWhy = ""; // the expanded notice's "why now" for this run
104
150
  private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
105
151
  /** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
106
152
  summarizedWarm = false;
107
- setRecallOk(ok: boolean, ctx?: Any) {
108
- if (ok !== this.recallOk) this.ledger({ type: "recall_available", ok });
109
- this.recallOk = ok;
110
- if (!ok && ctx) this.stateLine(ctx, "reread-only", "a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall to fold more");
153
+ setRecallMode(mode: "tool" | "file" | "reread", tools: { read: boolean; grep: boolean; bash: boolean }, ctx?: Any) {
154
+ if (mode !== this.recallMode) this.ledger({ type: "recall_available", ok: mode === "tool", mode, ...tools });
155
+ this.recallMode = mode;
156
+ this.recallTools = tools;
157
+ if (!ctx) return;
158
+ if (mode === "file" && this.fileRecall(ctx)) this.stateLine(ctx, "on", `a tool allowlist hides zip_recall · zipped outputs are read back from files in ${recallRootShown()}`, true, "on:file");
159
+ else if (mode === "file") this.stateLine(ctx, "reread-only", "a tool allowlist hides zip_recall · only re-readable outputs fold · no session file (--no-session), so no recall copies are written either", true, "reread-only:no-session");
160
+ else if (mode !== "tool") this.stateLine(ctx, "reread-only", "a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall, read, grep or bash to fold more");
161
+ }
162
+ /** The recall files this session's placeholders name; undefined outside file mode, and without a session file (`--no-session`: the
163
+ * outputs are in no file, so none is written for them either): then only re-readable outputs fold. */
164
+ private fileRecall(ctx: Any): FileRecall | undefined {
165
+ if (this.recallMode !== "file") return undefined;
166
+ const sid = this.recallSid(ctx);
167
+ return sid ? fileRecallFor(sid, this.recallTools) : undefined;
168
+ }
169
+ /** The session id recall copies are written under: none without a session file (`pi --no-session` keeps the session in memory). */
170
+ private recallSid(ctx: Any): string {
171
+ try {
172
+ return typeof ctx?.sessionManager?.getSessionFile?.() === "string" ? this.sessionId(ctx) : "";
173
+ } catch {
174
+ return "";
175
+ }
176
+ }
177
+ private sessionId(ctx: Any): string {
178
+ try {
179
+ const sid = ctx?.sessionManager?.getSessionId?.();
180
+ return typeof sid === "string" ? sid : "";
181
+ } catch {
182
+ return "";
183
+ }
111
184
  }
112
185
 
113
186
  /** Folded tool outputs of the current branch by toolCallId -> handle (the transcript marks them). */
114
187
  readonly foldedCalls = new Map<string, string>();
115
188
  foldedHandle = (toolCallId: string): string | undefined => this.foldedCalls.get(toolCallId);
189
+ /** handle -> what was folded ("read src/learn.ts"), from the placeholders of this branch: the recall row names it. */
190
+ readonly labels = new Map<string, string>();
191
+ labelOf = (handle: string): string | undefined => this.labels.get(handle);
116
192
  refreshFolded(ctx: Any) {
117
193
  const branch = this.branch(ctx);
118
194
  const byId = new Map(branch.map((e: Any) => [e?.id, e]));
119
195
  this.foldedCalls.clear();
120
196
  for (const e of branch) {
121
- if (e?.type !== "context_edit" || !String(e?.replacement?.content?.[0]?.text ?? "").startsWith(PH_MARK)) continue;
197
+ const ph = String(e?.replacement?.content?.[0]?.text ?? "");
198
+ if (e?.type !== "context_edit" || !ph.startsWith(PH_MARK)) continue;
199
+ const h = handleFor(e.targetId);
200
+ // older placeholders wrote a read slice as "offset=1 limit=2000": show it the way Pi does ("path:1-2000")
201
+ const label = ph.slice(0, ph.indexOf("\n") >>> 0).split(" · ")[1]?.replace(/ offset=(\d+)(?: limit=(\d+))?$| limit=(\d+)$/, (_m, o, l, l2) => {
202
+ const st = o ? +o : 1, li = l ?? l2;
203
+ return `:${st}${li ? `-${st + +li - 1}` : ""}`;
204
+ });
205
+ if (label) this.labels.set(h, label);
122
206
  const id = byId.get(e.targetId)?.message?.toolCallId;
123
- if (typeof id === "string") this.foldedCalls.set(id, handleFor(e.targetId));
207
+ if (typeof id === "string") this.foldedCalls.set(id, h);
208
+ }
209
+ }
210
+
211
+ // ---- the real size of the request that carried a plan (notices, research pi-tricks §3.4) ---------------------------------------
212
+ private liveCtx: Any = null; // the newest handler context: its sessionManager resolves to the current session at call time
213
+ private realMemo = new Map<number, { real: number | undefined } | { n: number }>();
214
+ /** input + cacheRead + cacheWrite of the first assistant message stamped at or after reqTs (errors and aborts skipped): the request
215
+ * that carried the plan. undefined until that response exists (the notice keeps its estimate); 0 when it reports no usage. A user
216
+ * message stamped after reqTs before it ends the search for good: what follows answers a later run (a valve plan committed at a run's
217
+ * last turn_end, a plan whose request failed), so the estimate stays. The run's own prompt is stamped before reqTs (its millisecond at
218
+ * the latest). A found value is kept; a miss is looked at again only when the session has grown. */
219
+ realSizeAfter(reqTs: number): number | undefined {
220
+ const memo = this.realMemo.get(reqTs);
221
+ if (memo && "real" in memo) return memo.real;
222
+ let sm: Any = null, n = -1;
223
+ try {
224
+ sm = this.liveCtx?.sessionManager;
225
+ n = typeof sm?.getEntryCount === "function" ? sm.getEntryCount() : -1;
226
+ } catch {
227
+ return undefined;
124
228
  }
229
+ if (memo && n >= 0 && memo.n === n) return undefined;
230
+ let branch: Any[] = [];
231
+ try {
232
+ branch = sm?.getBranch?.() ?? [];
233
+ } catch {}
234
+ for (const e of branch) {
235
+ const m = e?.type === "message" ? e.message : null;
236
+ if (m?.role === "user" && Number(m.timestamp) > reqTs) {
237
+ this.realMemo.set(reqTs, { real: undefined }); // a later prompt came first: no response can be this plan's any more
238
+ return undefined;
239
+ }
240
+ if (m?.role !== "assistant" || !(Number(m.timestamp) >= reqTs) || m.stopReason === "error" || m.stopReason === "aborted") continue;
241
+ const u = m.usage, real = u ? (Number(u.input) || 0) + (Number(u.cacheRead) || 0) + (Number(u.cacheWrite) || 0) : 0;
242
+ this.realMemo.set(reqTs, { real });
243
+ return real;
244
+ }
245
+ this.realMemo.set(reqTs, { n });
246
+ return undefined;
125
247
  }
126
248
 
127
249
  /** /zip output in the transcript (custom entry, never sent to the model). */
@@ -135,15 +257,15 @@ export class Zip implements ZipControl {
135
257
  }
136
258
  }
137
259
 
138
- /** A state change as one transcript line ("▸ pi-zip paused …"); each word once per session unless always. */
139
- stateLine(ctx: Any, word: StateWord, reason: string, once = true) {
260
+ /** A state change as one transcript line ("ƶ pi-zip paused …"); each word once per session unless always. */
261
+ stateLine(ctx: Any, word: StateWord, reason: string, once = true, key: string = word) {
140
262
  const text = `${PRODUCT} ${word} ${reason}`;
141
263
  if (ctx?.mode !== "tui" && word !== "paused") return; // print/json/rpc (sub-agents): no transcript to keep it in
142
- if (once && (this.statesShown.has(word) || this.branch(ctx).some((e: Any) => e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.kind === "state" && e.data?.word === word))) return;
143
- this.statesShown.add(word);
264
+ if (once && (this.statesShown.has(key) || this.branch(ctx).some((e: Any) => e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.kind === "state" && (e.data?.key ?? e.data?.word) === key))) return;
265
+ this.statesShown.add(key);
144
266
  this.ledger({ type: "state", word, reason });
145
267
  // a state is not tied to this turn's edits: append now, so it sits where it happened
146
- if (!this.show(ctx, { v: 2, kind: "state", word, reason, text, before: 0, after: 0, desc: "" })) this.notify(ctx, text);
268
+ if (!this.show(ctx, { v: 2, kind: "state", word, reason, text, before: 0, after: 0, desc: "", ...(key !== word ? { key } : {}) })) this.notify(ctx, text);
147
269
  }
148
270
  private statesShown = new Set<string>();
149
271
 
@@ -158,8 +280,8 @@ export class Zip implements ZipControl {
158
280
  } catch {
159
281
  return;
160
282
  }
161
- const ttl = ttlFor(ctx.model ?? this.model);
162
- this.stateLine(ctx, "on", `folds old tool output after the prompt cache expires (${fmtLife(ttl / 1000).slice(1)} here) · nothing to set up · /zip for status`);
283
+ const ttl = ttlFor(ctx.model ?? this.model, [], this.tier(ctx.model ?? this.model));
284
+ this.stateLine(ctx, "on", `old tool output gets zipped once the prompt cache expires (${fmtLife(ttl / 1000).slice(1)} here) · /zip`);
163
285
  }
164
286
 
165
287
  ledger(rec: Record<string, unknown>) {
@@ -207,25 +329,71 @@ export class Zip implements ZipControl {
207
329
  }
208
330
  }
209
331
 
210
- private sysTokens(ctx: Any): number {
332
+ /** The system prompt and the declared tools from Pi's API: only used when the transcript records no system message (plan.ts calibrate). */
333
+ private sysInfo(ctx: Any): SysInfo {
334
+ let text = "", tools: Any[] = [];
335
+ try {
336
+ text = ctx?.getSystemPrompt?.() ?? "";
337
+ } catch {}
338
+ try {
339
+ const active = new Set(this.pi.getActiveTools?.() ?? []);
340
+ tools = (this.pi.getAllTools?.() ?? []).filter((t: Any) => active.has(t?.name));
341
+ } catch {}
342
+ return { text, tools };
343
+ }
344
+
345
+ /** The cache tier of `model`'s requests, when known (see `retention`). */
346
+ private tier(model?: Any): "short" | "long" | undefined {
347
+ const r = this.retention;
348
+ return r && r.key === modelKey(model ?? this.model) ? r.tier : undefined;
349
+ }
350
+ /** Once per run: PI_CACHE_RETENTION as Pi resolves it for this model's provider (the credential's env in auth.json first, then the
351
+ * process env). A tier read from this model's newest request payload is the truth about that request and stays. */
352
+ private async resolveRetention(ctx: Any): Promise<void> {
353
+ const key = modelKey(ctx?.model);
354
+ if (!key || (this.retention?.key === key && this.retention.src === "payload")) return;
355
+ let scoped: string | undefined;
356
+ try {
357
+ const r = await ctx?.modelRegistry?.getApiKeyAndHeaders?.(ctx.model);
358
+ const v = r?.env?.PI_CACHE_RETENTION;
359
+ if (typeof v === "string" && v) scoped = v;
360
+ } catch {}
361
+ const v = scoped ?? process.env.PI_CACHE_RETENTION;
362
+ const next = { key, tier: v === "long" ? ("long" as const) : ("short" as const), src: scoped ? ("provider env" as const) : ("env" as const) };
363
+ if (next.tier !== this.retention?.tier || next.key !== this.retention?.key || next.src !== this.retention?.src) this.ledger({ type: "retention", ...next });
364
+ this.retention = next;
365
+ }
366
+
367
+ /** The token scale (O, c) read from the branch: stateless, so a fresh process calibrates exactly like a long-lived one. */
368
+ scaleOf(ctx: Any, entries?: Any[]): Scale {
369
+ try {
370
+ return calibrate(entries ?? ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []), this.sysInfo(ctx), this.branch(ctx), ctx?.model ? modelKey(ctx.model) : undefined);
371
+ } catch {
372
+ return UNIT;
373
+ }
374
+ }
375
+
376
+ /** The token scale of model `key` ("provider/id"), read from the branch. */
377
+ private scaleAt(ctx: Any, entries: Any[] | undefined, key: string): Scale {
211
378
  try {
212
- return tok4(ctx.getSystemPrompt?.() ?? "");
379
+ return calibrate(entries ?? [], this.sysInfo(ctx), this.branch(ctx), key);
213
380
  } catch {
214
- return 0;
381
+ return UNIT;
215
382
  }
216
383
  }
217
384
 
218
- /** Plan options with the token scale read from the branch (stateless: a fresh process calibrates exactly like a long-lived one). */
219
- private opts(ctx: Any, pending: boolean, mode: "cold" | "warm", entries: Any[], pWarm = mode === "cold" ? 0 : 1): { o: PlanOpts; cal: Calibration } {
220
- const sys = this.sysTokens(ctx);
221
- const cal = calibrate(entries, sys);
222
- 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: [] } };
385
+ /** Plan options with the token scale read from the branch. */
386
+ private opts(ctx: Any, pending: boolean, mode: "cold" | "warm", entries: Any[], pWarm = mode === "cold" ? 0 : 1): { o: PlanOpts; cal: Scale } {
387
+ const cal = this.scaleOf(ctx, entries);
388
+ const fileRecall = this.fileRecall(ctx);
389
+ const rereadOnly = this.recallMode === "reread" || (this.recallMode === "file" && !fileRecall);
390
+ return { cal, o: { mode, scale: cal, cwd: (ctx?.cwd as string) ?? process.cwd(), recalled: this.recalled, model: ctx?.model, promptPending: pending, rereadOnly, ...(fileRecall ? { fileRecall } : {}), noSummary: mode === "warm" && this.summarizedWarm, reserve: this.reserve(ctx), steerIds: this.steerIds, law: this.law(ctx, pWarm), trace: [] } };
223
391
  }
224
392
 
225
393
  private law(ctx: Any, pWarm: number): Law {
226
394
  const m = ctx?.model ?? this.model;
227
395
  const ent = this.entKey === modelKey(m) ? this.ent : undefined;
228
- return { pr: lawPrices(ent?.cls, resolveTtl(m, this.branch(ctx)).ms >= 3_600_000), g: this.g, pWarm };
396
+ return { pr: lawPrices(ent?.cls, resolveTtl(m, this.branch(ctx), this.tier(m)).ms >= 3_600_000), g: this.g, pWarm };
229
397
  }
230
398
  private entKey = "";
231
399
  private loadEnt(key: string) {
@@ -242,6 +410,25 @@ export class Zip implements ZipControl {
242
410
  });
243
411
  }
244
412
 
413
+ /** Ledger only (research pi-tricks §3.5): entries another context manager wrote into this branch, each logged once: a context_edit
414
+ * that is neither pi-zip's placeholder nor Pi's own omission (null), or a compaction an extension other than pi-zip supplied.
415
+ * Nothing is decided from it. */
416
+ private foreignSeen = new Set<string>();
417
+ private logForeign(ctx: Any, where: string) {
418
+ if (!process.env.PI_ZIP_LEDGER || this.off) return; // the ledger is the only reader
419
+ for (const e of this.branch(ctx)) {
420
+ if (typeof e?.id !== "string" || this.foreignSeen.has(e.id)) continue;
421
+ let rec: Record<string, unknown> | null = null;
422
+ if (e.type === "context_edit" && e.replacement != null) {
423
+ const text = textOf(e.replacement.content);
424
+ if (!text.startsWith(PH_MARK)) rec = { kind: "context_edit", targetId: e.targetId ?? null, chars: text.length, head: text.slice(0, 60) };
425
+ } else if (foreignCompaction(e)) rec = { kind: "compaction", by: e.details?.by ?? null, firstKeptEntryId: e.firstKeptEntryId ?? null, chars: String(e.summary ?? "").length };
426
+ if (!rec) continue;
427
+ this.foreignSeen.add(e.id);
428
+ this.ledger({ type: "foreign_edit", where, id: e.id, ...rec });
429
+ }
430
+ }
431
+
245
432
  private detectConflict(ctx: Any) {
246
433
  let found: string | null = null;
247
434
  try {
@@ -262,6 +449,7 @@ export class Zip implements ZipControl {
262
449
 
263
450
  // ---- events ----------------------------------------------------------------------------------------------
264
451
  sessionStart(ctx: Any) {
452
+ this.liveCtx = ctx;
265
453
  const branch = this.branch(ctx);
266
454
  for (let i = branch.length - 1; i >= 0; i--) {
267
455
  const en = branch[i];
@@ -275,8 +463,138 @@ export class Zip implements ZipControl {
275
463
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
276
464
  this.model = ctx?.model;
277
465
  this.ttlNoticed = false;
466
+ this.restoreState(branch);
278
467
  this.detectConflict(ctx);
279
- this.ledger({ type: "session_start", off: this.off, quiet: this.quiet, conflict: this.conflict });
468
+ let recallFilesRemoved = 0;
469
+ try {
470
+ recallFilesRemoved = cleanupRecall(); // recall copies older than 14 days (they can be written again from the session)
471
+ } catch {}
472
+ this.ledger({ type: "session_start", off: this.off, quiet: this.quiet, conflict: this.conflict, ...(recallFilesRemoved ? { recallFilesRemoved } : {}) });
473
+ }
474
+
475
+ /** A resumed (or restarted, reloaded, forked) process arms the away timer from the branch's last request, as `settle` would have after
476
+ * it (formal TRIAGE (a) 7): the summary for the next cold return is prepared while the user is away, not while they wait. Only when
477
+ * the session ended on a finished response (a run cut off mid-tool has no settle point) and the plan for it has a cut. Called after
478
+ * the recall mode is known (it decides which outputs fold, so which plan the return will make). */
479
+ resumeAway(ctx: Any) {
480
+ try {
481
+ const branch = this.branch(ctx);
482
+ if (!this.active() || !ctx?.hasUI || !ctx?.model) return;
483
+ const last = [...branch].reverse().find((en: Any) => en?.type === "message" && en.message);
484
+ const m = last?.message;
485
+ if (m?.role !== "assistant" || m.stopReason === "toolUse") return;
486
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
487
+ if (!entries.length) return;
488
+ this.loadEnt(modelKey(ctx.model));
489
+ const { o } = this.opts(ctx, true, "cold", entries);
490
+ const p = planContext(entries, o);
491
+ if (!p || p.cutIdx === null) return;
492
+ const at = lastMessageMs(branch);
493
+ const delayMs = this.scheduleSummary(p, ctx, at || undefined);
494
+ this.ledger({ type: "away_resumed", timerMs: delayMs, lastRequestMs: at });
495
+ } catch (err) {
496
+ this.ledger({ type: "error", where: "resume_away", error: errText(err) });
497
+ }
498
+ }
499
+
500
+ /** What a process that never left would know: growth g, "the newest response carried an edit" and "a summary was made in this warm
501
+ * stretch" are functions of the session, rebuilt from the branch (state.ts; a restart, a resume or a fork plans like the live process). */
502
+ private restoreState(branch: Any[]) {
503
+ try {
504
+ const r = replayGrowth(branch, G0);
505
+ this.g = r.g;
506
+ this.deltas = r.deltas;
507
+ this.justEdited = r.justEdited;
508
+ this.summarizedWarm = this.summarizedSince(branch);
509
+ if (branch.length) this.ledger({ type: "state_restored", g: Math.round(this.g), justEdited: this.justEdited, summarizedWarm: this.summarizedWarm });
510
+ } catch (err) {
511
+ this.ledger({ type: "error", where: "restore_state", error: errText(err) });
512
+ }
513
+ }
514
+
515
+ /** Is the newest pi-zip summary of the branch newer than the last cold return? (The live flag: set when a summary is persisted,
516
+ * cleared at a cold return.) Each user prompt after the summary is judged as `detectCold` judged it, from the branch before it. */
517
+ private summarizedSince(branch: Any[]): boolean {
518
+ let at = -1;
519
+ branch.forEach((e, i) => { if (e?.type === "compaction" && e.details?.by === PRODUCT) at = i; });
520
+ if (at < 0) return false;
521
+ let skip = branch[at].details?.via === "native"; // the prompt after a native summary is the very return that made it
522
+ const models = loadStats().models;
523
+ for (let i = at + 1; i < branch.length; i++) {
524
+ const m = branch[i]?.type === "message" ? branch[i].message : null;
525
+ if (m?.role !== "user" || this.steerIds.has(branch[i].id)) continue;
526
+ if (skip) { skip = false; continue; }
527
+ const a = branch.slice(i + 1).find((x: Any) => x?.type === "message" && x.message?.role === "assistant" && x.message.provider)?.message;
528
+ if (!a) continue; // a prompt nothing answered
529
+ const model = { provider: a.provider, id: a.model };
530
+ const ts = Number(m.timestamp) || Date.parse(branch[i].timestamp) || 0;
531
+ const ent = models[modelKey(model)];
532
+ if (detectCold(model, 0, branch.slice(0, i), ts, "", (g, prior) => pWarm(ent, g, prior), this.tier(model)).cold) return false;
533
+ }
534
+ return true;
535
+ }
536
+
537
+ // ---- recall through files (research pi-tricks §2) -----------------------------------------------------------------------
538
+ /** This machine's directory for this session's recall files. */
539
+ private localRecallDir(ctx: Any): string | null {
540
+ const sid = this.recallSid(ctx);
541
+ return sid ? join(recallRoot(), sidDir(sid)) : null;
542
+ }
543
+ /** Write the originals of these handles (from the current branch) if they are not on disk as they are: their local paths. */
544
+ private materializeHandles(ctx: Any, handles: string[]): Map<string, string> {
545
+ const sid = this.recallSid(ctx);
546
+ if (!sid || !handles.length) return new Map();
547
+ const { items } = resolveHandlesInBranch(this.branch(ctx), handles);
548
+ const found = items.filter((it): it is typeof it & { text: string } => it.text !== null);
549
+ const out = materialize(recallRoot(), sid, found.map((it) => ({ handle: it.handle, text: it.text })));
550
+ this.ledger({ type: "recall_file", handles: found.map((it) => it.handle), missing: handles.length - found.length, chars: found.reduce((a, it) => a + it.text.length, 0) });
551
+ return out;
552
+ }
553
+ /** Every output this session's model no longer sees in full (folded, or summarised away): what a grep, ls or find over the directory needs. */
554
+ private materializeAll(ctx: Any): string | null {
555
+ const sid = this.recallSid(ctx);
556
+ if (!sid) return null;
557
+ let visible = new Set<string>();
558
+ try {
559
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
560
+ visible = new Set(entries.filter((pe) => pe?.sourceEntry?.type === "message" && pe.messages?.[0]?.role === "toolResult" && !textOf(pe.messages[0].content).startsWith(PH_MARK)).map((pe) => pe.sourceEntry.id));
561
+ } catch {}
562
+ const zipped = branchOutputs(this.branch(ctx)).filter((o) => o.tool !== RECALL_TOOL && !visible.has(o.entryId));
563
+ materialize(recallRoot(), sid, zipped.map((o) => ({ handle: o.handle, text: o.text })));
564
+ this.ledger({ type: "recall_file", all: true, count: zipped.length, chars: zipped.reduce((a, o) => a + o.text.length, 0) });
565
+ return join(recallRoot(), sidDir(sid));
566
+ }
567
+ /**
568
+ * tool_call: a built-in read, grep, find or ls whose path names a recall file (or this session's recall directory), or a bash command
569
+ * that does, gets this machine's copy, written now from the current branch if it is not on disk yet. Any prefix is accepted (another
570
+ * machine, another user, a forked session's id): the handle is resolved against the current branch. Never blocks a call.
571
+ */
572
+ toolCall(e: Any, ctx: Any): undefined {
573
+ try {
574
+ const input = e?.input, name = e?.toolName;
575
+ if (!input || typeof input !== "object") return undefined;
576
+ if ((name === "read" || name === "grep" || name === "find" || name === "ls") && typeof input.path === "string") {
577
+ const ref = parseRecallPath(input.path);
578
+ if (!ref) return undefined;
579
+ if (ref.handle) {
580
+ const local = this.materializeHandles(ctx, [ref.handle]).get(ref.handle);
581
+ if (local) input.path = local;
582
+ } else if (name !== "read") {
583
+ const dir = this.materializeAll(ctx);
584
+ if (dir) input.path = dir;
585
+ }
586
+ } else if (name === "bash" && typeof input.command === "string" && commandNamesRecall(input.command)) {
587
+ const dir = this.localRecallDir(ctx);
588
+ if (!dir) return undefined;
589
+ const r = rewriteCommand(input.command, dir);
590
+ if (r.dir) this.materializeAll(ctx);
591
+ else this.materializeHandles(ctx, r.handles);
592
+ input.command = r.command;
593
+ }
594
+ } catch (err) {
595
+ this.ledger({ type: "error", where: "tool_call", error: errText(err) });
596
+ }
597
+ return undefined;
280
598
  }
281
599
 
282
600
  shutdown() {
@@ -285,7 +603,8 @@ export class Zip implements ZipControl {
285
603
  this.bg = null;
286
604
  }
287
605
 
288
- beforeAgentStart(ctx: Any) {
606
+ async beforeAgentStart(ctx: Any, e?: Any): Promise<void> {
607
+ this.liveCtx = ctx;
289
608
  this.cancelTimer(); // the user is back: nothing is started behind their back any more
290
609
  this.model = ctx?.model;
291
610
  this.cold = false;
@@ -293,10 +612,13 @@ export class Zip implements ZipControl {
293
612
  this.runChecked = true;
294
613
  this.runEdits = false;
295
614
  this.runPlan = null;
615
+ this.nativeRun = null;
616
+ this.foreignRun = false;
296
617
  this.settleIds = [];
297
618
  this.bgWaitMs = 0;
298
619
  this.wireLedgered = false;
299
620
  this.runUserSeen = 0;
621
+ this.logForeign(ctx, "run");
300
622
  if (!this.active()) return;
301
623
  this.detectConflict(ctx);
302
624
  if (!this.active()) return;
@@ -305,7 +627,8 @@ export class Zip implements ZipControl {
305
627
  this.loadEnt(key);
306
628
  this.branchLastMs = lastMessageMs(branch);
307
629
  if (!this.prevKey) ({ key: this.prevKey, total: this.prevTotal } = lastPrompt(branch)); // fresh process: the session's newest response
308
- 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));
630
+ await this.resolveRetention(ctx);
631
+ 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), this.tier(ctx.model));
309
632
  this.cold = cold;
310
633
  if (cold) this.summarizedWarm = false; // a cold return may summarise again
311
634
  this.pastTtl = pastTtl;
@@ -313,7 +636,7 @@ export class Zip implements ZipControl {
313
636
  this.survSrc = src;
314
637
  this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
315
638
  this.coldReason = reason;
316
- 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` : "";
639
+ this.coldWhy = src === "model switch" ? "model switched" : cold && gapS !== null ? `away ${fmtAway(gapS)}` : "";
317
640
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
318
641
  for (let i = branch.length - 1; i >= 0; i--) {
319
642
  const en = branch[i];
@@ -328,12 +651,164 @@ export class Zip implements ZipControl {
328
651
  this.ttlNoticed = true; // once per session
329
652
  if (!this.quiet) this.stateLine(ctx, "cache", ttl.note.replace(`${PRODUCT} · `, ""));
330
653
  }
654
+ if (cold && this.bg?.done) {
655
+ try {
656
+ await this.nativeSummary(ctx, e);
657
+ } catch (err) {
658
+ const a = this.nativeAsk;
659
+ if (a && !a.answered) a.compaction = null; // a late ask of pi-zip's compaction is cancelled, never left to Pi's own summary
660
+ this.ledger({ type: "error", where: "native_summary", error: errText(err) });
661
+ }
662
+ }
663
+ }
664
+
665
+ /** session_before_compact: answers ONLY the compaction pi-zip itself asked for a moment ago (nativeAsk): its own summary and its own
666
+ * tokensBefore. Pi's threshold and overflow compactions, /compact and other extensions' compactions get undefined and run untouched.
667
+ * Once pi-zip has given up on its compaction (compaction null), a late ask of it is cancelled, never one with a live signal: that is
668
+ * another compaction (pi-zip's own was aborted). */
669
+ sessionBeforeCompact(e?: Any): Any {
670
+ const a = this.nativeAsk;
671
+ if (!a || a.answered || Date.now() > a.until) return undefined;
672
+ if (e?.reason !== undefined && e.reason !== "manual") return undefined; // ctx.compact() is a manual compaction
673
+ if (!a.compaction && e?.signal && e.signal.aborted !== true) return undefined;
674
+ a.answered = true;
675
+ a.askedAt = performance.now();
676
+ return a.compaction ? { compaction: a.compaction } : { cancel: true };
677
+ }
678
+
679
+ /** The native path ends for the rest of the process (logged once): from now on summaries ride along with the first request. */
680
+ private endNative(reason: string) {
681
+ if (this.nativeOff) return;
682
+ this.nativeOff = reason;
683
+ this.ledger({ type: "native_off", reason });
684
+ }
685
+
686
+ /** Why Pi's own compaction cannot take this summary now (null: it can). Each reason keeps today's path: the run plan carries the summary
687
+ * request-locally and turn_end persists it as a draft. */
688
+ private nativeGate(ctx: Any, branch: Any[], pre: Any[]): string | null {
689
+ if (this.nativeOff) return "off for this process";
690
+ if (typeof ctx?.compact !== "function") return "no ctx.compact";
691
+ if (typeof ctx?.abort !== "function") return "no ctx.abort"; // what cancels a compaction another handler holds up
692
+ // another extension supplies compactions in this session: its session_before_compact handler would run for pi-zip's too
693
+ if (branch.some(foreignCompaction)) return "another extension's compaction is in this branch";
694
+ let idle = false;
695
+ try {
696
+ idle = ctx.isIdle?.() === true; // compact() aborts whatever runs first: only ever called while Pi is idle (it is, here, in 1.1.0)
697
+ } catch {}
698
+ if (!idle) return "not idle";
699
+ if (branch[branch.length - 1]?.type === "compaction") return "already compacted";
700
+ let settings: Any;
701
+ try {
702
+ settings = this.pi.getSettings?.();
703
+ } catch {}
704
+ if (!piCanCompact(pre, keepRecentTokensFor(settings, ctx?.model))) return "nothing to compact";
705
+ return null;
706
+ }
707
+
708
+ /**
709
+ * A summary prepared while the user was away goes into the session through Pi's own compaction, before the run (research pi-tricks
710
+ * §1b, variant 1): ctx.compact() with session_before_compact answering pi-zip's summary and pi-zip's own tokensBefore (the notice's
711
+ * "before", provider scale), so Pi's "[compaction] Compacted from N tokens" shows that number. It takes milliseconds: the summary is
712
+ * done. The run plan is then made on the projection as it was before the compaction, so the run's first request carries the same
713
+ * bytes as the draft path would have sent; any gate that fails keeps the draft path.
714
+ * Pi runs every extension's session_before_compact handler for it (the last answer wins, a cancel stops it): unless pi-zip's own
715
+ * entry is the one that went in, fast, the native path ends for the process (endNative). A compaction not finished by the deadline
716
+ * is cancelled first, and a summary prepared for a context another extension's compaction has replaced is discarded, never adopted.
717
+ */
718
+ private async nativeSummary(ctx: Any, e: Any): Promise<void> {
719
+ const b = this.bg;
720
+ if (!b?.done || !b.key) return;
721
+ const branch = this.branch(ctx);
722
+ const pre = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
723
+ const skip = (reason: string) => this.ledger({ type: "native_skip", reason });
724
+ // the plan the first request will make: the same one, with the prompt Pi is about to append
725
+ const pending = pendingPrompt(e);
726
+ const entries = pending ? [...pre, pending] : pre;
727
+ const { o } = this.opts(ctx, !pending, "cold", entries, this.pWarm);
728
+ o.pastTtl = this.pastTtl;
729
+ const p = planContext(entries, o);
730
+ if (!p || p.cutIdx === null || p.firstKeptEntryId !== b.key) return skip("the summary does not fit this plan");
731
+ const cut = b.done;
732
+ if (checkEdits(p.blocks, p.userTurns, p.folds, cut)) return skip("guard");
733
+ const gate = this.nativeGate(ctx, branch, pre);
734
+ if (gate) return skip(gate);
735
+ const before = Math.round(p.ctxTokens);
736
+ const compaction = { summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: before, details: { by: PRODUCT, via: "native", trigger: cut.trigger }, usage: cut.usage };
737
+ const known = new Set(branch.map((en: Any) => en?.id));
738
+ const ask = { compaction: compaction as Any | null, answered: false, askedAt: 0, until: Infinity };
739
+ this.nativeAsk = ask;
740
+ const deadline = nativeDeadlineMs(), t0 = performance.now();
741
+ const res = await new Promise<{ ok: boolean; err?: string; late?: boolean }>((resolve) => {
742
+ let settled = false;
743
+ const finish = (r: { ok: boolean; err?: string; late?: boolean }) => {
744
+ if (settled) return;
745
+ settled = true;
746
+ clearTimeout(timer);
747
+ resolve(r);
748
+ };
749
+ const timer = setTimeout(() => {
750
+ if (!ask.answered) ask.compaction = null; // asked after this, pi-zip cancels: never Pi's own summary on pi-zip's behalf
751
+ ask.until = Date.now() + 60_000;
752
+ finish({ ok: false, late: true });
753
+ }, deadline);
754
+ timer.unref?.();
755
+ const done = (r: { ok: boolean; err?: string }) => {
756
+ if (this.nativeAsk === ask) this.nativeAsk = null; // pi-zip's compaction is over: nothing is answered for it any more
757
+ finish(r);
758
+ };
759
+ try {
760
+ ctx.compact({ onComplete: () => done({ ok: true }), onError: (err: Any) => done({ ok: false, err: err instanceof Error ? err.message : String(err) }) });
761
+ } catch (err) {
762
+ done({ ok: false, err: errText(err) });
763
+ }
764
+ });
765
+ const ms = Math.round(performance.now() - t0), askMs = ask.askedAt ? Math.round(ask.askedAt - t0) : null;
766
+ // what went in: compactions added since the call (pi-zip's is the one carrying its summary; Pi keeps the last handler's answer)
767
+ const added = this.branch(ctx).filter((en: Any) => en?.type === "compaction" && !known.has(en?.id));
768
+ const last = added[added.length - 1] ?? null;
769
+ const entry = last && last.summary === cut.text ? last : null;
770
+ // still pending at the deadline: cancel it now, before the run starts (Pi checks its abort signal right before it appends)
771
+ if (res.late && !last) {
772
+ try {
773
+ ctx.abort?.();
774
+ } catch {}
775
+ }
776
+ const off = entry
777
+ ? added.length > 1 ? "another compaction went in as well" : res.late || ms > Math.min(NATIVE_SLOW_MS, deadline / 2) ? `Pi's compaction took ${ms} ms: another session_before_compact handler at work` : null
778
+ : last ? "another extension's compaction went in instead of pi-zip's"
779
+ : res.late ? (ask.answered ? `Pi's compaction was not done ${deadline} ms after pi-zip answered (cancelled)` : `another session_before_compact handler ran before pi-zip's (cancelled after ${deadline} ms)`)
780
+ : `Pi's compaction failed: ${res.err ?? "no compaction entry"}`;
781
+ if (off) this.endNative(off);
782
+ if (!entry?.id) {
783
+ this.ledger({ type: "native_fail", ms, askMs, answered: ask.answered, error: res.late ? "deadline" : (res.err ?? "no compaction entry"), foreign: !!last });
784
+ if (last) {
785
+ // another extension's compaction replaced the context this summary was written for: adopting it, or writing a new one on
786
+ // top of it, would summarise twice. It is this stretch's summary (summarizedWarm, as after a pi-zip summary).
787
+ this.discardBg();
788
+ this.foreignRun = true;
789
+ this.summarizedWarm = true;
790
+ }
791
+ return; // nothing went in: the draft path, the run plan adopts the prepared summary as before
792
+ }
793
+ this.bg = null; // adopted by Pi's compaction
794
+ this.summarizedWarm = true; // one summary per warm stretch, as for a draft (commit)
795
+ // the summary is in the session from now on, whatever the run does: book it here (commit() does not book it again)
796
+ const cutSaved = Math.round(p.scale.c * (cut.prefixTokens - cut.summaryTokens));
797
+ this.nativeRun = { key: b.key, cut, before, after: Math.round(p.ctxAfterFolds - cutSaved), pre, entryId: entry.id };
798
+ const s = this.stats;
799
+ s.summaries++;
800
+ s.summarizedTokens += cutSaved;
801
+ s.summaryUsd += cut.costUsd;
802
+ s.activeSaved += cutSaved;
803
+ s.writeSavedTokens += cutSaved;
804
+ this.ledger({ type: "summary", via: "native", trigger: cut.trigger, source: "settle", count: cut.count, tokensBefore: before, prefixTokens: Math.round(p.scale.c * cut.prefixTokens), summaryTokens: Math.round(p.scale.c * cut.summaryTokens), ms: cut.ms, compactMs: ms, askMs, waitedMs: 0, llmOk: cut.llmOk, llmError: cut.llmError, costUsd: cut.costUsd, ...this.usageOf(cut) });
331
805
  }
332
806
 
333
807
  private cancelTimer() {
334
808
  this.timerGen++;
335
809
  if (this.timer) clearTimeout(this.timer);
336
810
  this.timer = null;
811
+ this.awayPlan = null;
337
812
  }
338
813
 
339
814
  /** A summary nobody adopted still cost money: abort it if it is still running, book it if it finished. */
@@ -364,20 +839,25 @@ export class Zip implements ZipControl {
364
839
  this.ledger({ type: "error", where: "context/run_plan", error: errText(err) });
365
840
  }
366
841
  }
842
+ if (ctx?.signal?.aborted) return undefined; // Esc (Pi cancelled the prompt): this request never goes out, so nothing is applied or announced
367
843
  if (this.runPlan && !this.runPlan.persisted) {
368
844
  const out = applyPlanToMessages(e.messages, this.runPlan);
369
- if (out) {
845
+ if (out || this.runPlan.native) { // a native summary is already in the request Pi built: it still goes out with this request
370
846
  if (!this.runPlan.sent) this.nextEdit = this.runPlan.untouched ?? 0;
371
847
  this.runPlan.sent = true;
372
- this.runPlan.applied = out.applied;
373
- return { messages: out.messages };
848
+ const applied = (this.runPlan.applied = out?.applied ?? this.runPlan.applied ?? new Set<string>());
849
+ if (!this.runPlan.noticed) { // say it where it happens: at the top of the turn, before the model answers
850
+ this.runPlan.noticed = true;
851
+ const live = this.runPlan.folds.filter((t) => applied.has(t.entryId));
852
+ this.noticeFor(ctx, this.runPlan, live, this.runPlan.cut ?? this.runPlan.native ?? null, this.runPlan.ctxBefore, 0);
853
+ }
854
+ return out ? { messages: out.messages } : undefined;
374
855
  }
375
856
  }
376
857
  return undefined;
377
858
  }
378
859
 
379
- /** A prepared summary for this cut, or null. A summary still being written is waited for (only the remainder); Esc stops the waiting, not the work. */
380
- /** While the user waits for a summary, Pi's working line says what for (and that Esc skips a prepared one). */
860
+ /** While the user waits for a summary, Pi's working line says what for. */
381
861
  private async working<T>(ctx: Any, text: string, p: Promise<T>): Promise<T> {
382
862
  const ui = ctx?.mode === "tui" ? ctx?.ui : null;
383
863
  try {
@@ -392,6 +872,8 @@ export class Zip implements ZipControl {
392
872
  }
393
873
  }
394
874
 
875
+ /** A prepared summary for this cut, or null. A summary still being written is waited for (only the remainder). Esc there cancels the
876
+ * whole prompt (Pi 1.1.0 aborts the run, research pi-tricks §3.6): the waiting stops, the summary goes on for the next try. */
395
877
  private async takeBg(key: string | null, signal?: AbortSignal, ctx?: Any): Promise<{ cut: Cut | null; adopted: boolean }> {
396
878
  const b = this.bg;
397
879
  if (!b || key === null || b.key !== key) {
@@ -399,7 +881,7 @@ export class Zip implements ZipControl {
399
881
  return { cut: null, adopted: false };
400
882
  }
401
883
  const w0 = performance.now();
402
- const cut = b.done ?? (await this.working(ctx, `${PRODUCT}: finishing the summary it started while you were away · Esc skips it`, waitFor(b.promise, signal)));
884
+ const cut = b.done ?? (await this.working(ctx, `${PRODUCT}: finishing the summary it started while you were away · Esc cancels this prompt`, waitFor(b.promise, signal)));
403
885
  this.bgWaitMs = performance.now() - w0;
404
886
  if (cut) {
405
887
  this.bg = null;
@@ -413,17 +895,36 @@ export class Zip implements ZipControl {
413
895
  /** ONE plan per edit run: computed at the first request (the new prompt is in the session), applied to every request until turn_end persists the same set. */
414
896
  private async computeRunPlan(ctx: Any) {
415
897
  const t0 = performance.now();
416
- const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
898
+ const now = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
899
+ // a summary that went in through Pi's compaction before the run (nativeSummary): plan on the projection as it was right before
900
+ // it, plus what came after, so folds, scale and placeholders are exactly what the draft path computes (the run's first request
901
+ // carries the same bytes); the request Pi builds already starts with the summary
902
+ const nat = this.nativeRun, foreign = this.foreignRun;
903
+ this.nativeRun = null;
904
+ this.foreignRun = false;
905
+ const spliced = nat ? spliceNative(nat.pre, now, nat.entryId) : null;
417
906
  if (!this.cold && this.justEdited) { // no warm edit on the request right after an edited one (no back-to-back warm rewrites)
418
907
  this.ledger({ type: "b2b_skip", where: "run" });
419
908
  this.discardBg();
420
909
  return;
421
910
  }
422
- const { o, cal } = this.opts(ctx, false, this.cold ? "cold" : "warm", entries, this.pWarm);
423
- o.pastTtl = this.pastTtl;
424
- const p = planContext(entries, o);
911
+ const plan = (entries: Any[]) => {
912
+ const { o, cal } = this.opts(ctx, false, this.cold ? "cold" : "warm", entries, this.pWarm);
913
+ o.pastTtl = this.pastTtl;
914
+ if (foreign) o.noSummary = true; // another extension's compaction just went in: folds only (a summary only at Pi's compaction room)
915
+ return { o, cal, p: planContext(entries, o) };
916
+ };
917
+ let { o, cal, p } = plan(spliced ?? now);
918
+ const onPre = !!nat && !!spliced && !!p && p.cutIdx !== null && p.firstKeptEntryId === nat.key;
919
+ if (nat && !onPre) {
920
+ // the summary is in the session, but this plan would not cut there (the prompt, or what came with it, changed the plan): plan on
921
+ // the session as it is now, with the summary at its head
922
+ this.ledger({ type: "native_mismatch", planned: p?.firstKeptEntryId ?? null, compacted: nat.key });
923
+ ({ o, cal, p } = plan(now));
924
+ }
425
925
  this.lawLedger("run", o);
426
- if (!p) {
926
+ const native = nat?.cut ?? null;
927
+ if (!p && !native) {
427
928
  this.discardBg(); // the law does not fire (warm below the cap or a rewrite that does not pay back; a likely-warm cold return): a prepared summary does not pay back
428
929
  return;
429
930
  }
@@ -431,35 +932,46 @@ export class Zip implements ZipControl {
431
932
  const trigger = this.cold ? "cold" : "valve";
432
933
  let folds: FoldTarget[] = p?.folds ?? [];
433
934
  let cut: Cut | null = null;
434
- let adopted = false;
435
- if (p && p.cutIdx !== null) {
935
+ let adopted = !!native; // Pi's compaction took the prepared summary
936
+ if (!native && p && p.cutIdx !== null) {
436
937
  const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
437
938
  cut = got.cut;
438
939
  adopted = got.adopted;
439
- } else {
940
+ } else if (!native) {
440
941
  this.discardBg();
441
942
  }
442
943
  const sameSet = this.settleIds.length === folds.length && this.settleIds.every((id) => folds.some((u) => u.entryId === id));
443
944
  const source: RunPlan["source"] = !this.cold ? "valve" : adopted || (this.settleIds.length > 0 && sameSet) ? "settle" : "runstart";
444
945
  const foldMs = performance.now() - t0;
445
- if (!cut && p && p.cutIdx !== null && !ctx.signal?.aborted) cut = await this.working(ctx, `${PRODUCT}: writing a summary (folding alone cannot make the context small enough)`, buildCut(p, ctx)); // produced now, before the first request: the user waits
946
+ if (!cut && !native && p && p.cutIdx !== null && !ctx.signal?.aborted) cut = await this.working(ctx, `${PRODUCT}: writing a summary (folding alone cannot make the context small enough)`, buildCut(p, ctx)); // produced now, before the first request: the user waits
446
947
  // a plan that cannot be persisted must never be sent: the request view would differ from what turn_end can write (I1)
447
948
  if (p) {
448
- const invalid = checkEdits(p.blocks, p.userTurns, folds, cut);
949
+ const invalid = checkEdits(p.blocks, p.userTurns, folds, cut ?? (onPre ? native : null));
449
950
  if (invalid) {
450
- this.ledger({ type: "guard_drop", where: "run_plan", reason: invalid, folds: folds.length, summary: !!cut });
451
- if (cut && checkEdits(p.blocks, p.userTurns, folds, null) === null) cut = null;
951
+ this.ledger({ type: "guard_drop", where: "run_plan", reason: invalid, folds: folds.length, summary: !!(cut ?? native) });
952
+ if (native) folds = []; // the summary is in the session already: only the folds can be dropped
953
+ else if (cut && checkEdits(p.blocks, p.userTurns, folds, null) === null) cut = null;
452
954
  else { folds = []; cut = null; }
453
955
  }
454
956
  }
455
- const ctxAfter = cut ? (p?.ctxAfterFolds ?? 0) - cal.k * (cut.prefixTokens - cut.summaryTokens) : (p?.ctxAfterFolds ?? 0);
456
- this.runPlan = { source, folds, cut, ctxBefore: p?.ctxTokens ?? 0, ctxAfter, ms: foldMs, k: cal.k, persisted: false, cutVisibleIdx: p?.cutVisibleIdx ?? -1, cutKeptFirstMsg: p?.cutKeptFirstMsg, untouched: p ? p.k * untouchedEst(p.blocks, folds, !!cut, o.sys) : 0 };
457
- this.ledger({ type: "cold_plan", trigger, source, folds: folds.length, summary: !!cut, ...this.calOf(cal), ctxBefore: Math.round(this.runPlan.ctxBefore), ctxAfter: Math.round(ctxAfter), ms: Math.round(foldMs), ...(cut ? { summaryMs: cut.ms, llmOk: cut.llmOk, waitedMs: Math.round(adopted ? this.bgWaitMs : cut.ms), ...this.usageOf(cut) } : {}) });
957
+ const sum = cut ?? (onPre ? native : null); // a summary this plan's sizes do not count yet
958
+ let ctxBefore = p?.ctxTokens ?? 0;
959
+ let ctxAfter = (p?.ctxAfterFolds ?? 0) - (sum ? cal.c * (sum.prefixTokens - sum.summaryTokens) : 0);
960
+ if (nat) {
961
+ // the notice's "before" is the number Pi's [compaction] block shows (computed with the prompt before Pi appended it)
962
+ ctxAfter = p ? ctxAfter + (onPre ? nat.before - ctxBefore : 0) : nat.after;
963
+ ctxBefore = nat.before;
964
+ }
965
+ this.runPlan = {
966
+ source, folds: onPre ? rebaseFolds(folds, p!.cutVisibleIdx) : folds, cut, native: native ?? undefined, ctxBefore, ctxAfter, ms: foldMs, scale: cal, persisted: false,
967
+ waitMs: cut ? (adopted ? this.bgWaitMs : cut.ms) : 0, cutVisibleIdx: p?.cutVisibleIdx ?? -1, cutKeptFirstMsg: p?.cutKeptFirstMsg, untouched: p ? sizeOf(cal, untouchedEst(p.blocks, folds, !!(cut ?? native))) : sizeOf(cal, 0),
968
+ };
969
+ this.ledger({ type: "cold_plan", trigger, source, folds: folds.length, summary: !!(cut ?? native), ...(p?.ahead ? { ahead: true } : {}), ...(native ? { via: "native", onPre } : {}), ...this.calOf(cal), ctxBefore: Math.round(ctxBefore), ctxAfter: Math.round(ctxAfter), ms: Math.round(foldMs), ...(cut ? { summaryMs: cut.ms, llmOk: cut.llmOk, waitedMs: Math.round(adopted ? this.bgWaitMs : cut.ms), ...this.usageOf(cut) } : {}) });
458
970
  }
459
971
 
460
- /** Ledger form of a calibration; ctxBefore/ctxAfter next to it are already scaled (k x estimate). */
461
- private calOf(cal: Calibration) {
462
- return { k: Math.round(cal.k * 1000) / 1000, calReal: cal.real, calEst: Math.round(cal.est), calSource: cal.source };
972
+ /** Ledger form of a calibration; ctxBefore/ctxAfter next to it are already real (O + c x estimate). */
973
+ private calOf(cal: Scale) {
974
+ return { O: Math.round(cal.O), c: Math.round(cal.c * 1000) / 1000, oSource: cal.oSource, calReal: cal.real, calEst: Math.round(cal.est), calSource: cal.source };
463
975
  }
464
976
 
465
977
  private usageOf(cut: Cut) {
@@ -490,8 +1002,11 @@ export class Zip implements ZipControl {
490
1002
  }
491
1003
  } else {
492
1004
  this.discardBg();
1005
+ // no cut now, but the timer still runs: if Pi's own compaction or another extension's edit rewrites the context while the user
1006
+ // is away, the plan made then may have one (runAway plans again when the context changed since this settle)
1007
+ scheduledMs = this.scheduleSummary(p, ctx);
493
1008
  }
494
- this.ledger({ type: "settle_plan", folds: p.folds.length, summary: p.cutIdx !== null, timerMs: scheduledMs, ...this.calOf(cal), ctxBefore: Math.round(p.ctxTokens), ms: Math.round(performance.now() - t0) });
1009
+ this.ledger({ type: "settle_plan", folds: p.folds.length, summary: p.cutIdx !== null, ...(p.ahead ? { ahead: true } : {}), timerMs: scheduledMs, ...this.calOf(cal), ctxBefore: Math.round(p.ctxTokens), ms: Math.round(performance.now() - t0) });
495
1010
  if (!p.folds.length && p.cutIdx === null) return undefined;
496
1011
  // ids only: placeholders and summary text are recomputed (they are pure functions of the session), never stored a second time
497
1012
  const data = { policy: PRODUCT, ts: Date.now(), targets: p.folds.map((t) => t.entryId), cutKey: p.firstKeptEntryId };
@@ -503,18 +1018,127 @@ export class Zip implements ZipControl {
503
1018
  }
504
1019
 
505
1020
  /** Start the away-timer for a summary; returns the delay in ms. unref'd: it never keeps the process alive. */
506
- private scheduleSummary(p: PlanResult, ctx: Any): number {
507
- const ttl = ttlFor(ctx.model, this.branch(ctx));
508
- const delay = Math.max(0, (this.lastReqMs || Date.now()) + AWAY_FRACTION * ttl - Date.now());
1021
+ private scheduleSummary(p: PlanResult, ctx: Any, lastMs?: number): number {
1022
+ const ttl = ttlFor(ctx.model, this.branch(ctx), this.tier(ctx.model));
1023
+ const last = lastMs ?? (this.lastReqMs || Date.now());
1024
+ this.awaySig = this.contextSig(ctx);
1025
+ const warm = this.idleWarming() ? warmingDelayMs(ttl) : undefined;
1026
+ if (warm !== undefined) {
1027
+ // Pi keeps the cache warm between runs (cacheWarming "idle") and decides before each refresh, at its warming delay: a summary
1028
+ // written now would be thrown away at a warm return, so it waits for Pi's decision (cacheWarmingDecision); should none come
1029
+ // (Pi is not warming this request after all), it starts just after the decision was due
1030
+ this.awayPlan = { p, ctx };
1031
+ const delay = Math.max(0, last + warm + warmingMargin(ttl) - Date.now());
1032
+ this.armAway(delay, () => this.startAway("no warming decision"));
1033
+ return Math.round(delay);
1034
+ }
1035
+ const delay = Math.max(0, last + AWAY_FRACTION * ttl - Date.now());
1036
+ this.armAway(delay, () => void this.runAway(p, ctx));
1037
+ return Math.round(delay);
1038
+ }
1039
+
1040
+ /** What the model would see of the branch, by its newest context entry: a message, edit, compaction or summary (our own bookkeeping
1041
+ * entries and other extensions' custom entries change nothing). A different one at the timer than at settle means something
1042
+ * rewrote the context in between (Pi's compaction, another extension's edit). */
1043
+ private awaySig = "";
1044
+ private contextSig(ctx: Any): string {
1045
+ const branch = this.branch(ctx);
1046
+ for (let i = branch.length - 1; i >= 0; i--) {
1047
+ const t = branch[i]?.type;
1048
+ if (t === "message" || t === "context_edit" || t === "compaction" || t === "branch_summary" || t === "custom_message") return `${t}:${branch[i].id}`;
1049
+ }
1050
+ return "";
1051
+ }
1052
+
1053
+ /** One timer for the away summary (unref'd: it never keeps the process alive); a newer one, or the user's return, cancels it. */
1054
+ private armAway(delayMs: number, fn: () => void) {
1055
+ if (this.timer) clearTimeout(this.timer);
509
1056
  const gen = ++this.timerGen;
510
- this.timer = setTimeout(() => void this.runAway(p, ctx, gen), delay);
1057
+ this.timer = setTimeout(() => {
1058
+ this.timer = null;
1059
+ if (gen === this.timerGen && this.active()) fn(); // else the user came back, or we were switched off
1060
+ }, Math.max(0, delayMs));
511
1061
  this.timer.unref?.();
512
- return Math.round(delay);
513
1062
  }
514
1063
 
515
- private async runAway(p: PlanResult, ctx: Any, gen: number) {
516
- this.timer = null;
517
- if (gen !== this.timerGen || !this.active()) return; // the user came back, or we were switched off
1064
+ // ---- Pi's own idle cache warming (research pi-tricks §3.3): observed, never answered ----------------------------------------
1065
+ private awayPlan: { p: PlanResult; ctx: Any } | null = null; // a summary waiting for Pi's warming decision
1066
+ /** Does Pi refresh the cache between runs by itself? (settings cacheWarming "idle"; the default "streaming" warms only during a run)
1067
+ * Pi reads it from the global settings only (settings-manager getCacheWarmingMode: warming costs money), so a project-level "idle"
1068
+ * in the merged settings warms nothing: the global file decides; only when it cannot be read do the merged settings. */
1069
+ private idleWarming(): boolean {
1070
+ try {
1071
+ if (this.pi.getSettings?.()?.cacheWarming !== "idle") return false;
1072
+ } catch {
1073
+ return false;
1074
+ }
1075
+ try {
1076
+ const global = JSON.parse(readFileSync(join(agentDir(), "settings.json"), "utf8"));
1077
+ return global?.cacheWarming === "idle";
1078
+ } catch {
1079
+ return true;
1080
+ }
1081
+ }
1082
+ private startAway(reason: string) {
1083
+ const a = this.awayPlan;
1084
+ this.awayPlan = null;
1085
+ if (!a) return;
1086
+ this.ledger({ type: "away_unblocked", reason });
1087
+ void this.runAway(a.p, a.ctx);
1088
+ }
1089
+ /** cache_warming_decision: Pi is about to refresh the cache ("warm") or to let it expire ("stop"). Only observed: never answered.
1090
+ * While Pi warms, the away summary waits; once Pi stops, it is prepared as without warming (the cache now expires on its own). */
1091
+ cacheWarmingDecision(e: Any): undefined {
1092
+ const action = e?.action;
1093
+ this.ledger({ type: "cache_warming", action, warmCost: e?.warmCost, missCost: e?.missCost, p: e?.continuationProbability, waiting: !!this.awayPlan });
1094
+ const a = this.awayPlan;
1095
+ if (!a || !this.active()) return undefined;
1096
+ if (action === "stop") {
1097
+ this.armAway(0, () => {
1098
+ this.awayPlan = a;
1099
+ this.startAway("Pi stopped warming");
1100
+ });
1101
+ return undefined;
1102
+ }
1103
+ if (action !== "warm") return undefined;
1104
+ // Pi refreshes now, unless another extension overrides the decision (its answer is not visible here): a refresh shows up as a
1105
+ // cache_warm usage entry within seconds. With one, wait for the next decision; without one, Pi stopped: prepare the summary now.
1106
+ const t = Date.now(), ttl = ttlFor(a.ctx.model, this.branch(a.ctx), this.tier(a.ctx.model));
1107
+ this.ledger({ type: "away_postponed", reason: "Pi warms the cache" });
1108
+ this.armAway(warmCheckMs(ttl), () => {
1109
+ this.awayPlan = a;
1110
+ let done = NaN; // when the refresh finished: its cache_warm entry is appended right before Pi schedules the next decision
1111
+ for (const en of this.branch(a.ctx)) if (en?.type === "usage" && en.kind === "cache_warm" && Date.parse(en.timestamp) >= t) done = Date.parse(en.timestamp);
1112
+ if (Number.isNaN(done)) return this.startAway("no refresh after a warm decision");
1113
+ // Pi's next decision is due one warming delay after the refresh finished (cache-warmer.js schedule), not after the decision
1114
+ this.armAway(done + (warmingDelayMs(ttl) ?? ttl) + warmingMargin(ttl) - Date.now(), () => {
1115
+ this.awayPlan = a;
1116
+ this.startAway("no warming decision");
1117
+ });
1118
+ });
1119
+ return undefined;
1120
+ }
1121
+
1122
+ private async runAway(p0: PlanResult, ctx: Any) {
1123
+ if (!this.active()) return;
1124
+ let p = p0;
1125
+ if (this.contextSig(ctx) === this.awaySig && p0.cutIdx === null) return; // nothing was planned and nothing changed
1126
+ if (this.contextSig(ctx) !== this.awaySig) {
1127
+ // something rewrote the context since the plan was made (Pi's own compaction, another extension's edit): the summary prepared
1128
+ // for the old plan would be discarded at the return (its cut is not this plan's). Plan again on the session as it is now.
1129
+ try {
1130
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
1131
+ const { o } = this.opts(ctx, true, "cold", entries);
1132
+ const q = entries.length ? planContext(entries, o) : null;
1133
+ this.ledger({ type: "away_replan", from: p0.firstKeptEntryId, to: q?.firstKeptEntryId ?? null });
1134
+ if (!q || q.cutIdx === null) return this.discardBg();
1135
+ if (this.bg && this.bg.key !== q.firstKeptEntryId) this.discardBg();
1136
+ p = q;
1137
+ } catch (err) {
1138
+ this.ledger({ type: "error", where: "away_replan", error: errText(err) });
1139
+ return;
1140
+ }
1141
+ }
518
1142
  const controller = new AbortController();
519
1143
  const bg: Bg = { key: p.firstKeptEntryId, done: null, promise: Promise.resolve(null), controller };
520
1144
  bg.promise = buildCut(p, ctx, controller.signal)
@@ -528,6 +1152,7 @@ export class Zip implements ZipControl {
528
1152
  }
529
1153
 
530
1154
  async turnEnd(e: Any, ctx: Any): Promise<Any> {
1155
+ this.logForeign(ctx, "turn_end");
531
1156
  if (!this.active()) return undefined;
532
1157
  try {
533
1158
  const fresh = this.collectSteer(e); // marked before anything is validated: a steering message is part of its turn
@@ -564,7 +1189,7 @@ export class Zip implements ZipControl {
564
1189
  const userTurns = countUserTurns(blocks);
565
1190
  const u = msg?.usage;
566
1191
  const usageTotal = u ? (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) : 0;
567
- const o = { t0, blocks, userTurns, sys: this.sysTokens(ctx), usageTotal };
1192
+ const o = { t0, blocks, userTurns, usageTotal };
568
1193
  if (this.runEdits) {
569
1194
  this.runEdits = false;
570
1195
  if (!this.runPlan?.sent && this.runPlan && (this.runPlan.folds.length || this.runPlan.cut)) {
@@ -590,14 +1215,44 @@ export class Zip implements ZipControl {
590
1215
  const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
591
1216
  cut = got.cut ?? (await this.working(ctx, `${PRODUCT}: writing a summary (the context is close to the window)`, buildCut(p, ctx))); // the user is here and the cache is warm; the alternative is Pi's lossy compaction
592
1217
  } else this.discardBg();
593
- const plan: RunPlan = { source: "valve", folds: p.folds, cut, ctxBefore: p.ctxTokens, ctxAfter: p.ctxAfterFolds, ms: performance.now() - t0, k: p.k, persisted: false, untouched: p.k * untouchedEst(p.blocks, p.folds, !!cut, popts.sys) };
1218
+ const ctxAfter = p.ctxAfterFolds - (cut ? p.scale.c * (cut.prefixTokens - cut.summaryTokens) : 0);
1219
+ const plan: RunPlan = { source: "valve", folds: p.folds, cut, ctxBefore: p.ctxTokens, ctxAfter, ms: performance.now() - t0, scale: p.scale, persisted: false, untouched: sizeOf(p.scale, untouchedEst(p.blocks, p.folds, !!cut)) };
594
1220
  return this.commit(e, ctx, plan, o);
595
1221
  }
596
1222
 
597
- private commit(e: Any, ctx: Any, plan: RunPlan, o: { t0: number; blocks: Block[]; userTurns: number; sys: number; usageTotal: number }) {
1223
+ /**
1224
+ * The transcript notice for one plan. Facts only, no claims: what changed (sizes), how long it took, and, expanded, why now
1225
+ * ("away 47 min") and what was folded. Whether the cache really was cold is learned from the response, silently (/zip shows it).
1226
+ * `realAfter` > 0: the size the provider reported for the request that carried the plan (shown instead of the estimate).
1227
+ */
1228
+ private noticeFor(ctx: Any, plan: RunPlan, live: FoldTarget[], cut: Cut | null, ctxTokens: number, realAfter: number) {
1229
+ if (this.quiet || (!live.length && !cut)) return;
1230
+ const { c } = plan.scale, valve = plan.source === "valve"; // amounts scale by c; sizes are O + c x content (plan.ts)
1231
+ const before = c * live.reduce((a, t) => a + t.entryTokens, 0), after = c * live.reduce((a, t) => a + t.phTokens, 0);
1232
+ const cutSaved = cut ? c * (cut.prefixTokens - cut.summaryTokens) : 0;
1233
+ const estAfter = ctxTokens - (before - after) - cutSaved;
1234
+ const waitMs = cut ? (plan.waitMs ?? (plan.source === "settle" ? this.bgWaitMs : cut.ms)) : 0;
1235
+ const notices: NoticeAction[] = [];
1236
+ if (live.length) notices.push({ kind: "fold", count: live.length, tokensBefore: ctxTokens, tokensAfter: cut || !realAfter ? ctxTokens - (before - after) : realAfter, ms: plan.ms, pressure: valve });
1237
+ if (cut) {
1238
+ const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
1239
+ notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: realAfter || estAfter, ms: Math.max(cut.ms, waitMs), waitMs, prepared, pressure: valve });
1240
+ }
1241
+ for (const t of live) this.labels.set(handleFor(t.entryId), `${t.tool}${t.args ? " " + t.args : ""}`);
1242
+ const ITEMS = 8;
1243
+ const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, tokens: Math.round(c * t.entryTokens), handle: handleFor(t.entryId) }));
1244
+ const why = valve || !this.cold ? "cache warm · worth it at this size" : this.coldWhy;
1245
+ // reqTs: a run plan is announced as its first request goes out, a valve plan at turn_end, before the next request that first carries
1246
+ // it; either way the first assistant message stamped at or after this moment answered the request that carried the plan
1247
+ 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), reqTs: Date.now() };
1248
+ this.noticed = true;
1249
+ this.notify(ctx, data.text, data);
1250
+ }
1251
+
1252
+ private commit(e: Any, ctx: Any, plan: RunPlan, o: { t0: number; blocks: Block[]; userTurns: number; usageTotal: number }) {
598
1253
  plan.persisted = true;
599
- const k = plan.k; // one scale for the whole run: stats, notices and the ledger agree with what the plan compared against the limits
600
- const ctxTokens = k * (o.sys + o.blocks.reduce((a, b) => a + b.tokens, 0));
1254
+ const sc = plan.scale, c = sc.c; // one scale for the whole run: stats, notices and the ledger agree with what the plan compared against the limits
1255
+ const ctxTokens = sizeOf(sc, o.blocks.reduce((a, b) => a + b.tokens, 0));
601
1256
  const byId = new Map(o.blocks.map((b) => [b.entryId, b]));
602
1257
  const wasApplied = (t: FoldTarget) => !plan.applied || plan.applied.has(t.entryId); // persist what the requests actually carried
603
1258
  const live = plan.folds.filter((t) => byId.has(t.entryId) && wasApplied(t));
@@ -615,12 +1270,16 @@ export class Zip implements ZipControl {
615
1270
  return undefined;
616
1271
  }
617
1272
  if (!plan.sent) this.nextEdit = plan.untouched ?? 0; // persisted now, first sent with the next request
1273
+ const sentBefore = !!plan.sent;
618
1274
  const ours: Any[] = live.map((t) => ({ type: "context_edit", targetId: t.entryId, replacement: { content: [{ type: "text", text: t.ph }] } }));
619
1275
  for (const t of live) if (t.toolCallId) this.foldedCalls.set(t.toolCallId, handleFor(t.entryId));
620
- if (cut) ours.push({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger }, usage: cut.usage });
621
- const before = Math.round(k * live.reduce((a, t) => a + t.entryTokens, 0));
622
- const after = Math.round(k * live.reduce((a, t) => a + t.phTokens, 0));
623
- const cutSaved = cut ? Math.round(k * (cut.prefixTokens - cut.summaryTokens)) : 0;
1276
+ // the compaction draft goes FIRST: Pi writes its tokensBefore from the newest usage only while no edit follows that usage, so a
1277
+ // fold drafted before it would make Pi's "Compacted from N tokens" the chars/4 of a half-applied projection (research pi-tricks §1a)
1278
+ if (cut) ours.unshift({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger, carried: sentBefore }, usage: cut.usage });
1279
+ ours.push({ type: "custom", customType: COMMIT_CUSTOM, data: { carried: sentBefore } }); // says who first carried these edits (state.ts, plan.ts calibrate)
1280
+ const before = Math.round(c * live.reduce((a, t) => a + t.entryTokens, 0));
1281
+ const after = Math.round(c * live.reduce((a, t) => a + t.phTokens, 0));
1282
+ const cutSaved = cut ? Math.round(c * (cut.prefixTokens - cut.summaryTokens)) : 0;
624
1283
  const waitMs = cut ? (plan.source === "settle" ? this.bgWaitMs : cut.ms) : 0;
625
1284
  const s = this.stats;
626
1285
  s.folds += live.length;
@@ -641,25 +1300,13 @@ export class Zip implements ZipControl {
641
1300
  if (live.length) this.ledger({
642
1301
  type: "fold", trigger: plan.source, turnIndex: e.turnIndex, count: live.length, entries: live.map((t) => t.entryId), tools: live.map((t) => t.tool),
643
1302
  recoverability: live.map((t) => t.recover ?? "?"), trigs: live.map((t) => t.trig), handles: live.map((t) => handleFor(t.entryId)),
644
- entryTokensBefore: before, entryTokensAfter: after, ctxBefore: Math.round(ctxTokens), ctxAfter: Math.round(ctxTokens - (before - after) - cutSaved), k: Math.round(k * 1000) / 1000, usageTotal: o.usageTotal,
1303
+ entryTokensBefore: before, entryTokensAfter: after, ctxBefore: Math.round(ctxTokens), ctxAfter: Math.round(ctxTokens - (before - after) - cutSaved), O: Math.round(sc.O), c: Math.round(sc.c * 1000) / 1000, usageTotal: o.usageTotal,
645
1304
  coldReason: this.coldReason, prepared: plan.source === "settle", ...(stale ? { staleTargets: stale } : {}),
646
1305
  });
647
- if (cut) this.ledger({ type: "summary", trigger: cut.trigger, source: plan.source, count: cut.count, prefixTokens: cut.prefixTokens, summaryTokens: cut.summaryTokens, ms: cut.ms, waitedMs: Math.round(waitMs), llmOk: cut.llmOk, llmError: cut.llmError, costUsd: cut.costUsd, ...this.usageOf(cut) });
648
- const valve = plan.source === "valve";
649
- const notices: NoticeAction[] = [];
650
- if (live.length) notices.push({ kind: "fold", count: live.length, tokensBefore: ctxTokens, tokensAfter: ctxTokens - (before - after), ms: plan.ms + (performance.now() - o.t0), pressure: valve });
651
- if (cut) {
652
- const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
653
- notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: ctxTokens - (before - after) - cutSaved, ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
654
- }
655
- if (notices.length && !this.quiet) {
656
- const ITEMS = 8;
657
- 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) }));
658
- 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";
659
- 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) };
660
- this.noticed = true;
661
- this.notify(ctx, data.text, data);
662
- }
1306
+ if (cut) this.ledger({ type: "summary", trigger: cut.trigger, source: plan.source, count: cut.count, prefixTokens: Math.round(c * cut.prefixTokens), summaryTokens: Math.round(c * cut.summaryTokens), ms: cut.ms, waitedMs: Math.round(waitMs), llmOk: cut.llmOk, llmError: cut.llmError, costUsd: cut.costUsd, ...this.usageOf(cut) });
1307
+ // a run plan was announced when its first request went out; a valve plan (between turns) is announced now
1308
+ if (!plan.noticed) this.noticeFor(ctx, plan, live, cut, ctxTokens, plan.source === "valve" ? 0 : o.usageTotal);
1309
+ plan.noticed = true;
663
1310
  return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
664
1311
  }
665
1312
 
@@ -669,17 +1316,21 @@ export class Zip implements ZipControl {
669
1316
  // the free cache signal: this response's cacheRead against what this request re-sent unchanged, after this gap (same model only)
670
1317
  this.pending = { gapS: last && (this.lastModelKey || this.prevKey) === key ? (now - last) / 1000 : -1, expect: edited ? this.nextEdit! : this.prevTotal, edited, key };
671
1318
  this.nextEdit = null;
1319
+ this.reqMsBefore = this.lastReqMs;
672
1320
  this.lastReqMs = now;
673
1321
  this.lastModelKey = key;
674
1322
  this.stats.readSavedTokens += this.stats.activeSaved;
675
1323
  const p: Any = e.payload;
1324
+ const seen = payloadRetention(p); // what this very request asked the provider for: it wins over any environment
1325
+ if (seen && key && (seen !== this.retention?.tier || this.retention?.key !== key || this.retention?.src !== "payload")) {
1326
+ this.retention = { key, tier: seen, src: "payload" };
1327
+ this.ledger({ type: "retention", ...this.retention });
1328
+ }
676
1329
  if (process.env.PI_ZIP_LEDGER && this.cold && !this.wireLedgered && p && Array.isArray(p.messages)) {
677
- this.wireLedgered = true; // proof that the FIRST request after a cold return is already small
1330
+ this.wireLedgered = true; // proof that the FIRST request after a cold return is already small (real scale: O + c x the wire's content)
678
1331
  let tok = 0;
679
- for (const m of p.messages) tok += tok4(JSON.stringify(m?.content ?? "")) + 8;
680
- const sys = p.system;
681
- tok += typeof sys === "string" ? tok4(sys) : Array.isArray(sys) ? tok4(sys.map((x: Any) => x?.text ?? "").join("\n")) : 0;
682
- this.ledger({ type: "cold_wire", tokens: Math.round(tok), messages: p.messages.length, folds: this.runPlan && !this.runPlan.persisted ? this.runPlan.folds.length : 0, summary: !!this.runPlan?.cut, source: this.runPlan?.source ?? null });
1332
+ for (const m of p.messages) if (m?.role !== "system" && m?.role !== "developer") tok += tok4(JSON.stringify(m?.content ?? "")) + 8;
1333
+ this.ledger({ type: "cold_wire", tokens: Math.round(sizeOf(this.runPlan?.scale ?? UNIT, tok)), messages: p.messages.length, folds: this.runPlan && !this.runPlan.persisted ? this.runPlan.folds.length : 0, summary: !!this.runPlan?.cut, source: this.runPlan?.source ?? null });
683
1334
  }
684
1335
  if (this.off) return undefined; // strict no-op
685
1336
  const r = repairPayload(p);
@@ -690,6 +1341,7 @@ export class Zip implements ZipControl {
690
1341
 
691
1342
  messageEnd(m: Any) {
692
1343
  if (m?.role === "assistant" && m.stopReason !== "error" && m.stopReason !== "aborted") this.lastReqMs = Date.now(); // the response is complete: the entry was refreshed when it finished
1344
+ else if (refused(m)) this.lastReqMs = this.reqMsBefore; // the provider refused the request: it touched no cache (cache.ts lastMessageMs reads the branch the same way)
693
1345
  if (m?.role === "user" && this.active()) {
694
1346
  // the first user message of a run is the prompt; later ones (steering, follow-up) are part of that run's turn. Pi does not mark them, we do.
695
1347
  if (this.runUserSeen++ > 0 && typeof m.timestamp === "number") this.steerTs.add(m.timestamp);
@@ -741,19 +1393,30 @@ export class Zip implements ZipControl {
741
1393
  private totals(ctx: Any) {
742
1394
  const branch = ctx ? this.branch(ctx) : [];
743
1395
  const byId = new Map(branch.map((e: Any) => [e?.id, e]));
744
- let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
1396
+ let folds = 0, summaries = 0, recalls = 0, usd = 0, key = "";
745
1397
  let last: Any = null;
1398
+ const fileRecalls = new Set<string>(); // tool call ids of built-in reads of recall files (file recall): unzips too
1399
+ const byModel = new Map<string, number>(); // chars/4 folded, by the model of the newest response before the fold (the one that planned it)
746
1400
  for (const e of branch) {
747
1401
  const ph = String(e?.replacement?.content?.[0]?.text ?? "");
1402
+ if (e?.type === "message" && e.message?.role === "assistant" && (e.message.provider || e.message.model)) key = `${e.message.provider ?? ""}/${e.message.model ?? ""}`;
1403
+ if (e?.type === "message" && e.message?.role === "assistant" && Array.isArray(e.message.content))
1404
+ for (const c of e.message.content) if (c?.type === "toolCall" && (c.name === "bash" ? commandNamesRecall(c.arguments?.command) : ["read", "grep", "find", "ls"].includes(c.name) && !!parseRecallPath(c.arguments?.path))) fileRecalls.add(c.id);
748
1405
  if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
749
1406
  folds++;
750
- folded += Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4;
1407
+ byModel.set(key, (byModel.get(key) ?? 0) + Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4);
751
1408
  } else if (e?.type === "compaction" && e.details?.by === PRODUCT) {
752
1409
  summaries++;
753
1410
  usd += Number(e.usage?.cost?.total) || 0;
754
1411
  } else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
755
1412
  else if (e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.v === 2 && !e.data?.kind) last = e;
756
- else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
1413
+ else if (e?.type === "message" && e.message?.role === "toolResult" && (e.message?.toolName === RECALL_TOOL || fileRecalls.has(e.message?.toolCallId))) recalls++;
1414
+ }
1415
+ // chars/4 of the folded text, on the provider's scale: x c of the model that folded it (folded outputs are content)
1416
+ let folded = 0;
1417
+ if (folds) {
1418
+ const entries = byModel.size > 1 ? ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) : undefined;
1419
+ for (const [k, v] of byModel) folded += v * (byModel.size > 1 && k ? this.scaleAt(ctx, entries, k) : this.scaleOf(ctx)).c;
757
1420
  }
758
1421
  return { folds, folded, summaries, recalls, usd, last };
759
1422
  }
@@ -761,37 +1424,40 @@ export class Zip implements ZipControl {
761
1424
  private sessionTotals(ctx: Any): string {
762
1425
  const { folds, folded, summaries, recalls, usd } = this.totals(ctx);
763
1426
  const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
764
- 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")}`;
1427
+ return `this session: ${folds} zipped (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "unzip", "unzips")}`;
765
1428
  }
766
- /** The /zip status card (rendered by ui.ts renderCard). */
1429
+ /** The /zip card (rendered by ui.ts renderCard). */
767
1430
  card(ctx?: Any): CardData {
768
1431
  const t = this.totals(ctx);
769
1432
  const model = ctx?.model ?? this.model;
770
- const key = modelKey(model), ttlS = ttlFor(model) / 1000, ent = key ? loadStats().models[key] : undefined;
771
- const alive = key ? SPARK_GAPS.map((g) => pWarm(ent, g, ttlS).p) : undefined;
1433
+ const key = modelKey(model), ttlS = ttlFor(model, [], this.tier(model)) / 1000, ent = key ? loadStats().models[key] : undefined;
772
1434
  let lifeS = 0;
773
1435
  for (let g = 30; g <= 7200; g += 30) if (pWarm(ent, g, ttlS).p >= 0.5) lifeS = g;
774
- const learned = !!ent && ent.n > 0 && pWarm(ent, Math.max(30, lifeS), ttlS).src === "learned";
775
- const life = key ? `${lifeS >= 7200 ? "over 2 h" : fmtLife(lifeS || ttlS)} (${learned ? `learned from ${Math.round(ent!.n)} ${Math.round(ent!.n) === 1 ? "reply" : "replies"}` : "declared"})` : undefined;
1436
+ const measured = !!ent && ent.n > 0 && pWarm(ent, Math.max(30, lifeS), ttlS).src === "learned";
1437
+ const life = key ? (lifeS >= 7200 ? "2 h+" : fmtLife(lifeS || ttlS)) : undefined;
1438
+ const alive = key ? Array.from({ length: CHART_COLS }, (_, i) => pWarm(ent, chartGap(i), ttlS).p) : undefined;
776
1439
  const ts = t.last?.timestamp ? new Date(t.last.timestamp) : null;
777
1440
  const hhmm = ts && !Number.isNaN(ts.getTime()) ? `${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")} ` : "";
778
1441
  const last = t.last ? `${hhmm}${t.last.data.desc}${t.last.data.why ? " · " + t.last.data.why : ""}` : undefined;
779
- const state = this.off ? "off" : this.conflict ? "paused" : this.recallOk ? "on" : "reread-only";
1442
+ const file = this.recallMode === "file" && !!this.fileRecall(ctx);
1443
+ const state = this.off ? "off" : this.conflict ? "paused" : this.recallMode === "tool" || file ? "on" : "reread-only"; // file recall is a full mode
1444
+ const rereadWhy = this.recallMode === "file" ? "a tool allowlist hides zip_recall · no session file, so no recall copies" : "a tool allowlist hides zip_recall · only re-readable outputs fold";
780
1445
  return {
781
- state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : undefined,
782
- model: key || undefined, cls: ent?.cls, life, alive, folds: t.folds, foldedTokens: fmtK(t.folded), summaries: t.summaries, summaryUsd: t.usd, recalls: t.recalls, last, quiet: this.quiet,
1446
+ state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : state === "reread-only" ? rereadWhy : undefined,
1447
+ model: key || undefined, life, measured, alive, folds: t.folds, foldedTokens: fmtK(t.folded), summaries: t.summaries, summaryUsd: t.usd, recalls: t.recalls, last, quiet: this.quiet,
1448
+ ...(file ? { unzip: `from files in ${recallRootShown()} (a tool allowlist hides zip_recall)` } : {}),
783
1449
  };
784
1450
  }
785
1451
  status(ctx?: Any): string {
786
1452
  const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
787
- const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
1453
+ const key = modelKey(this.model), ttl = ttlFor(this.model, [], this.tier()), ent = loadStats().models[key];
788
1454
  const pr = lawPrices(ent?.cls, ttl >= 3_600_000);
789
1455
  const cache = !key ? "" : `\ncache ${key}: ${pr ? `${pr.cls} (from usage)` : "class unknown until the first response"}` +
790
1456
  `, ${pr ? `read ${+pr.r.toFixed(4)} / write ${+pr.w.toFixed(4)} / output ${+pr.out.toFixed(4)} x input (class ratios)` : "no prices (legacy rule)"}` +
791
1457
  `, ${describe(ent, ttl / 1000)}, growth ${(this.g / 1000).toFixed(1)}K/request`;
792
1458
  return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}${this.sessionTotals(ctx)}${cache}`;
793
1459
  }
794
- statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (token counts are chars/4 estimates)`;
1460
+ statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (tokens on the provider's scale, calibrated from this session's usage)`;
795
1461
  setOff(off: boolean): string {
796
1462
  this.off = off;
797
1463
  if (off) { this.runPlan = null; this.runEdits = false; this.cancelTimer(); this.discardBg(); }