pi-zip 0.2.2 → 0.2.6
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 +19 -8
- package/package.json +1 -1
- package/src/index.ts +29 -2
- package/src/notice.ts +83 -44
- package/src/run.ts +59 -9
package/README.md
CHANGED
|
@@ -34,14 +34,25 @@ Known limits:
|
|
|
34
34
|
|
|
35
35
|
## What you see
|
|
36
36
|
|
|
37
|
-
At most one line per turn, only when
|
|
37
|
+
At most one line per turn in the transcript, only when the context was folded or summarized. The numbers come first, and the bar shows how much is left:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
pi-zip
|
|
41
|
-
|
|
40
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
41
|
+
originals are kept; the model can recall any of them with zip_recall
|
|
42
|
+
▸ pi-zip 182K → 41K ▰▰▱▱▱▱▱▱▱▱ summarized 64 requests · ready while you were away
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
The line
|
|
45
|
+
The second line appears once per session. Expand tool output (ctrl+o) to see why it happened now and what was folded:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
49
|
+
cache cold (away 47 min): this request rewrites it anyway, so editing is free
|
|
50
|
+
bash npm test turn 1 14K k3x9q2m7ab
|
|
51
|
+
read src/payment.ts turn 1 9K p8d2x1qa0m
|
|
52
|
+
… 10 more
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
These lines are saved in the session, so they are still there after a restart, but they are never sent to the model. On narrow terminals the words go first, then the bar; the numbers always stay. A summary still shows up as Pi's own `[compaction]` block as well; the pi-zip line next to it tells you who made it. Recalls show up as ordinary `zip_recall` tool calls.
|
|
45
56
|
|
|
46
57
|
## The three rules
|
|
47
58
|
|
|
@@ -61,8 +72,8 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
|
|
|
61
72
|
|
|
62
73
|
| Command | Effect |
|
|
63
74
|
|---|---|
|
|
64
|
-
| `/zip status` | on / off / paused, cache TTL,
|
|
65
|
-
| `/zip stats` | tokens
|
|
75
|
+
| `/zip status` | on / off / paused, cache TTL, what this session has folded, summarised and recalled, learned cache survival |
|
|
76
|
+
| `/zip stats` | this session's folds (with tokens removed), summaries and recalls; persisted in the session, so a restart does not reset them |
|
|
66
77
|
| `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
|
|
67
78
|
| `/zip on` | resume |
|
|
68
79
|
| `/zip quiet` | toggle the per-turn notice (folding continues) |
|
|
@@ -109,9 +120,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
|
|
|
109
120
|
|
|
110
121
|
## FAQ
|
|
111
122
|
|
|
112
|
-
**Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip stats` shows
|
|
123
|
+
**Will it save money?** Mostly on cold returns, which is where a long session pays for a full cache rewrite. While the cache is warm it edits only when the inequality above says the rewrite pays back (large contexts, near Pi's compaction trigger, outputs 60+ requests old). `/zip stats` shows what this session has folded (and roughly how many tokens that removed), its summaries with what their model calls cost, and its recalls. The numbers live in the session file, so a restart does not reset them.
|
|
113
124
|
|
|
114
|
-
**Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip stats`. A background summary you never use (you came back while the cache was warm) is counted too. Recalled content re-enters the context at normal prices.
|
|
125
|
+
**Does it cost extra?** Planning is free. A summary is one extra model call (the current model, no tools, no prompt cache), shown in `/zip stats`. A background summary you never use (you came back while the cache was warm) is counted there too. Recalled content re-enters the context at normal prices.
|
|
115
126
|
|
|
116
127
|
**Can the model lose information?** Folded outputs are replaced by a placeholder with key lines and a handle, and the placeholder tells the model not to guess. Summaries quote your requests verbatim and never paraphrase the model's reasoning. If the model ignores the handle and guesses, that is a model failure pi-zip cannot catch; this is why only re-readable outputs of the previous turn are folded at a cold return; other outputs of the protected turns wait until they are 60 requests old.
|
|
117
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zip",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Keeps long Pi sessions cheap without losing anything: folds old tool output only when the prompt cache has already gone cold, and every fold can be recalled byte for byte. Zero config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
// pi-zip: keeps long Pi sessions cheap without losing anything. Wiring only; the logic lives in the sibling modules.
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
3
|
+
import type { Any } from "./util.ts";
|
|
4
|
+
import { type NoticeData, registerZipCommand, renderNotice } from "./notice.ts";
|
|
4
5
|
import { RECALL_TOOL } from "./placeholder.ts";
|
|
5
6
|
import { registerRecallTool } from "./recall.ts";
|
|
6
|
-
import { Zip } from "./run.ts";
|
|
7
|
+
import { NOTICE_CUSTOM, Zip } from "./run.ts";
|
|
7
8
|
|
|
8
9
|
export default function piZip(pi: ExtensionAPI) {
|
|
9
10
|
if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
|
|
10
11
|
const zip = new Zip(pi);
|
|
11
12
|
registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
|
|
12
13
|
registerZipCommand(pi, zip);
|
|
14
|
+
try {
|
|
15
|
+
// fold/summary notices stay in the transcript as one dim line (a custom entry: never part of the model's context)
|
|
16
|
+
let vw: (s: string) => number = (s) => s.length;
|
|
17
|
+
import("@earendil-works/pi-tui").then((m: Any) => {
|
|
18
|
+
if (typeof m?.visibleWidth === "function") vw = m.visibleWidth;
|
|
19
|
+
}, () => {});
|
|
20
|
+
pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
|
|
21
|
+
const d = entry?.data;
|
|
22
|
+
if (!d?.text) return undefined;
|
|
23
|
+
const data: NoticeData = d.v === 2 ? d : { v: 2, text: d.text, before: 0, after: 0, desc: "" };
|
|
24
|
+
return {
|
|
25
|
+
render: (width: number) => {
|
|
26
|
+
if (d.v !== 2) return [theme.fg("dim", vw(d.text) > width ? d.text.slice(0, Math.max(0, width - 1)) + "…" : d.text)];
|
|
27
|
+
try {
|
|
28
|
+
return renderNotice(data, !!o?.expanded, width, theme, vw);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
invalidate: () => {},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
zip.entryRenderer = typeof pi.registerEntryRenderer === "function";
|
|
37
|
+
} catch {
|
|
38
|
+
/* older Pi: notices fall back to the status line */
|
|
39
|
+
}
|
|
13
40
|
// A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
|
|
14
41
|
// zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
|
|
15
42
|
const checkRecall = () => {
|
package/src/notice.ts
CHANGED
|
@@ -12,25 +12,90 @@ export interface NoticeAction {
|
|
|
12
12
|
pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** One folded output, for the expanded (ctrl+o) view of a notice. */
|
|
16
|
+
export interface NoticeItem {
|
|
17
|
+
label: string; // tool + short args
|
|
18
|
+
turn?: number;
|
|
19
|
+
tokens: number;
|
|
20
|
+
handle: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a transcript notice stores (custom entry data; never sent to the model). `text` is the plain one-line form (status-line fallback, ledger). */
|
|
24
|
+
export interface NoticeData {
|
|
25
|
+
v: 2;
|
|
26
|
+
text: string;
|
|
27
|
+
before: number;
|
|
28
|
+
after: number;
|
|
29
|
+
desc: string; // "folded 12 old outputs · summarized 64 requests · ready while you were away"
|
|
30
|
+
why?: string; // expanded view: why now
|
|
31
|
+
items?: NoticeItem[];
|
|
32
|
+
more?: number; // folded outputs not listed
|
|
33
|
+
first?: boolean; // the session's first notice: say once that originals are kept
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
|
|
16
37
|
|
|
38
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
39
|
+
const secs = (ms: number) => `${(Math.round(ms / 100) / 10).toFixed(1)} s`;
|
|
40
|
+
|
|
41
|
+
/** The words after the numbers: what happened, plus how long the user waited for a summary (if at all). */
|
|
42
|
+
export function noticeDesc(actions: NoticeAction[]): string {
|
|
43
|
+
return actions
|
|
44
|
+
.map((a) => {
|
|
45
|
+
if (a.kind === "fold") return `folded ${plural(a.count, "old output")}`;
|
|
46
|
+
return `summarized ${plural(a.count, "request")} · ${a.prepared ? "ready while you were away" : `waited ${secs(a.ms)}`}`;
|
|
47
|
+
})
|
|
48
|
+
.join(" · ");
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
export function noticeText(actions: NoticeAction[]): string {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const first = actions[0], last = actions[actions.length - 1];
|
|
53
|
+
return `${PRODUCT} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)} ${noticeDesc(actions)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ten cells, filled in proportion to what is left: length is read before any digit is. */
|
|
57
|
+
export function ratioBar(before: number, after: number, cells = 10): string {
|
|
58
|
+
const f = before > 0 ? Math.min(cells, Math.max(1, Math.round((cells * after) / before))) : cells;
|
|
59
|
+
return "▰".repeat(f) + "▱".repeat(cells - f);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NoticeTheme {
|
|
63
|
+
fg(color: string, text: string): string;
|
|
28
64
|
}
|
|
29
65
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
66
|
+
const MARK = "▸ ";
|
|
67
|
+
const INDENT = " ".repeat(MARK.length + PRODUCT.length + 2);
|
|
68
|
+
|
|
69
|
+
/** Lines for the transcript. Narrow terminals drop whole segments in order (description, then bar); the numbers always stay.
|
|
70
|
+
* `w` measures display width (Pi's visibleWidth in the renderer; string length in tests). Every line fits `width`. */
|
|
71
|
+
export function renderNotice(d: NoticeData, expanded: boolean, width: number, th: NoticeTheme, w: (s: string) => number = (s) => s.length): string[] {
|
|
72
|
+
const nums = `${fmtK(d.before)} → ${fmtK(d.after)}`;
|
|
73
|
+
const bar = ratioBar(d.before, d.after);
|
|
74
|
+
const tries: [string, string][] = [
|
|
75
|
+
[`${MARK}${PRODUCT} ${nums} ${bar} ${d.desc}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)} ${th.fg("dim", d.desc)}`],
|
|
76
|
+
[`${MARK}${PRODUCT} ${nums} ${bar}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)}`],
|
|
77
|
+
[`${MARK}${PRODUCT} ${nums}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)}`],
|
|
78
|
+
[nums, th.fg("text", nums)],
|
|
79
|
+
];
|
|
80
|
+
const head = tries.find(([plain]) => w(plain) <= width);
|
|
81
|
+
if (!head) return [];
|
|
82
|
+
const out = [head[1]];
|
|
83
|
+
const sub = (plain: string) => {
|
|
84
|
+
const line = INDENT + plain;
|
|
85
|
+
if (w(line) <= width) out.push(th.fg("dim", line));
|
|
86
|
+
else if (w(plain) <= width) out.push(th.fg("dim", plain));
|
|
87
|
+
};
|
|
88
|
+
if (d.first) sub("originals are kept; the model can recall any of them with zip_recall");
|
|
89
|
+
if (!expanded) return out;
|
|
90
|
+
if (d.why) sub(d.why);
|
|
91
|
+
const items = d.items ?? [];
|
|
92
|
+
const lw = Math.min(28, Math.max(0, ...items.map((i) => i.label.length)));
|
|
93
|
+
for (const i of items) {
|
|
94
|
+
const label = i.label.length > lw ? i.label.slice(0, lw - 1) + "…" : i.label.padEnd(lw);
|
|
95
|
+
sub(`${label} ${(i.turn ? `turn ${i.turn}` : "").padEnd(8)}${fmtK(i.tokens).padStart(6)} ${i.handle}`);
|
|
96
|
+
}
|
|
97
|
+
if (d.more) sub(`… ${d.more} more`);
|
|
98
|
+
return out;
|
|
34
99
|
}
|
|
35
100
|
|
|
36
101
|
export class Stats {
|
|
@@ -49,37 +114,11 @@ export class Stats {
|
|
|
49
114
|
activeSaved = 0; // tokens currently removed from the context by our edits
|
|
50
115
|
notices = 0;
|
|
51
116
|
|
|
52
|
-
/** Estimated $ saved versus doing nothing: avoided cache writes and reads, minus summary calls, recalled content re-entering the context, and warm-valve rewrites. */
|
|
53
|
-
savedUsd(p: Prices | null): number | null {
|
|
54
|
-
if (!p) return null;
|
|
55
|
-
const gross = (this.writeSavedTokens * p.cacheWrite + this.readSavedTokens * p.cacheRead) / 1e6;
|
|
56
|
-
const recall = (this.recallChars / 4) * p.cacheWrite / 1e6;
|
|
57
|
-
const pressure = this.pressureRewriteTokens * Math.max(p.cacheWrite - p.cacheRead, 0) / 1e6;
|
|
58
|
-
return gross - this.summaryUsd - recall - pressure;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export const pricesOf = (model: Any): Prices | null => {
|
|
63
|
-
const c = model?.cost;
|
|
64
|
-
const cw = c?.cacheWrite > 0 ? c.cacheWrite : c?.input > 0 ? c.input : 0;
|
|
65
|
-
const cr = c?.cacheRead > 0 ? c.cacheRead : cw * 0.1;
|
|
66
|
-
return cw > 0 ? { cacheWrite: cw, cacheRead: cr } : null;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export function statsText(s: Stats, model: Any): string {
|
|
70
|
-
const usd = s.savedUsd(pricesOf(model));
|
|
71
|
-
const money = usd === null ? "n/a (model declares no prices)" : `${usd < 0 ? "-" : ""}$${Math.abs(usd).toFixed(usd !== 0 && Math.abs(usd) < 0.1 ? 4 : 2)}`;
|
|
72
|
-
return (
|
|
73
|
-
`${PRODUCT} stats (since pi started): folded ${s.folds} output${s.folds === 1 ? "" : "s"} (${fmtK(s.foldedTokens)} tokens), ` +
|
|
74
|
-
`${s.summaries} summar${s.summaries === 1 ? "y" : "ies"} (${fmtK(s.summarizedTokens)} tokens, ${(s.summaryMs / 1000).toFixed(1)} s waited, $${s.summaryUsd.toFixed(4)}), ` +
|
|
75
|
-
`${s.recalls} recall${s.recalls === 1 ? "" : "s"} (~${fmtK(s.recallChars / 4)} tokens re-read). ` +
|
|
76
|
-
`Estimated saved vs doing nothing: ${money}, counting avoided cache writes and reads minus summary calls, recalled content and warm-valve rewrites; token counts are chars/4 estimates.`
|
|
77
|
-
);
|
|
78
117
|
}
|
|
79
118
|
|
|
80
119
|
export interface ZipControl {
|
|
81
|
-
status(): string;
|
|
82
|
-
statsLine(): string;
|
|
120
|
+
status(ctx?: Any): string;
|
|
121
|
+
statsLine(ctx?: Any): string;
|
|
83
122
|
setOff(off: boolean): string;
|
|
84
123
|
toggleQuiet(): string;
|
|
85
124
|
}
|
|
@@ -90,8 +129,8 @@ export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
|
|
|
90
129
|
handler: async (args: string, cctx: Any) => {
|
|
91
130
|
const sub = (args ?? "").trim().toLowerCase() || "status";
|
|
92
131
|
const text =
|
|
93
|
-
sub === "status" ? zip.status()
|
|
94
|
-
: sub === "stats" ? zip.statsLine()
|
|
132
|
+
sub === "status" ? zip.status(cctx)
|
|
133
|
+
: sub === "stats" ? zip.statsLine(cctx)
|
|
95
134
|
: sub === "off" ? zip.setOff(true)
|
|
96
135
|
: sub === "on" ? zip.setOff(false)
|
|
97
136
|
: sub === "quiet" ? zip.toggleQuiet()
|
package/src/run.ts
CHANGED
|
@@ -6,16 +6,19 @@ import { dirname } from "node:path";
|
|
|
6
6
|
import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
|
|
7
7
|
import { describe, lawPrices, loadStats, pWarm, record, sample, GAP_EDGES, type Entry } from "./learn.ts";
|
|
8
8
|
import { validateEdits, repairPayload } from "./guard.ts";
|
|
9
|
-
import { Stats,
|
|
9
|
+
import { fmtK, Stats, noticeDesc, noticeText, type NoticeAction, type NoticeData, type ZipControl } from "./notice.ts";
|
|
10
10
|
import { applyPlanToMessages, buildBlocks, calibrate, countUserTurns, G0, planContext, reserveTokensFor, untouchedEst, type Block, type Calibration, type Cut, type FoldTarget, type Law, type PlanOpts, type PlanResult, type RunPlan } from "./plan.ts";
|
|
11
|
-
import { handleFor } from "./placeholder.ts";
|
|
11
|
+
import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
|
|
12
12
|
import { recalledHandlesFromBranch } from "./recall.ts";
|
|
13
13
|
import { buildCut } from "./summary.ts";
|
|
14
|
-
import { type Any, PRODUCT, tok4 } from "./util.ts";
|
|
14
|
+
import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
|
|
15
15
|
|
|
16
16
|
export const PLAN_CUSTOM = "pi-zip/plan";
|
|
17
17
|
export const STATE_CUSTOM = "pi-zip/state";
|
|
18
18
|
export const STEER_CUSTOM = "pi-zip/steer";
|
|
19
|
+
const fmtAway = (s: number) => (s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(Math.round(s / 360) / 10).toFixed(1)} h`);
|
|
20
|
+
export const NOTICE_CUSTOM = "pi-zip/notice"; // a fold/summary notice kept in the transcript (rendered by index.ts, never sent to the model)
|
|
21
|
+
export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
|
|
19
22
|
export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
|
|
20
23
|
// Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
|
|
21
24
|
const CONFLICT_RE = /billion-context|bc-pi|magic-context|smart-compact|hot-compact|context-prune|pi-vcc|context-mode|prefix-cache/i;
|
|
@@ -94,6 +97,10 @@ export class Zip implements ZipControl {
|
|
|
94
97
|
|
|
95
98
|
/** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
|
|
96
99
|
recallOk = true;
|
|
100
|
+
/** index.ts registered the transcript renderer for NOTICE_CUSTOM (Pi versions without registerEntryRenderer fall back to a status line). */
|
|
101
|
+
entryRenderer = false;
|
|
102
|
+
private coldWhy = ""; // the expanded notice's "why now" for this run
|
|
103
|
+
private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
|
|
97
104
|
/** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
|
|
98
105
|
summarizedWarm = false;
|
|
99
106
|
setRecallOk(ok: boolean) {
|
|
@@ -110,9 +117,19 @@ export class Zip implements ZipControl {
|
|
|
110
117
|
} catch {}
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
/** keep = a fold/summary notice: written to the session as a custom entry, so it stays in the transcript (also after a restart)
|
|
121
|
+
* next to Pi's own "[compaction]" block instead of a status line the next status overwrites. Custom entries never reach the model. */
|
|
122
|
+
private notify(ctx: Any, text: string, keep?: NoticeData) {
|
|
114
123
|
this.stats.notices++;
|
|
115
124
|
this.ledger({ type: "notice", text });
|
|
125
|
+
if (keep && ctx?.mode === "tui" && this.entryRenderer) {
|
|
126
|
+
setTimeout(() => { // after Pi has appended this turn's edits
|
|
127
|
+
try {
|
|
128
|
+
this.pi.appendEntry(NOTICE_CUSTOM, keep);
|
|
129
|
+
} catch {}
|
|
130
|
+
}, 0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
116
133
|
if (ctx?.ui?.notify && (ctx.mode === "tui" || ctx.mode === "rpc")) {
|
|
117
134
|
try {
|
|
118
135
|
ctx.ui.notify(text, "info");
|
|
@@ -242,6 +259,7 @@ export class Zip implements ZipControl {
|
|
|
242
259
|
this.survSrc = src;
|
|
243
260
|
this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
|
|
244
261
|
this.coldReason = reason;
|
|
262
|
+
this.coldWhy = src === "model switch" ? "model switched: its cache starts empty, so this request rewrites it anyway" : cold && gapS !== null ? `cache cold (away ${fmtAway(gapS)}): this request rewrites it anyway, so editing is free` : "";
|
|
245
263
|
for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
|
|
246
264
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
247
265
|
const en = branch[i];
|
|
@@ -270,7 +288,13 @@ export class Zip implements ZipControl {
|
|
|
270
288
|
this.bg = null;
|
|
271
289
|
if (!b) return;
|
|
272
290
|
if (!b.done) b.controller.abort();
|
|
273
|
-
b.promise.then((c) => {
|
|
291
|
+
b.promise.then((c) => {
|
|
292
|
+
if (!c) return;
|
|
293
|
+
this.stats.summaryUsd += c.costUsd;
|
|
294
|
+
try {
|
|
295
|
+
this.pi.appendEntry(UNUSED_SUMMARY_CUSTOM, { usd: c.costUsd });
|
|
296
|
+
} catch {}
|
|
297
|
+
});
|
|
274
298
|
}
|
|
275
299
|
|
|
276
300
|
async context(e: Any, ctx: Any): Promise<Any> {
|
|
@@ -558,7 +582,14 @@ export class Zip implements ZipControl {
|
|
|
558
582
|
const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
|
|
559
583
|
notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: ctxTokens - (before - after) - cutSaved, ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
|
|
560
584
|
}
|
|
561
|
-
if (notices.length && !this.quiet)
|
|
585
|
+
if (notices.length && !this.quiet) {
|
|
586
|
+
const ITEMS = 8;
|
|
587
|
+
const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, turn: byId.get(t.entryId)?.userTurn, tokens: Math.round(k * t.entryTokens), handle: handleFor(t.entryId) }));
|
|
588
|
+
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";
|
|
589
|
+
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) };
|
|
590
|
+
this.noticed = true;
|
|
591
|
+
this.notify(ctx, data.text, data);
|
|
592
|
+
}
|
|
562
593
|
return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
|
|
563
594
|
}
|
|
564
595
|
|
|
@@ -636,16 +667,35 @@ export class Zip implements ZipControl {
|
|
|
636
667
|
this.pi.appendEntry(STATE_CUSTOM, { off: this.off, quiet: this.quiet });
|
|
637
668
|
} catch {}
|
|
638
669
|
}
|
|
639
|
-
|
|
670
|
+
/** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
|
|
671
|
+
private sessionTotals(ctx: Any): string {
|
|
672
|
+
const branch = ctx ? this.branch(ctx) : [];
|
|
673
|
+
const byId = new Map(branch.map((e: Any) => [e?.id, e]));
|
|
674
|
+
let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
|
|
675
|
+
for (const e of branch) {
|
|
676
|
+
const ph = String(e?.replacement?.content?.[0]?.text ?? "");
|
|
677
|
+
if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
|
|
678
|
+
folds++;
|
|
679
|
+
folded += Math.max(0, textOf(byId.get(e.targetId)?.message?.content).length - ph.length) / 4;
|
|
680
|
+
} else if (e?.type === "compaction" && e.details?.by === PRODUCT) {
|
|
681
|
+
summaries++;
|
|
682
|
+
usd += Number(e.usage?.cost?.total) || 0;
|
|
683
|
+
} else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
|
|
684
|
+
else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
|
|
685
|
+
}
|
|
686
|
+
const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
|
|
687
|
+
return `this session: ${n(folds, "folded output", "folded outputs")} (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "recall", "recalls")}`;
|
|
688
|
+
}
|
|
689
|
+
status(ctx?: Any): string {
|
|
640
690
|
const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
|
|
641
691
|
const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
|
|
642
692
|
const pr = lawPrices(ent?.cls, ttl >= 3_600_000);
|
|
643
693
|
const cache = !key ? "" : `\ncache ${key}: ${pr ? `${pr.cls} (from usage)` : "class unknown until the first response"}` +
|
|
644
694
|
`, ${pr ? `read ${+pr.r.toFixed(4)} / write ${+pr.w.toFixed(4)} / output ${+pr.out.toFixed(4)} x input (class ratios)` : "no prices (legacy rule)"}` +
|
|
645
695
|
`, ${describe(ent, ttl / 1000)}, growth ${(this.g / 1000).toFixed(1)}K/request`;
|
|
646
|
-
return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}
|
|
696
|
+
return `${PRODUCT}: ${state}, cache TTL ${Math.round(ttl / 1000)} s, ${this.quiet ? "notices off, " : ""}${this.sessionTotals(ctx)}${cache}`;
|
|
647
697
|
}
|
|
648
|
-
statsLine = () =>
|
|
698
|
+
statsLine = (ctx?: Any) => `${PRODUCT} ${this.sessionTotals(ctx)} (token counts are chars/4 estimates)`;
|
|
649
699
|
setOff(off: boolean): string {
|
|
650
700
|
this.off = off;
|
|
651
701
|
if (off) { this.runPlan = null; this.runEdits = false; this.cancelTimer(); this.discardBg(); }
|