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