pi-zip 0.2.8 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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, curve, 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, FOLD_HOLD, 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, clamp, textOf, tok4, tokensOf } from "./util.ts";
15
- import { fmtLife, SPARK_GAPS, type CardData, type StateWord } from "./ui.ts";
15
+ import { type Any, COLD_CUSTOM, COMMIT_CUSTOM, envInt, PRODUCT, textOf, tok4 } from "./util.ts";
16
+ import { replayGrowth } from "./state.ts";
17
+ import { fmtLife, type CardData, type ChartBin, 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;
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;
124
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,81 @@ 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, ...(FOLD_HOLD && mode === "warm" ? this.holdOf(ctx) : {}), reserve: this.reserve(ctx), steerIds: this.steerIds, law: this.law(ctx, pWarm), trace: [] } };
391
+ }
392
+
393
+ /** The prompt of the session's last cold return: the first user message after the newest COLD_CUSTOM entry (none: nothing is held). */
394
+ private holdOf(ctx: Any): { hold?: { anchor: string } } {
395
+ const branch = this.branch(ctx);
396
+ let mark = -1;
397
+ branch.forEach((e: Any, i: number) => { if (e?.type === "custom" && e.customType === COLD_CUSTOM) mark = i; });
398
+ if (mark < 0) return {};
399
+ const at = branch.findIndex((e: Any, i: number) => i > mark && e?.type === "message" && e.message?.role === "user" && !this.steerIds.has(e.id));
400
+ return at < 0 ? {} : { hold: { anchor: branch[at].id } };
223
401
  }
224
402
 
225
403
  private law(ctx: Any, pWarm: number): Law {
226
404
  const m = ctx?.model ?? this.model;
227
405
  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 };
406
+ return { pr: lawPrices(ent?.cls, resolveTtl(m, this.branch(ctx), this.tier(m)).ms >= 3_600_000), g: this.g, pWarm };
229
407
  }
230
408
  private entKey = "";
231
409
  private loadEnt(key: string) {
@@ -242,6 +420,25 @@ export class Zip implements ZipControl {
242
420
  });
243
421
  }
244
422
 
423
+ /** Ledger only (research pi-tricks §3.5): entries another context manager wrote into this branch, each logged once: a context_edit
424
+ * that is neither pi-zip's placeholder nor Pi's own omission (null), or a compaction an extension other than pi-zip supplied.
425
+ * Nothing is decided from it. */
426
+ private foreignSeen = new Set<string>();
427
+ private logForeign(ctx: Any, where: string) {
428
+ if (!process.env.PI_ZIP_LEDGER || this.off) return; // the ledger is the only reader
429
+ for (const e of this.branch(ctx)) {
430
+ if (typeof e?.id !== "string" || this.foreignSeen.has(e.id)) continue;
431
+ let rec: Record<string, unknown> | null = null;
432
+ if (e.type === "context_edit" && e.replacement != null) {
433
+ const text = textOf(e.replacement.content);
434
+ if (!text.startsWith(PH_MARK)) rec = { kind: "context_edit", targetId: e.targetId ?? null, chars: text.length, head: text.slice(0, 60) };
435
+ } else if (foreignCompaction(e)) rec = { kind: "compaction", by: e.details?.by ?? null, firstKeptEntryId: e.firstKeptEntryId ?? null, chars: String(e.summary ?? "").length };
436
+ if (!rec) continue;
437
+ this.foreignSeen.add(e.id);
438
+ this.ledger({ type: "foreign_edit", where, id: e.id, ...rec });
439
+ }
440
+ }
441
+
245
442
  private detectConflict(ctx: Any) {
246
443
  let found: string | null = null;
247
444
  try {
@@ -262,6 +459,7 @@ export class Zip implements ZipControl {
262
459
 
263
460
  // ---- events ----------------------------------------------------------------------------------------------
264
461
  sessionStart(ctx: Any) {
462
+ this.liveCtx = ctx;
265
463
  const branch = this.branch(ctx);
266
464
  for (let i = branch.length - 1; i >= 0; i--) {
267
465
  const en = branch[i];
@@ -275,8 +473,138 @@ export class Zip implements ZipControl {
275
473
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
276
474
  this.model = ctx?.model;
277
475
  this.ttlNoticed = false;
476
+ this.restoreState(branch);
278
477
  this.detectConflict(ctx);
279
- this.ledger({ type: "session_start", off: this.off, quiet: this.quiet, conflict: this.conflict });
478
+ let recallFilesRemoved = 0;
479
+ try {
480
+ recallFilesRemoved = cleanupRecall(); // recall copies older than 14 days (they can be written again from the session)
481
+ } catch {}
482
+ this.ledger({ type: "session_start", off: this.off, quiet: this.quiet, conflict: this.conflict, ...(recallFilesRemoved ? { recallFilesRemoved } : {}) });
483
+ }
484
+
485
+ /** A resumed (or restarted, reloaded, forked) process arms the away timer from the branch's last request, as `settle` would have after
486
+ * 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
487
+ * 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
488
+ * the recall mode is known (it decides which outputs fold, so which plan the return will make). */
489
+ resumeAway(ctx: Any) {
490
+ try {
491
+ const branch = this.branch(ctx);
492
+ if (!this.active() || !ctx?.hasUI || !ctx?.model) return;
493
+ const last = [...branch].reverse().find((en: Any) => en?.type === "message" && en.message);
494
+ const m = last?.message;
495
+ if (m?.role !== "assistant" || m.stopReason === "toolUse") return;
496
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
497
+ if (!entries.length) return;
498
+ this.loadEnt(modelKey(ctx.model));
499
+ const { o } = this.opts(ctx, true, "cold", entries);
500
+ const p = planContext(entries, o);
501
+ if (!p || p.cutIdx === null) return;
502
+ const at = lastMessageMs(branch);
503
+ const delayMs = this.scheduleSummary(p, ctx, at || undefined);
504
+ this.ledger({ type: "away_resumed", timerMs: delayMs, lastRequestMs: at });
505
+ } catch (err) {
506
+ this.ledger({ type: "error", where: "resume_away", error: errText(err) });
507
+ }
508
+ }
509
+
510
+ /** What a process that never left would know: growth g, "the newest response carried an edit" and "a summary was made in this warm
511
+ * stretch" are functions of the session, rebuilt from the branch (state.ts; a restart, a resume or a fork plans like the live process). */
512
+ private restoreState(branch: Any[]) {
513
+ try {
514
+ const r = replayGrowth(branch, G0);
515
+ this.g = r.g;
516
+ this.deltas = r.deltas;
517
+ this.justEdited = r.justEdited;
518
+ this.summarizedWarm = this.summarizedSince(branch);
519
+ if (branch.length) this.ledger({ type: "state_restored", g: Math.round(this.g), justEdited: this.justEdited, summarizedWarm: this.summarizedWarm });
520
+ } catch (err) {
521
+ this.ledger({ type: "error", where: "restore_state", error: errText(err) });
522
+ }
523
+ }
524
+
525
+ /** Is the newest pi-zip summary of the branch newer than the last cold return? (The live flag: set when a summary is persisted,
526
+ * cleared at a cold return.) Each user prompt after the summary is judged as `detectCold` judged it, from the branch before it. */
527
+ private summarizedSince(branch: Any[]): boolean {
528
+ let at = -1;
529
+ branch.forEach((e, i) => { if (e?.type === "compaction" && e.details?.by === PRODUCT) at = i; });
530
+ if (at < 0) return false;
531
+ let skip = branch[at].details?.via === "native"; // the prompt after a native summary is the very return that made it
532
+ const models = loadStats().models;
533
+ for (let i = at + 1; i < branch.length; i++) {
534
+ const m = branch[i]?.type === "message" ? branch[i].message : null;
535
+ if (m?.role !== "user" || this.steerIds.has(branch[i].id)) continue;
536
+ if (skip) { skip = false; continue; }
537
+ const a = branch.slice(i + 1).find((x: Any) => x?.type === "message" && x.message?.role === "assistant" && x.message.provider)?.message;
538
+ if (!a) continue; // a prompt nothing answered
539
+ const model = { provider: a.provider, id: a.model };
540
+ const ts = Number(m.timestamp) || Date.parse(branch[i].timestamp) || 0;
541
+ const ent = models[modelKey(model)];
542
+ if (detectCold(model, 0, branch.slice(0, i), ts, "", (g, prior) => pWarm(ent, g, prior), this.tier(model)).cold) return false;
543
+ }
544
+ return true;
545
+ }
546
+
547
+ // ---- recall through files (research pi-tricks §2) -----------------------------------------------------------------------
548
+ /** This machine's directory for this session's recall files. */
549
+ private localRecallDir(ctx: Any): string | null {
550
+ const sid = this.recallSid(ctx);
551
+ return sid ? join(recallRoot(), sidDir(sid)) : null;
552
+ }
553
+ /** Write the originals of these handles (from the current branch) if they are not on disk as they are: their local paths. */
554
+ private materializeHandles(ctx: Any, handles: string[]): Map<string, string> {
555
+ const sid = this.recallSid(ctx);
556
+ if (!sid || !handles.length) return new Map();
557
+ const { items } = resolveHandlesInBranch(this.branch(ctx), handles);
558
+ const found = items.filter((it): it is typeof it & { text: string } => it.text !== null);
559
+ const out = materialize(recallRoot(), sid, found.map((it) => ({ handle: it.handle, text: it.text })));
560
+ 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) });
561
+ return out;
562
+ }
563
+ /** 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. */
564
+ private materializeAll(ctx: Any): string | null {
565
+ const sid = this.recallSid(ctx);
566
+ if (!sid) return null;
567
+ let visible = new Set<string>();
568
+ try {
569
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
570
+ 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));
571
+ } catch {}
572
+ const zipped = branchOutputs(this.branch(ctx)).filter((o) => o.tool !== RECALL_TOOL && !visible.has(o.entryId));
573
+ materialize(recallRoot(), sid, zipped.map((o) => ({ handle: o.handle, text: o.text })));
574
+ this.ledger({ type: "recall_file", all: true, count: zipped.length, chars: zipped.reduce((a, o) => a + o.text.length, 0) });
575
+ return join(recallRoot(), sidDir(sid));
576
+ }
577
+ /**
578
+ * 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
579
+ * 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
580
+ * machine, another user, a forked session's id): the handle is resolved against the current branch. Never blocks a call.
581
+ */
582
+ toolCall(e: Any, ctx: Any): undefined {
583
+ try {
584
+ const input = e?.input, name = e?.toolName;
585
+ if (!input || typeof input !== "object") return undefined;
586
+ if ((name === "read" || name === "grep" || name === "find" || name === "ls") && typeof input.path === "string") {
587
+ const ref = parseRecallPath(input.path);
588
+ if (!ref) return undefined;
589
+ if (ref.handle) {
590
+ const local = this.materializeHandles(ctx, [ref.handle]).get(ref.handle);
591
+ if (local) input.path = local;
592
+ } else if (name !== "read") {
593
+ const dir = this.materializeAll(ctx);
594
+ if (dir) input.path = dir;
595
+ }
596
+ } else if (name === "bash" && typeof input.command === "string" && commandNamesRecall(input.command)) {
597
+ const dir = this.localRecallDir(ctx);
598
+ if (!dir) return undefined;
599
+ const r = rewriteCommand(input.command, dir);
600
+ if (r.dir) this.materializeAll(ctx);
601
+ else this.materializeHandles(ctx, r.handles);
602
+ input.command = r.command;
603
+ }
604
+ } catch (err) {
605
+ this.ledger({ type: "error", where: "tool_call", error: errText(err) });
606
+ }
607
+ return undefined;
280
608
  }
281
609
 
282
610
  shutdown() {
@@ -285,7 +613,8 @@ export class Zip implements ZipControl {
285
613
  this.bg = null;
286
614
  }
287
615
 
288
- beforeAgentStart(ctx: Any) {
616
+ async beforeAgentStart(ctx: Any, e?: Any): Promise<void> {
617
+ this.liveCtx = ctx;
289
618
  this.cancelTimer(); // the user is back: nothing is started behind their back any more
290
619
  this.model = ctx?.model;
291
620
  this.cold = false;
@@ -293,10 +622,13 @@ export class Zip implements ZipControl {
293
622
  this.runChecked = true;
294
623
  this.runEdits = false;
295
624
  this.runPlan = null;
625
+ this.nativeRun = null;
626
+ this.foreignRun = false;
296
627
  this.settleIds = [];
297
628
  this.bgWaitMs = 0;
298
629
  this.wireLedgered = false;
299
630
  this.runUserSeen = 0;
631
+ this.logForeign(ctx, "run");
300
632
  if (!this.active()) return;
301
633
  this.detectConflict(ctx);
302
634
  if (!this.active()) return;
@@ -305,15 +637,21 @@ export class Zip implements ZipControl {
305
637
  this.loadEnt(key);
306
638
  this.branchLastMs = lastMessageMs(branch);
307
639
  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));
640
+ await this.resolveRetention(ctx);
641
+ 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
642
  this.cold = cold;
310
- if (cold) this.summarizedWarm = false; // a cold return may summarise again
643
+ if (cold) {
644
+ this.summarizedWarm = false; // a cold return may summarise again
645
+ try {
646
+ this.pi.appendEntry(COLD_CUSTOM, {}); // right before the prompt: the session says where its last cold return was (plan.ts FOLD_HOLD)
647
+ } catch {}
648
+ }
311
649
  this.pastTtl = pastTtl;
312
650
  this.pWarm = p;
313
651
  this.survSrc = src;
314
652
  this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
315
653
  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` : "";
654
+ this.coldWhy = src === "model switch" ? "cache cold · model switched" : cold && gapS !== null ? `cache cold · away ${fmtAway(gapS)}` : ""; // the warm form says "cache warm · worth it at this size"
317
655
  for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
318
656
  for (let i = branch.length - 1; i >= 0; i--) {
319
657
  const en = branch[i];
@@ -328,12 +666,164 @@ export class Zip implements ZipControl {
328
666
  this.ttlNoticed = true; // once per session
329
667
  if (!this.quiet) this.stateLine(ctx, "cache", ttl.note.replace(`${PRODUCT} · `, ""));
330
668
  }
669
+ if (cold && this.bg?.done) {
670
+ try {
671
+ await this.nativeSummary(ctx, e);
672
+ } catch (err) {
673
+ const a = this.nativeAsk;
674
+ if (a && !a.answered) a.compaction = null; // a late ask of pi-zip's compaction is cancelled, never left to Pi's own summary
675
+ this.ledger({ type: "error", where: "native_summary", error: errText(err) });
676
+ }
677
+ }
678
+ }
679
+
680
+ /** session_before_compact: answers ONLY the compaction pi-zip itself asked for a moment ago (nativeAsk): its own summary and its own
681
+ * tokensBefore. Pi's threshold and overflow compactions, /compact and other extensions' compactions get undefined and run untouched.
682
+ * 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
683
+ * another compaction (pi-zip's own was aborted). */
684
+ sessionBeforeCompact(e?: Any): Any {
685
+ const a = this.nativeAsk;
686
+ if (!a || a.answered || Date.now() > a.until) return undefined;
687
+ if (e?.reason !== undefined && e.reason !== "manual") return undefined; // ctx.compact() is a manual compaction
688
+ if (!a.compaction && e?.signal && e.signal.aborted !== true) return undefined;
689
+ a.answered = true;
690
+ a.askedAt = performance.now();
691
+ return a.compaction ? { compaction: a.compaction } : { cancel: true };
692
+ }
693
+
694
+ /** The native path ends for the rest of the process (logged once): from now on summaries ride along with the first request. */
695
+ private endNative(reason: string) {
696
+ if (this.nativeOff) return;
697
+ this.nativeOff = reason;
698
+ this.ledger({ type: "native_off", reason });
699
+ }
700
+
701
+ /** 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
702
+ * request-locally and turn_end persists it as a draft. */
703
+ private nativeGate(ctx: Any, branch: Any[], pre: Any[]): string | null {
704
+ if (this.nativeOff) return "off for this process";
705
+ if (typeof ctx?.compact !== "function") return "no ctx.compact";
706
+ if (typeof ctx?.abort !== "function") return "no ctx.abort"; // what cancels a compaction another handler holds up
707
+ // another extension supplies compactions in this session: its session_before_compact handler would run for pi-zip's too
708
+ if (branch.some(foreignCompaction)) return "another extension's compaction is in this branch";
709
+ let idle = false;
710
+ try {
711
+ idle = ctx.isIdle?.() === true; // compact() aborts whatever runs first: only ever called while Pi is idle (it is, here, in 1.1.0)
712
+ } catch {}
713
+ if (!idle) return "not idle";
714
+ if (branch[branch.length - 1]?.type === "compaction") return "already compacted";
715
+ let settings: Any;
716
+ try {
717
+ settings = this.pi.getSettings?.();
718
+ } catch {}
719
+ if (!piCanCompact(pre, keepRecentTokensFor(settings, ctx?.model))) return "nothing to compact";
720
+ return null;
721
+ }
722
+
723
+ /**
724
+ * A summary prepared while the user was away goes into the session through Pi's own compaction, before the run (research pi-tricks
725
+ * §1b, variant 1): ctx.compact() with session_before_compact answering pi-zip's summary and pi-zip's own tokensBefore (the notice's
726
+ * "before", provider scale), so Pi's "[compaction] Compacted from N tokens" shows that number. It takes milliseconds: the summary is
727
+ * 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
728
+ * bytes as the draft path would have sent; any gate that fails keeps the draft path.
729
+ * Pi runs every extension's session_before_compact handler for it (the last answer wins, a cancel stops it): unless pi-zip's own
730
+ * entry is the one that went in, fast, the native path ends for the process (endNative). A compaction not finished by the deadline
731
+ * is cancelled first, and a summary prepared for a context another extension's compaction has replaced is discarded, never adopted.
732
+ */
733
+ private async nativeSummary(ctx: Any, e: Any): Promise<void> {
734
+ const b = this.bg;
735
+ if (!b?.done || !b.key) return;
736
+ const branch = this.branch(ctx);
737
+ const pre = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
738
+ const skip = (reason: string) => this.ledger({ type: "native_skip", reason });
739
+ // the plan the first request will make: the same one, with the prompt Pi is about to append
740
+ const pending = pendingPrompt(e);
741
+ const entries = pending ? [...pre, pending] : pre;
742
+ const { o } = this.opts(ctx, !pending, "cold", entries, this.pWarm);
743
+ o.pastTtl = this.pastTtl;
744
+ const p = planContext(entries, o);
745
+ if (!p || p.cutIdx === null || p.firstKeptEntryId !== b.key) return skip("the summary does not fit this plan");
746
+ const cut = b.done;
747
+ if (checkEdits(p.blocks, p.userTurns, p.folds, cut)) return skip("guard");
748
+ const gate = this.nativeGate(ctx, branch, pre);
749
+ if (gate) return skip(gate);
750
+ const before = Math.round(p.ctxTokens);
751
+ const compaction = { summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: before, details: { by: PRODUCT, via: "native", trigger: cut.trigger }, usage: cut.usage };
752
+ const known = new Set(branch.map((en: Any) => en?.id));
753
+ const ask = { compaction: compaction as Any | null, answered: false, askedAt: 0, until: Infinity };
754
+ this.nativeAsk = ask;
755
+ const deadline = nativeDeadlineMs(), t0 = performance.now();
756
+ const res = await new Promise<{ ok: boolean; err?: string; late?: boolean }>((resolve) => {
757
+ let settled = false;
758
+ const finish = (r: { ok: boolean; err?: string; late?: boolean }) => {
759
+ if (settled) return;
760
+ settled = true;
761
+ clearTimeout(timer);
762
+ resolve(r);
763
+ };
764
+ const timer = setTimeout(() => {
765
+ if (!ask.answered) ask.compaction = null; // asked after this, pi-zip cancels: never Pi's own summary on pi-zip's behalf
766
+ ask.until = Date.now() + 60_000;
767
+ finish({ ok: false, late: true });
768
+ }, deadline);
769
+ timer.unref?.();
770
+ const done = (r: { ok: boolean; err?: string }) => {
771
+ if (this.nativeAsk === ask) this.nativeAsk = null; // pi-zip's compaction is over: nothing is answered for it any more
772
+ finish(r);
773
+ };
774
+ try {
775
+ ctx.compact({ onComplete: () => done({ ok: true }), onError: (err: Any) => done({ ok: false, err: err instanceof Error ? err.message : String(err) }) });
776
+ } catch (err) {
777
+ done({ ok: false, err: errText(err) });
778
+ }
779
+ });
780
+ const ms = Math.round(performance.now() - t0), askMs = ask.askedAt ? Math.round(ask.askedAt - t0) : null;
781
+ // what went in: compactions added since the call (pi-zip's is the one carrying its summary; Pi keeps the last handler's answer)
782
+ const added = this.branch(ctx).filter((en: Any) => en?.type === "compaction" && !known.has(en?.id));
783
+ const last = added[added.length - 1] ?? null;
784
+ const entry = last && last.summary === cut.text ? last : null;
785
+ // still pending at the deadline: cancel it now, before the run starts (Pi checks its abort signal right before it appends)
786
+ if (res.late && !last) {
787
+ try {
788
+ ctx.abort?.();
789
+ } catch {}
790
+ }
791
+ const off = entry
792
+ ? 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
793
+ : last ? "another extension's compaction went in instead of pi-zip's"
794
+ : 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)`)
795
+ : `Pi's compaction failed: ${res.err ?? "no compaction entry"}`;
796
+ if (off) this.endNative(off);
797
+ if (!entry?.id) {
798
+ this.ledger({ type: "native_fail", ms, askMs, answered: ask.answered, error: res.late ? "deadline" : (res.err ?? "no compaction entry"), foreign: !!last });
799
+ if (last) {
800
+ // another extension's compaction replaced the context this summary was written for: adopting it, or writing a new one on
801
+ // top of it, would summarise twice. It is this stretch's summary (summarizedWarm, as after a pi-zip summary).
802
+ this.discardBg();
803
+ this.foreignRun = true;
804
+ this.summarizedWarm = true;
805
+ }
806
+ return; // nothing went in: the draft path, the run plan adopts the prepared summary as before
807
+ }
808
+ this.bg = null; // adopted by Pi's compaction
809
+ this.summarizedWarm = true; // one summary per warm stretch, as for a draft (commit)
810
+ // the summary is in the session from now on, whatever the run does: book it here (commit() does not book it again)
811
+ const cutSaved = Math.round(p.scale.c * (cut.prefixTokens - cut.summaryTokens));
812
+ this.nativeRun = { key: b.key, cut, before, after: Math.round(p.ctxAfterFolds - cutSaved), pre, entryId: entry.id };
813
+ const s = this.stats;
814
+ s.summaries++;
815
+ s.summarizedTokens += cutSaved;
816
+ s.summaryUsd += cut.costUsd;
817
+ s.activeSaved += cutSaved;
818
+ s.writeSavedTokens += cutSaved;
819
+ 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
820
  }
332
821
 
333
822
  private cancelTimer() {
334
823
  this.timerGen++;
335
824
  if (this.timer) clearTimeout(this.timer);
336
825
  this.timer = null;
826
+ this.awayPlan = null;
337
827
  }
338
828
 
339
829
  /** A summary nobody adopted still cost money: abort it if it is still running, book it if it finished. */
@@ -364,20 +854,25 @@ export class Zip implements ZipControl {
364
854
  this.ledger({ type: "error", where: "context/run_plan", error: errText(err) });
365
855
  }
366
856
  }
857
+ if (ctx?.signal?.aborted) return undefined; // Esc (Pi cancelled the prompt): this request never goes out, so nothing is applied or announced
367
858
  if (this.runPlan && !this.runPlan.persisted) {
368
859
  const out = applyPlanToMessages(e.messages, this.runPlan);
369
- if (out) {
860
+ if (out || this.runPlan.native) { // a native summary is already in the request Pi built: it still goes out with this request
370
861
  if (!this.runPlan.sent) this.nextEdit = this.runPlan.untouched ?? 0;
371
862
  this.runPlan.sent = true;
372
- this.runPlan.applied = out.applied;
373
- return { messages: out.messages };
863
+ const applied = (this.runPlan.applied = out?.applied ?? this.runPlan.applied ?? new Set<string>());
864
+ if (!this.runPlan.noticed) { // say it where it happens: at the top of the turn, before the model answers
865
+ this.runPlan.noticed = true;
866
+ const live = this.runPlan.folds.filter((t) => applied.has(t.entryId));
867
+ this.noticeFor(ctx, this.runPlan, live, this.runPlan.cut ?? this.runPlan.native ?? null, this.runPlan.ctxBefore, 0);
868
+ }
869
+ return out ? { messages: out.messages } : undefined;
374
870
  }
375
871
  }
376
872
  return undefined;
377
873
  }
378
874
 
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). */
875
+ /** While the user waits for a summary, Pi's working line says what for. */
381
876
  private async working<T>(ctx: Any, text: string, p: Promise<T>): Promise<T> {
382
877
  const ui = ctx?.mode === "tui" ? ctx?.ui : null;
383
878
  try {
@@ -392,6 +887,8 @@ export class Zip implements ZipControl {
392
887
  }
393
888
  }
394
889
 
890
+ /** A prepared summary for this cut, or null. A summary still being written is waited for (only the remainder). Esc there cancels the
891
+ * 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
892
  private async takeBg(key: string | null, signal?: AbortSignal, ctx?: Any): Promise<{ cut: Cut | null; adopted: boolean }> {
396
893
  const b = this.bg;
397
894
  if (!b || key === null || b.key !== key) {
@@ -399,7 +896,7 @@ export class Zip implements ZipControl {
399
896
  return { cut: null, adopted: false };
400
897
  }
401
898
  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)));
899
+ 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
900
  this.bgWaitMs = performance.now() - w0;
404
901
  if (cut) {
405
902
  this.bg = null;
@@ -413,17 +910,36 @@ export class Zip implements ZipControl {
413
910
  /** 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
911
  private async computeRunPlan(ctx: Any) {
415
912
  const t0 = performance.now();
416
- const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
913
+ const now = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
914
+ // a summary that went in through Pi's compaction before the run (nativeSummary): plan on the projection as it was right before
915
+ // it, plus what came after, so folds, scale and placeholders are exactly what the draft path computes (the run's first request
916
+ // carries the same bytes); the request Pi builds already starts with the summary
917
+ const nat = this.nativeRun, foreign = this.foreignRun;
918
+ this.nativeRun = null;
919
+ this.foreignRun = false;
920
+ const spliced = nat ? spliceNative(nat.pre, now, nat.entryId) : null;
417
921
  if (!this.cold && this.justEdited) { // no warm edit on the request right after an edited one (no back-to-back warm rewrites)
418
922
  this.ledger({ type: "b2b_skip", where: "run" });
419
923
  this.discardBg();
420
924
  return;
421
925
  }
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);
926
+ const plan = (entries: Any[]) => {
927
+ const { o, cal } = this.opts(ctx, false, this.cold ? "cold" : "warm", entries, this.pWarm);
928
+ o.pastTtl = this.pastTtl;
929
+ if (foreign) o.noSummary = true; // another extension's compaction just went in: folds only (a summary only at Pi's compaction room)
930
+ return { o, cal, p: planContext(entries, o) };
931
+ };
932
+ let { o, cal, p } = plan(spliced ?? now);
933
+ const onPre = !!nat && !!spliced && !!p && p.cutIdx !== null && p.firstKeptEntryId === nat.key;
934
+ if (nat && !onPre) {
935
+ // 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
936
+ // the session as it is now, with the summary at its head
937
+ this.ledger({ type: "native_mismatch", planned: p?.firstKeptEntryId ?? null, compacted: nat.key });
938
+ ({ o, cal, p } = plan(now));
939
+ }
425
940
  this.lawLedger("run", o);
426
- if (!p) {
941
+ const native = nat?.cut ?? null;
942
+ if (!p && !native) {
427
943
  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
944
  return;
429
945
  }
@@ -431,35 +947,46 @@ export class Zip implements ZipControl {
431
947
  const trigger = this.cold ? "cold" : "valve";
432
948
  let folds: FoldTarget[] = p?.folds ?? [];
433
949
  let cut: Cut | null = null;
434
- let adopted = false;
435
- if (p && p.cutIdx !== null) {
950
+ let adopted = !!native; // Pi's compaction took the prepared summary
951
+ if (!native && p && p.cutIdx !== null) {
436
952
  const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
437
953
  cut = got.cut;
438
954
  adopted = got.adopted;
439
- } else {
955
+ } else if (!native) {
440
956
  this.discardBg();
441
957
  }
442
958
  const sameSet = this.settleIds.length === folds.length && this.settleIds.every((id) => folds.some((u) => u.entryId === id));
443
959
  const source: RunPlan["source"] = !this.cold ? "valve" : adopted || (this.settleIds.length > 0 && sameSet) ? "settle" : "runstart";
444
960
  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
961
+ 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
962
  // a plan that cannot be persisted must never be sent: the request view would differ from what turn_end can write (I1)
447
963
  if (p) {
448
- const invalid = checkEdits(p.blocks, p.userTurns, folds, cut);
964
+ const invalid = checkEdits(p.blocks, p.userTurns, folds, cut ?? (onPre ? native : null));
449
965
  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;
966
+ this.ledger({ type: "guard_drop", where: "run_plan", reason: invalid, folds: folds.length, summary: !!(cut ?? native) });
967
+ if (native) folds = []; // the summary is in the session already: only the folds can be dropped
968
+ else if (cut && checkEdits(p.blocks, p.userTurns, folds, null) === null) cut = null;
452
969
  else { folds = []; cut = null; }
453
970
  }
454
971
  }
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) } : {}) });
972
+ const sum = cut ?? (onPre ? native : null); // a summary this plan's sizes do not count yet
973
+ let ctxBefore = p?.ctxTokens ?? 0;
974
+ let ctxAfter = (p?.ctxAfterFolds ?? 0) - (sum ? cal.c * (sum.prefixTokens - sum.summaryTokens) : 0);
975
+ if (nat) {
976
+ // the notice's "before" is the number Pi's [compaction] block shows (computed with the prompt before Pi appended it)
977
+ ctxAfter = p ? ctxAfter + (onPre ? nat.before - ctxBefore : 0) : nat.after;
978
+ ctxBefore = nat.before;
979
+ }
980
+ this.runPlan = {
981
+ source, folds: onPre ? rebaseFolds(folds, p!.cutVisibleIdx) : folds, cut, native: native ?? undefined, ctxBefore, ctxAfter, ms: foldMs, scale: cal, persisted: false,
982
+ 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),
983
+ };
984
+ 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
985
  }
459
986
 
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 };
987
+ /** Ledger form of a calibration; ctxBefore/ctxAfter next to it are already real (O + c x estimate). */
988
+ private calOf(cal: Scale) {
989
+ 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
990
  }
464
991
 
465
992
  private usageOf(cut: Cut) {
@@ -490,8 +1017,11 @@ export class Zip implements ZipControl {
490
1017
  }
491
1018
  } else {
492
1019
  this.discardBg();
1020
+ // no cut now, but the timer still runs: if Pi's own compaction or another extension's edit rewrites the context while the user
1021
+ // is away, the plan made then may have one (runAway plans again when the context changed since this settle)
1022
+ scheduledMs = this.scheduleSummary(p, ctx);
493
1023
  }
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) });
1024
+ 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
1025
  if (!p.folds.length && p.cutIdx === null) return undefined;
496
1026
  // ids only: placeholders and summary text are recomputed (they are pure functions of the session), never stored a second time
497
1027
  const data = { policy: PRODUCT, ts: Date.now(), targets: p.folds.map((t) => t.entryId), cutKey: p.firstKeptEntryId };
@@ -503,18 +1033,127 @@ export class Zip implements ZipControl {
503
1033
  }
504
1034
 
505
1035
  /** 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());
1036
+ private scheduleSummary(p: PlanResult, ctx: Any, lastMs?: number): number {
1037
+ const ttl = ttlFor(ctx.model, this.branch(ctx), this.tier(ctx.model));
1038
+ const last = lastMs ?? (this.lastReqMs || Date.now());
1039
+ this.awaySig = this.contextSig(ctx);
1040
+ const warm = this.idleWarming() ? warmingDelayMs(ttl) : undefined;
1041
+ if (warm !== undefined) {
1042
+ // Pi keeps the cache warm between runs (cacheWarming "idle") and decides before each refresh, at its warming delay: a summary
1043
+ // written now would be thrown away at a warm return, so it waits for Pi's decision (cacheWarmingDecision); should none come
1044
+ // (Pi is not warming this request after all), it starts just after the decision was due
1045
+ this.awayPlan = { p, ctx };
1046
+ const delay = Math.max(0, last + warm + warmingMargin(ttl) - Date.now());
1047
+ this.armAway(delay, () => this.startAway("no warming decision"));
1048
+ return Math.round(delay);
1049
+ }
1050
+ const delay = Math.max(0, last + AWAY_FRACTION * ttl - Date.now());
1051
+ this.armAway(delay, () => void this.runAway(p, ctx));
1052
+ return Math.round(delay);
1053
+ }
1054
+
1055
+ /** What the model would see of the branch, by its newest context entry: a message, edit, compaction or summary (our own bookkeeping
1056
+ * entries and other extensions' custom entries change nothing). A different one at the timer than at settle means something
1057
+ * rewrote the context in between (Pi's compaction, another extension's edit). */
1058
+ private awaySig = "";
1059
+ private contextSig(ctx: Any): string {
1060
+ const branch = this.branch(ctx);
1061
+ for (let i = branch.length - 1; i >= 0; i--) {
1062
+ const t = branch[i]?.type;
1063
+ if (t === "message" || t === "context_edit" || t === "compaction" || t === "branch_summary" || t === "custom_message") return `${t}:${branch[i].id}`;
1064
+ }
1065
+ return "";
1066
+ }
1067
+
1068
+ /** One timer for the away summary (unref'd: it never keeps the process alive); a newer one, or the user's return, cancels it. */
1069
+ private armAway(delayMs: number, fn: () => void) {
1070
+ if (this.timer) clearTimeout(this.timer);
509
1071
  const gen = ++this.timerGen;
510
- this.timer = setTimeout(() => void this.runAway(p, ctx, gen), delay);
1072
+ this.timer = setTimeout(() => {
1073
+ this.timer = null;
1074
+ if (gen === this.timerGen && this.active()) fn(); // else the user came back, or we were switched off
1075
+ }, Math.max(0, delayMs));
511
1076
  this.timer.unref?.();
512
- return Math.round(delay);
513
1077
  }
514
1078
 
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
1079
+ // ---- Pi's own idle cache warming (research pi-tricks §3.3): observed, never answered ----------------------------------------
1080
+ private awayPlan: { p: PlanResult; ctx: Any } | null = null; // a summary waiting for Pi's warming decision
1081
+ /** Does Pi refresh the cache between runs by itself? (settings cacheWarming "idle"; the default "streaming" warms only during a run)
1082
+ * Pi reads it from the global settings only (settings-manager getCacheWarmingMode: warming costs money), so a project-level "idle"
1083
+ * in the merged settings warms nothing: the global file decides; only when it cannot be read do the merged settings. */
1084
+ private idleWarming(): boolean {
1085
+ try {
1086
+ if (this.pi.getSettings?.()?.cacheWarming !== "idle") return false;
1087
+ } catch {
1088
+ return false;
1089
+ }
1090
+ try {
1091
+ const global = JSON.parse(readFileSync(join(agentDir(), "settings.json"), "utf8"));
1092
+ return global?.cacheWarming === "idle";
1093
+ } catch {
1094
+ return true;
1095
+ }
1096
+ }
1097
+ private startAway(reason: string) {
1098
+ const a = this.awayPlan;
1099
+ this.awayPlan = null;
1100
+ if (!a) return;
1101
+ this.ledger({ type: "away_unblocked", reason });
1102
+ void this.runAway(a.p, a.ctx);
1103
+ }
1104
+ /** cache_warming_decision: Pi is about to refresh the cache ("warm") or to let it expire ("stop"). Only observed: never answered.
1105
+ * While Pi warms, the away summary waits; once Pi stops, it is prepared as without warming (the cache now expires on its own). */
1106
+ cacheWarmingDecision(e: Any): undefined {
1107
+ const action = e?.action;
1108
+ this.ledger({ type: "cache_warming", action, warmCost: e?.warmCost, missCost: e?.missCost, p: e?.continuationProbability, waiting: !!this.awayPlan });
1109
+ const a = this.awayPlan;
1110
+ if (!a || !this.active()) return undefined;
1111
+ if (action === "stop") {
1112
+ this.armAway(0, () => {
1113
+ this.awayPlan = a;
1114
+ this.startAway("Pi stopped warming");
1115
+ });
1116
+ return undefined;
1117
+ }
1118
+ if (action !== "warm") return undefined;
1119
+ // Pi refreshes now, unless another extension overrides the decision (its answer is not visible here): a refresh shows up as a
1120
+ // cache_warm usage entry within seconds. With one, wait for the next decision; without one, Pi stopped: prepare the summary now.
1121
+ const t = Date.now(), ttl = ttlFor(a.ctx.model, this.branch(a.ctx), this.tier(a.ctx.model));
1122
+ this.ledger({ type: "away_postponed", reason: "Pi warms the cache" });
1123
+ this.armAway(warmCheckMs(ttl), () => {
1124
+ this.awayPlan = a;
1125
+ let done = NaN; // when the refresh finished: its cache_warm entry is appended right before Pi schedules the next decision
1126
+ 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);
1127
+ if (Number.isNaN(done)) return this.startAway("no refresh after a warm decision");
1128
+ // Pi's next decision is due one warming delay after the refresh finished (cache-warmer.js schedule), not after the decision
1129
+ this.armAway(done + (warmingDelayMs(ttl) ?? ttl) + warmingMargin(ttl) - Date.now(), () => {
1130
+ this.awayPlan = a;
1131
+ this.startAway("no warming decision");
1132
+ });
1133
+ });
1134
+ return undefined;
1135
+ }
1136
+
1137
+ private async runAway(p0: PlanResult, ctx: Any) {
1138
+ if (!this.active()) return;
1139
+ let p = p0;
1140
+ if (this.contextSig(ctx) === this.awaySig && p0.cutIdx === null) return; // nothing was planned and nothing changed
1141
+ if (this.contextSig(ctx) !== this.awaySig) {
1142
+ // something rewrote the context since the plan was made (Pi's own compaction, another extension's edit): the summary prepared
1143
+ // 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.
1144
+ try {
1145
+ const entries = ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) as Any[];
1146
+ const { o } = this.opts(ctx, true, "cold", entries);
1147
+ const q = entries.length ? planContext(entries, o) : null;
1148
+ this.ledger({ type: "away_replan", from: p0.firstKeptEntryId, to: q?.firstKeptEntryId ?? null });
1149
+ if (!q || q.cutIdx === null) return this.discardBg();
1150
+ if (this.bg && this.bg.key !== q.firstKeptEntryId) this.discardBg();
1151
+ p = q;
1152
+ } catch (err) {
1153
+ this.ledger({ type: "error", where: "away_replan", error: errText(err) });
1154
+ return;
1155
+ }
1156
+ }
518
1157
  const controller = new AbortController();
519
1158
  const bg: Bg = { key: p.firstKeptEntryId, done: null, promise: Promise.resolve(null), controller };
520
1159
  bg.promise = buildCut(p, ctx, controller.signal)
@@ -528,6 +1167,7 @@ export class Zip implements ZipControl {
528
1167
  }
529
1168
 
530
1169
  async turnEnd(e: Any, ctx: Any): Promise<Any> {
1170
+ this.logForeign(ctx, "turn_end");
531
1171
  if (!this.active()) return undefined;
532
1172
  try {
533
1173
  const fresh = this.collectSteer(e); // marked before anything is validated: a steering message is part of its turn
@@ -564,7 +1204,7 @@ export class Zip implements ZipControl {
564
1204
  const userTurns = countUserTurns(blocks);
565
1205
  const u = msg?.usage;
566
1206
  const usageTotal = u ? (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) : 0;
567
- const o = { t0, blocks, userTurns, sys: this.sysTokens(ctx), usageTotal };
1207
+ const o = { t0, blocks, userTurns, usageTotal };
568
1208
  if (this.runEdits) {
569
1209
  this.runEdits = false;
570
1210
  if (!this.runPlan?.sent && this.runPlan && (this.runPlan.folds.length || this.runPlan.cut)) {
@@ -590,14 +1230,44 @@ export class Zip implements ZipControl {
590
1230
  const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
591
1231
  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
1232
  } 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) };
1233
+ const ctxAfter = p.ctxAfterFolds - (cut ? p.scale.c * (cut.prefixTokens - cut.summaryTokens) : 0);
1234
+ 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
1235
  return this.commit(e, ctx, plan, o);
595
1236
  }
596
1237
 
597
- private commit(e: Any, ctx: Any, plan: RunPlan, o: { t0: number; blocks: Block[]; userTurns: number; sys: number; usageTotal: number }) {
1238
+ /**
1239
+ * The transcript notice for one plan. Facts only, no claims: what changed (sizes), how long it took, and, expanded, why now
1240
+ * ("cache cold · away 47 min") and what was folded. Whether the cache really was cold is learned from the response, silently (/zip shows it).
1241
+ * `realAfter` > 0: the size the provider reported for the request that carried the plan (shown instead of the estimate).
1242
+ */
1243
+ private noticeFor(ctx: Any, plan: RunPlan, live: FoldTarget[], cut: Cut | null, ctxTokens: number, realAfter: number) {
1244
+ if (this.quiet || (!live.length && !cut)) return;
1245
+ const { c } = plan.scale, valve = plan.source === "valve"; // amounts scale by c; sizes are O + c x content (plan.ts)
1246
+ const before = c * live.reduce((a, t) => a + t.entryTokens, 0), after = c * live.reduce((a, t) => a + t.phTokens, 0);
1247
+ const cutSaved = cut ? c * (cut.prefixTokens - cut.summaryTokens) : 0;
1248
+ const estAfter = ctxTokens - (before - after) - cutSaved;
1249
+ const waitMs = cut ? (plan.waitMs ?? (plan.source === "settle" ? this.bgWaitMs : cut.ms)) : 0;
1250
+ const notices: NoticeAction[] = [];
1251
+ if (live.length) notices.push({ kind: "fold", count: live.length, tokensBefore: ctxTokens, tokensAfter: cut || !realAfter ? ctxTokens - (before - after) : realAfter, ms: plan.ms, pressure: valve });
1252
+ if (cut) {
1253
+ const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
1254
+ notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: realAfter || estAfter, ms: Math.max(cut.ms, waitMs), waitMs, prepared, pressure: valve });
1255
+ }
1256
+ for (const t of live) this.labels.set(handleFor(t.entryId), `${t.tool}${t.args ? " " + t.args : ""}`);
1257
+ const ITEMS = 8;
1258
+ 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) }));
1259
+ const why = valve || !this.cold ? "cache warm · worth it at this size" : this.coldWhy;
1260
+ // 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
1261
+ // it; either way the first assistant message stamped at or after this moment answered the request that carried the plan
1262
+ 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() };
1263
+ this.noticed = true;
1264
+ this.notify(ctx, data.text, data);
1265
+ }
1266
+
1267
+ private commit(e: Any, ctx: Any, plan: RunPlan, o: { t0: number; blocks: Block[]; userTurns: number; usageTotal: number }) {
598
1268
  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));
1269
+ 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
1270
+ const ctxTokens = sizeOf(sc, o.blocks.reduce((a, b) => a + b.tokens, 0));
601
1271
  const byId = new Map(o.blocks.map((b) => [b.entryId, b]));
602
1272
  const wasApplied = (t: FoldTarget) => !plan.applied || plan.applied.has(t.entryId); // persist what the requests actually carried
603
1273
  const live = plan.folds.filter((t) => byId.has(t.entryId) && wasApplied(t));
@@ -615,12 +1285,16 @@ export class Zip implements ZipControl {
615
1285
  return undefined;
616
1286
  }
617
1287
  if (!plan.sent) this.nextEdit = plan.untouched ?? 0; // persisted now, first sent with the next request
1288
+ const sentBefore = !!plan.sent;
618
1289
  const ours: Any[] = live.map((t) => ({ type: "context_edit", targetId: t.entryId, replacement: { content: [{ type: "text", text: t.ph }] } }));
619
1290
  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;
1291
+ // the compaction draft goes FIRST: Pi writes its tokensBefore from the newest usage only while no edit follows that usage, so a
1292
+ // fold drafted before it would make Pi's "Compacted from N tokens" the chars/4 of a half-applied projection (research pi-tricks §1a)
1293
+ if (cut) ours.unshift({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger, carried: sentBefore }, usage: cut.usage });
1294
+ ours.push({ type: "custom", customType: COMMIT_CUSTOM, data: { carried: sentBefore } }); // says who first carried these edits (state.ts, plan.ts calibrate)
1295
+ const before = Math.round(c * live.reduce((a, t) => a + t.entryTokens, 0));
1296
+ const after = Math.round(c * live.reduce((a, t) => a + t.phTokens, 0));
1297
+ const cutSaved = cut ? Math.round(c * (cut.prefixTokens - cut.summaryTokens)) : 0;
624
1298
  const waitMs = cut ? (plan.source === "settle" ? this.bgWaitMs : cut.ms) : 0;
625
1299
  const s = this.stats;
626
1300
  s.folds += live.length;
@@ -641,30 +1315,13 @@ export class Zip implements ZipControl {
641
1315
  if (live.length) this.ledger({
642
1316
  type: "fold", trigger: plan.source, turnIndex: e.turnIndex, count: live.length, entries: live.map((t) => t.entryId), tools: live.map((t) => t.tool),
643
1317
  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,
1318
+ 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
1319
  coldReason: this.coldReason, prepared: plan.source === "settle", ...(stale ? { staleTargets: stale } : {}),
646
1320
  });
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
- // What the user sees should match Pi's own context meter. A run plan was carried by the request that just ended, so its
650
- // usage is the REAL size after the edits: rescale the notice to it (display only; decisions stay on the plan's k). A
651
- // valve plan only applies from the next request: keep the estimate.
652
- const estAfter = ctxTokens - (before - after) - cutSaved - k * tokensOf(e.message);
653
- const r = !valve && o.usageTotal > 0 && estAfter > 0 ? clamp(o.usageTotal / estAfter, 0.2, 5) : 1;
654
- const notices: NoticeAction[] = [];
655
- if (live.length) notices.push({ kind: "fold", count: live.length, tokensBefore: r * ctxTokens, tokensAfter: r * (ctxTokens - (before - after)), ms: plan.ms + (performance.now() - o.t0), pressure: valve });
656
- if (cut) {
657
- const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
658
- notices.push({ kind: "summary", count: cut.count, tokensBefore: r * (ctxTokens - (before - after)), tokensAfter: r * (ctxTokens - (before - after) - cutSaved), ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
659
- }
660
- if (notices.length && !this.quiet) {
661
- const ITEMS = 8;
662
- const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, turn: byId.get(t.entryId)?.userTurn, tokens: Math.round(r * k * t.entryTokens), handle: handleFor(t.entryId) }));
663
- 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";
664
- 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) };
665
- this.noticed = true;
666
- this.notify(ctx, data.text, data);
667
- }
1321
+ 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) });
1322
+ // a run plan was announced when its first request went out; a valve plan (between turns) is announced now
1323
+ if (!plan.noticed) this.noticeFor(ctx, plan, live, cut, ctxTokens, plan.source === "valve" ? 0 : o.usageTotal);
1324
+ plan.noticed = true;
668
1325
  return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
669
1326
  }
670
1327
 
@@ -674,17 +1331,21 @@ export class Zip implements ZipControl {
674
1331
  // the free cache signal: this response's cacheRead against what this request re-sent unchanged, after this gap (same model only)
675
1332
  this.pending = { gapS: last && (this.lastModelKey || this.prevKey) === key ? (now - last) / 1000 : -1, expect: edited ? this.nextEdit! : this.prevTotal, edited, key };
676
1333
  this.nextEdit = null;
1334
+ this.reqMsBefore = this.lastReqMs;
677
1335
  this.lastReqMs = now;
678
1336
  this.lastModelKey = key;
679
1337
  this.stats.readSavedTokens += this.stats.activeSaved;
680
1338
  const p: Any = e.payload;
1339
+ const seen = payloadRetention(p); // what this very request asked the provider for: it wins over any environment
1340
+ if (seen && key && (seen !== this.retention?.tier || this.retention?.key !== key || this.retention?.src !== "payload")) {
1341
+ this.retention = { key, tier: seen, src: "payload" };
1342
+ this.ledger({ type: "retention", ...this.retention });
1343
+ }
681
1344
  if (process.env.PI_ZIP_LEDGER && this.cold && !this.wireLedgered && p && Array.isArray(p.messages)) {
682
- this.wireLedgered = true; // proof that the FIRST request after a cold return is already small
1345
+ this.wireLedgered = true; // proof that the FIRST request after a cold return is already small (real scale: O + c x the wire's content)
683
1346
  let tok = 0;
684
- for (const m of p.messages) tok += tok4(JSON.stringify(m?.content ?? "")) + 8;
685
- const sys = p.system;
686
- tok += typeof sys === "string" ? tok4(sys) : Array.isArray(sys) ? tok4(sys.map((x: Any) => x?.text ?? "").join("\n")) : 0;
687
- 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 });
1347
+ for (const m of p.messages) if (m?.role !== "system" && m?.role !== "developer") tok += tok4(JSON.stringify(m?.content ?? "")) + 8;
1348
+ 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 });
688
1349
  }
689
1350
  if (this.off) return undefined; // strict no-op
690
1351
  const r = repairPayload(p);
@@ -695,6 +1356,7 @@ export class Zip implements ZipControl {
695
1356
 
696
1357
  messageEnd(m: Any) {
697
1358
  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
1359
+ 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)
698
1360
  if (m?.role === "user" && this.active()) {
699
1361
  // 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.
700
1362
  if (this.runUserSeen++ > 0 && typeof m.timestamp === "number") this.steerTs.add(m.timestamp);
@@ -746,19 +1408,30 @@ export class Zip implements ZipControl {
746
1408
  private totals(ctx: Any) {
747
1409
  const branch = ctx ? this.branch(ctx) : [];
748
1410
  const byId = new Map(branch.map((e: Any) => [e?.id, e]));
749
- let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
1411
+ let folds = 0, summaries = 0, recalls = 0, usd = 0, key = "";
750
1412
  let last: Any = null;
1413
+ const fileRecalls = new Set<string>(); // tool call ids of built-in reads of recall files (file recall): unzips too
1414
+ const byModel = new Map<string, number>(); // chars/4 folded, by the model of the newest response before the fold (the one that planned it)
751
1415
  for (const e of branch) {
752
1416
  const ph = String(e?.replacement?.content?.[0]?.text ?? "");
1417
+ if (e?.type === "message" && e.message?.role === "assistant" && (e.message.provider || e.message.model)) key = `${e.message.provider ?? ""}/${e.message.model ?? ""}`;
1418
+ if (e?.type === "message" && e.message?.role === "assistant" && Array.isArray(e.message.content))
1419
+ 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);
753
1420
  if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
754
1421
  folds++;
755
- folded += Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4;
1422
+ byModel.set(key, (byModel.get(key) ?? 0) + Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4);
756
1423
  } else if (e?.type === "compaction" && e.details?.by === PRODUCT) {
757
1424
  summaries++;
758
1425
  usd += Number(e.usage?.cost?.total) || 0;
759
1426
  } else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
760
1427
  else if (e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.v === 2 && !e.data?.kind) last = e;
761
- else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
1428
+ else if (e?.type === "message" && e.message?.role === "toolResult" && (e.message?.toolName === RECALL_TOOL || fileRecalls.has(e.message?.toolCallId))) recalls++;
1429
+ }
1430
+ // chars/4 of the folded text, on the provider's scale: x c of the model that folded it (folded outputs are content)
1431
+ let folded = 0;
1432
+ if (folds) {
1433
+ const entries = byModel.size > 1 ? ((ctx.sessionManager.buildSessionProjection() as Any)?.entries ?? []) : undefined;
1434
+ for (const [k, v] of byModel) folded += v * (byModel.size > 1 && k ? this.scaleAt(ctx, entries, k) : this.scaleOf(ctx)).c;
762
1435
  }
763
1436
  return { folds, folded, summaries, recalls, usd, last };
764
1437
  }
@@ -766,37 +1439,49 @@ export class Zip implements ZipControl {
766
1439
  private sessionTotals(ctx: Any): string {
767
1440
  const { folds, folded, summaries, recalls, usd } = this.totals(ctx);
768
1441
  const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
769
- 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")}`;
1442
+ return `this session: ${folds} zipped (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "unzip", "unzips")}`;
770
1443
  }
771
- /** The /zip status card (rendered by ui.ts renderCard). */
1444
+ /** The /zip card (rendered by ui.ts renderCard). */
772
1445
  card(ctx?: Any): CardData {
773
1446
  const t = this.totals(ctx);
774
1447
  const model = ctx?.model ?? this.model;
775
- const key = modelKey(model), ttlS = ttlFor(model) / 1000, ent = key ? loadStats().models[key] : undefined;
776
- const alive = key ? SPARK_GAPS.map((g) => pWarm(ent, g, ttlS).p) : undefined;
1448
+ const key = modelKey(model), ttlS = ttlFor(model, [], this.tier(model)) / 1000, ent = key ? loadStats().models[key] : undefined;
777
1449
  let lifeS = 0;
778
1450
  for (let g = 30; g <= 7200; g += 30) if (pWarm(ent, g, ttlS).p >= 0.5) lifeS = g;
779
- const learned = !!ent && ent.n > 0 && pWarm(ent, Math.max(30, lifeS), ttlS).src === "learned";
780
- 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;
1451
+ const life = key ? (lifeS >= 7200 ? "2 h+" : fmtLife(lifeS || ttlS)) : undefined;
1452
+ // the chart: every learned bin that still rounds to a return (the weights are decayed), and where the learned curve says
1453
+ // "still there" ends (s1) and "gone" starts (s2); never the declared TTL
1454
+ const bins: ChartBin[] = Object.entries(ent?.bins ?? {})
1455
+ .map(([b, [a, d]]) => ({ lo: GAP_EDGES[Number(b)], hi: GAP_EDGES[Number(b) + 1] ?? 7200, alive: Math.round(a), gone: Math.round(d) }))
1456
+ .filter((x) => x.lo !== undefined && x.alive + x.gone > 0)
1457
+ .sort((x, y) => x.lo - y.lo);
1458
+ const cur = (ent ? curve(ent, ttlS) : []).map((x) => ({ lo: GAP_EDGES[x.bin], hi: GAP_EDGES[x.bin + 1] ?? 7200, p: x.p }));
1459
+ let s1 = GAP_EDGES[0];
1460
+ for (const x of cur) { if (x.p >= 0.8) s1 = x.hi; else break; }
1461
+ const s2 = cur.find((x) => x.lo >= s1 && x.p <= 0.2)?.lo ?? 7200;
1462
+ const usually = cur.some((x) => x.hi <= s1 && x.p < 0.95);
781
1463
  const ts = t.last?.timestamp ? new Date(t.last.timestamp) : null;
782
1464
  const hhmm = ts && !Number.isNaN(ts.getTime()) ? `${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")} ` : "";
783
1465
  const last = t.last ? `${hhmm}${t.last.data.desc}${t.last.data.why ? " · " + t.last.data.why : ""}` : undefined;
784
- const state = this.off ? "off" : this.conflict ? "paused" : this.recallOk ? "on" : "reread-only";
1466
+ const file = this.recallMode === "file" && !!this.fileRecall(ctx);
1467
+ const state = this.off ? "off" : this.conflict ? "paused" : this.recallMode === "tool" || file ? "on" : "reread-only"; // file recall is a full mode
1468
+ 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";
785
1469
  return {
786
- state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : undefined,
787
- 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,
1470
+ state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : state === "reread-only" ? rereadWhy : undefined,
1471
+ model: key || undefined, life, ...(bins.length ? { bins, s1, s2, usually } : {}), folds: t.folds, foldedTokens: fmtK(t.folded), summaries: t.summaries, summaryUsd: t.usd, recalls: t.recalls, last, quiet: this.quiet,
1472
+ ...(file ? { unzip: `from files in ${recallRootShown()} (a tool allowlist hides zip_recall)` } : {}),
788
1473
  };
789
1474
  }
790
1475
  status(ctx?: Any): string {
791
1476
  const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
792
- const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
1477
+ const key = modelKey(this.model), ttl = ttlFor(this.model, [], this.tier()), ent = loadStats().models[key];
793
1478
  const pr = lawPrices(ent?.cls, ttl >= 3_600_000);
794
1479
  const cache = !key ? "" : `\ncache ${key}: ${pr ? `${pr.cls} (from usage)` : "class unknown until the first response"}` +
795
1480
  `, ${pr ? `read ${+pr.r.toFixed(4)} / write ${+pr.w.toFixed(4)} / output ${+pr.out.toFixed(4)} x input (class ratios)` : "no prices (legacy rule)"}` +
796
1481
  `, ${describe(ent, ttl / 1000)}, growth ${(this.g / 1000).toFixed(1)}K/request`;
797
1482
  return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}${this.sessionTotals(ctx)}${cache}`;
798
1483
  }
799
- statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (token counts are chars/4 estimates)`;
1484
+ statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (tokens on the provider's scale, calibrated from this session's usage)`;
800
1485
  setOff(off: boolean): string {
801
1486
  this.off = off;
802
1487
  if (off) { this.runPlan = null; this.runEdits = false; this.cancelTimer(); this.discardBg(); }