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