pi-zip 0.2.6 → 0.2.7
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 +45 -11
- package/package.json +1 -1
- package/src/index.ts +45 -11
- package/src/notice.ts +19 -2
- package/src/recall.ts +36 -6
- package/src/run.ts +108 -12
- package/src/ui.ts +158 -0
package/README.md
CHANGED
|
@@ -16,12 +16,13 @@ Try it for one run without installing: `pi -e npm:pi-zip`.
|
|
|
16
16
|
|
|
17
17
|
## Measured results (v0.2.0)
|
|
18
18
|
|
|
19
|
-
Live A/B runs on the same coding tasks (4 task templates × 4 seeds, the user away
|
|
19
|
+
Live A/B runs on the same coding tasks (4 task templates × 4 seeds, the user away between prompts as stated), paired by task, 95% bootstrap intervals. BC = [billion-context](https://github.com/ranxianglei/billion-context) with its defaults.
|
|
20
20
|
|
|
21
21
|
| Model | Cost vs BC | Speed | Quality (task done / planted facts recalled) |
|
|
22
22
|
|---|---|---|---|
|
|
23
|
-
| Claude Sonnet 5.5 | **0.79×** [0.72, 0.86] | p90 wait per prompt 53 s vs 85 s; same as plain Pi | 16/16 and 1.00, same as BC |
|
|
24
|
-
| GPT-6.1-sol
|
|
23
|
+
| Claude Sonnet 5.5, away 6 min | **0.79×** [0.72, 0.86] | p90 wait per prompt 53 s vs 85 s; same as plain Pi | 16/16 and 1.00, same as BC |
|
|
24
|
+
| GPT-6.1-sol, away 6 min | 1.00× [0.92, 1.08] | median task 209 s vs 305 s | 16/16 and 1.00 vs 15/16 |
|
|
25
|
+
| GPT-6.1-sol, away 2 or 6 min | 1.07× [0.91, 1.22] | median task 271 s vs 389 s | 16/16 and 1.00 vs 14/16 |
|
|
25
26
|
|
|
26
27
|
Against plain Pi on the same Claude runs: same speed, 0.45× the cost. Hidden-question probes on real long sessions (questions whose answer had been folded): 45–52% answered from the original via `zip_recall` vs 5% for BC's reconstruction, same number of wrong answers.
|
|
27
28
|
|
|
@@ -52,7 +53,41 @@ The second line appears once per session. Expand tool output (ctrl+o) to see why
|
|
|
52
53
|
… 10 more
|
|
53
54
|
```
|
|
54
55
|
|
|
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.
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
Everything else pi-zip shows uses the same one-line grammar, and only when something changed:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
▸ pi-zip on folds old tool output after the prompt cache expires (5 min here) · nothing to set up · /zip for status
|
|
62
|
+
▸ pi-zip reread-only a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall to fold more
|
|
63
|
+
▸ pi-zip paused billion-context also manages context, so pi-zip only guards requests · to use pi-zip: pi remove the other one
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The first one appears once per machine. A folded output keeps its place in the transcript; its tool row gets a dim mark on the right, so you can see what the model no longer sees in full:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
$ npm test ▸ folded · k3x9q2m7ab
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A recall is one quiet row (ctrl+o shows the recalled text):
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
↺ recall k3x9q2m7ab grep "Expected"
|
|
76
|
+
bash npm test · turn 1 3 of 812 lines
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
While a summary started in the background is being finished, Pi's working line says so (Esc skips the wait). `/zip` prints a small card into the transcript:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
▸ pi-zip on
|
|
83
|
+
cache anthropic/claude-sonnet-5-5 · explicit · lives ~5 min (declared)
|
|
84
|
+
alive ██████▁▁▁▁▁▁▁▁▁▁ 30 s → 2 h
|
|
85
|
+
session 56 folds ~310K · 2 summaries $0.41 · 3 recalls
|
|
86
|
+
last 10:50 folded 12 old outputs · cache cold (away 47 min): this request rewrites it anyway, so editing is free
|
|
87
|
+
mode full (zip_recall available)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`alive` is what pi-zip currently believes about the cache: how likely it is to be still warm after 30 s, 1 min, … 2 h away, learned from the provider's replies (see below).
|
|
56
91
|
|
|
57
92
|
## The three rules
|
|
58
93
|
|
|
@@ -60,11 +95,11 @@ These lines are saved in the session, so they are still there after a restart, b
|
|
|
60
95
|
2. **Edit only when the cache is already gone.** Provider prompt caches expire (the model's declared TTL, usually minutes). Changing the context while the cache is warm means paying to rewrite it; changing it after it expired is free, because the whole context is rewritten anyway, and a smaller context makes that rewrite cheaper. So when you come back after the TTL (or after switching model, or when the session was last touched longer ago than the TTL), pi-zip folds old outputs down to about 40K real tokens in one step, and every request of that turn sends the same bytes. While the cache is warm it does nothing, with one exception, the warm valve: above the 40K target it applies that plan, minus the previous user turn (a warm edit never folds the turn you just finished, except when you come back after the declared TTL: there the previous turn is eligible exactly as at a cold return, so a provider whose cache outlives its TTL does not keep it at every return), when the edit pays for the rewrite it causes, and never on the request right after an edited one (no back-to-back warm rewrites). That is one inequality, r Δ²/(2g) + η Δ ≥ K with K = (w − r)(P T − (1 − P) Δ): the reads the removed Δ tokens would cost while the context grows back at g tokens per request (measured in the session), plus, near Pi's compaction trigger, what Pi would charge for the same room (η), against the rewrite of the T = A tokens left after the edit (pricing only the suffix after the earliest edit fires warm edits earlier and lost quality in the offline evaluation; the suffix is logged as `Tsuf` for measurement). r and w are the read and rewrite price ratios of the cache class, never the model's price table: explicit write premium 0.1 / 1.25 x input (2 x on the 1-hour tier), automatic prefix cache 0.2 / 1 x input; the class is read from the provider's usage reports, and until the first response the old fixed rule applies. P is the probability that the cache is still warm. A cold return is P = 0, so K < 0 and it always fires; a single small fold never pays at a warm cache, a large one does.
|
|
61
96
|
3. **Never in the way.** Planning is local and takes milliseconds. Anything that needs a model call (a summary, only when folding is not enough and the same inequality prices the extra model call in) is prepared while you are away: if the cache is about to expire (0.8 x its lifetime after your last request) and you have not come back, a background timer writes the summary with a separate, uncached call. The timer is cancelled the moment you send a prompt. If you return before the summary finishes, only the remaining time is waited, Esc stops the waiting, and the notice says so. If you return while the cache is still warm and the valve does not fire, the prepared summary is discarded (its cost is still counted). In non-interactive modes (`-p`, `--mode json`) nothing is ever started in the background: a cold return that needs a summary computes it right then.
|
|
62
97
|
|
|
63
|
-
**The cache lifetime is learned, not configured.** Every response says how much of the prompt came from the cache. pi-zip compares that read with what the request re-sent unchanged (the previous prompt, or the untouched prefix before one of its own edits: on an automatic prefix cache every edit leaves the first 8K tokens alone, so even the response right after a fold says whether the cache survived) and so learns, per provider and model, whether the cache survived a gap of that length: a few counts per gap bin (30 s to 90 min, with bin edges on the 5-minute and 1-hour tiers), monotone in the gap, older evidence halved after 16 newer observations of the same bin, stored without any content in `~/.pi/agent/pi-zip/cache-survival.json`. Before any evidence the model's declared TTL decides, exactly as before (300 s when it declares none; too short a guess is cheaper than too long); beyond it one clean read overrides it, inside it a lone miss counts as noise (warm caches do miss now and then) and only repeated misses do. A GLM cache read in full after 365 s makes the next 365 s return warm; a Claude 5-minute cache that read nothing after 360 s stays dead. Whether the provider bills cache writes (explicit cache) or not (automatic prefix cache) is read from the first response too. `/zip status` shows the class,
|
|
98
|
+
**The cache lifetime is learned, not configured.** Every response says how much of the prompt came from the cache. pi-zip compares that read with what the request re-sent unchanged (the previous prompt, or the untouched prefix before one of its own edits: on an automatic prefix cache every edit leaves the first 8K tokens alone, so even the response right after a fold says whether the cache survived) and so learns, per provider and model, whether the cache survived a gap of that length: a few counts per gap bin (30 s to 90 min, with bin edges on the 5-minute and 1-hour tiers), monotone in the gap, older evidence halved after 16 newer observations of the same bin, stored without any content in `~/.pi/agent/pi-zip/cache-survival.json`. Before any evidence the model's declared TTL decides, exactly as before (300 s when it declares none; too short a guess is cheaper than too long); beyond it one clean read overrides it, inside it a lone miss counts as noise (warm caches do miss now and then) and only repeated misses do. A GLM cache read in full after 365 s makes the next 365 s return warm; a Claude 5-minute cache that read nothing after 360 s stays dead. Whether the provider bills cache writes (explicit cache) or not (automatic prefix cache) is read from the first response too. `/zip status` shows the class, the lifetime it currently believes, and the learned survival as the `alive` row.
|
|
64
99
|
|
|
65
100
|
Protected from folding: the current user turn and the previous one. When the context is above the target, re-readable outputs of the previous turn (an unchanged file, a read-only command) can still be folded at a cold return or at any return after the declared TTL (never on a warm request inside it), and any output in either turn can be folded once it is 60 assistant requests old (so a long agent run that is a single user turn with hundreds of tool calls is not exempt from folding; the newest 59 requests' outputs always stay, and every fold stays recallable). Messages you type while the agent is running (steering, follow-up) belong to that turn and do not start a new one. Outputs you have already recalled, and `zip_recall` results themselves, are never folded again. "Read-only" is a conservative whitelist: `find -delete` or `-exec`, command substitution, redirects, background jobs, `git diff --output` and the like are not.
|
|
66
101
|
|
|
67
|
-
**Token counts are calibrated, not guessed.** Sizes are estimated as chars/4, which undercounts real tokens (typically by about 1.7x in coding sessions). So the cold cap, the compaction room and the law's token counts are all compared against `k` x the estimate, where `k` = real tokens / estimated tokens for the newest assistant message that reports usage (input + cache read + cache write, over the estimate of the context that request carried; clamped to 1 to 2.5). `k` is read from the session itself on every decision, so a restart, `pi -p` or a resumed session calibrates exactly like a long-lived one, and nothing extra is stored. With no usage to read (a brand-new session, or a provider that reports none) `k` is 1.7. Sizes in the notices, `/zip
|
|
102
|
+
**Token counts are calibrated, not guessed.** Sizes are estimated as chars/4, which undercounts real tokens (typically by about 1.7x in coding sessions). So the cold cap, the compaction room and the law's token counts are all compared against `k` x the estimate, where `k` = real tokens / estimated tokens for the newest assistant message that reports usage (input + cache read + cache write, over the estimate of the context that request carried; clamped to 1 to 2.5). `k` is read from the session itself on every decision, so a restart, `pi -p` or a resumed session calibrates exactly like a long-lived one, and nothing extra is stored. With no usage to read (a brand-new session, or a provider that reports none) `k` is 1.7. Sizes in the notices, `/zip status` and the ledger use the same scale.
|
|
68
103
|
|
|
69
104
|
The cold cap is kept below Pi's own compaction trigger (window minus `compaction.reserveTokens`), so on small windows Pi's lossy compaction does not get there first.
|
|
70
105
|
|
|
@@ -72,13 +107,12 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
|
|
|
72
107
|
|
|
73
108
|
| Command | Effect |
|
|
74
109
|
|---|---|
|
|
75
|
-
| `/zip status` |
|
|
76
|
-
| `/zip stats` | this session's folds (with tokens removed), summaries and recalls; persisted in the session, so a restart does not reset them |
|
|
110
|
+
| `/zip` or `/zip status` | the card above: state, cache class and lifetime, learned survival, this session's folds, summaries and recalls (counted from the session, so a restart does not reset them) |
|
|
77
111
|
| `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
|
|
78
112
|
| `/zip on` | resume |
|
|
79
113
|
| `/zip quiet` | toggle the per-turn notice (folding continues) |
|
|
80
114
|
|
|
81
|
-
`off` and `quiet` are remembered per session.
|
|
115
|
+
`off` and `quiet` are remembered per session; each change is one line in the transcript. In `-p` / json mode `/zip status` prints plain text to stderr.
|
|
82
116
|
|
|
83
117
|
## Recall
|
|
84
118
|
|
|
@@ -120,9 +154,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
|
|
|
120
154
|
|
|
121
155
|
## FAQ
|
|
122
156
|
|
|
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
|
|
157
|
+
**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 status` 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.
|
|
124
158
|
|
|
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
|
|
159
|
+
**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 status`. 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.
|
|
126
160
|
|
|
127
161
|
**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.
|
|
128
162
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zip",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
@@ -5,6 +5,7 @@ import { type NoticeData, registerZipCommand, renderNotice } from "./notice.ts";
|
|
|
5
5
|
import { RECALL_TOOL } from "./placeholder.ts";
|
|
6
6
|
import { registerRecallTool } from "./recall.ts";
|
|
7
7
|
import { NOTICE_CUSTOM, Zip } from "./run.ts";
|
|
8
|
+
import { markFirstLine, measure, renderCard, renderState, setMeasure } from "./ui.ts";
|
|
8
9
|
|
|
9
10
|
export default function piZip(pi: ExtensionAPI) {
|
|
10
11
|
if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
|
|
@@ -12,20 +13,22 @@ export default function piZip(pi: ExtensionAPI) {
|
|
|
12
13
|
registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
|
|
13
14
|
registerZipCommand(pi, zip);
|
|
14
15
|
try {
|
|
15
|
-
//
|
|
16
|
-
|
|
16
|
+
// Everything pi-zip shows lives in the transcript as custom entries (never part of the model's context): fold/summary
|
|
17
|
+
// notices, state lines and the /zip status card. Widths are measured with Pi's own pi-tui once it has loaded.
|
|
17
18
|
import("@earendil-works/pi-tui").then((m: Any) => {
|
|
18
|
-
if (typeof m?.visibleWidth === "function"
|
|
19
|
+
if (typeof m?.visibleWidth === "function" && typeof m?.truncateToWidth === "function")
|
|
20
|
+
setMeasure({ vw: m.visibleWidth, cut: (s: string, w: number) => (m.visibleWidth(s) <= w ? s : w <= 0 ? "" : m.truncateToWidth(s, w, "…")) });
|
|
19
21
|
}, () => {});
|
|
20
22
|
pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
|
|
21
23
|
const d = entry?.data;
|
|
22
24
|
if (!d?.text) return undefined;
|
|
23
|
-
const data: NoticeData = d.v === 2 ? d : { v: 2, text: d.text, before: 0, after: 0, desc: "" };
|
|
24
25
|
return {
|
|
25
26
|
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
27
|
try {
|
|
28
|
-
return
|
|
28
|
+
if (d.v !== 2) return [theme.fg("dim", measure.cut(d.text, width))];
|
|
29
|
+
if (d.kind === "state") return renderState(d.word, d.reason ?? "", width, theme, measure);
|
|
30
|
+
if (d.kind === "card" && d.card) return renderCard(d.card, width, theme, measure);
|
|
31
|
+
return renderNotice(d as NoticeData, !!o?.expanded, width, theme, measure.vw);
|
|
29
32
|
} catch {
|
|
30
33
|
return [];
|
|
31
34
|
}
|
|
@@ -34,25 +37,56 @@ export default function piZip(pi: ExtensionAPI) {
|
|
|
34
37
|
};
|
|
35
38
|
});
|
|
36
39
|
zip.entryRenderer = typeof pi.registerEntryRenderer === "function";
|
|
40
|
+
// a folded output keeps its place in the transcript; its call row gets a dim "▸ folded · <handle>" on the right
|
|
41
|
+
pi.registerToolRenderer?.((toolName: string, next: () => Any) => {
|
|
42
|
+
const base = next();
|
|
43
|
+
if (toolName === RECALL_TOOL || typeof base?.renderCall !== "function") return base;
|
|
44
|
+
return {
|
|
45
|
+
...base,
|
|
46
|
+
renderCall: (args: Any, theme: Any, c: Any) => {
|
|
47
|
+
const comp = base.renderCall(args, theme, c);
|
|
48
|
+
const id = c?.toolCallId;
|
|
49
|
+
if (!comp || typeof comp.render !== "function" || typeof id !== "string") return comp;
|
|
50
|
+
return new Proxy(comp, {
|
|
51
|
+
get(t, p, r) {
|
|
52
|
+
if (p !== "render") return Reflect.get(t, p, r);
|
|
53
|
+
return (width: number) => {
|
|
54
|
+
const lines = t.render(width);
|
|
55
|
+
const h = zip.foldedHandle(id);
|
|
56
|
+
try {
|
|
57
|
+
return h ? markFirstLine(lines, h, width, theme, measure) : lines;
|
|
58
|
+
} catch {
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
});
|
|
37
67
|
} catch {
|
|
38
68
|
/* older Pi: notices fall back to the status line */
|
|
39
69
|
}
|
|
40
70
|
// A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
|
|
41
71
|
// zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
|
|
42
|
-
const checkRecall = () => {
|
|
72
|
+
const checkRecall = (ctx: Any) => {
|
|
43
73
|
try {
|
|
44
74
|
const active = pi.getActiveTools?.();
|
|
45
|
-
if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL));
|
|
75
|
+
if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL), ctx);
|
|
46
76
|
} catch {
|
|
47
77
|
/* never in the way */
|
|
48
78
|
}
|
|
49
79
|
};
|
|
50
80
|
pi.on("session_start", (_e, ctx) => {
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
const r = zip.sessionStart(ctx);
|
|
82
|
+
checkRecall(ctx);
|
|
83
|
+
zip.refreshFolded(ctx);
|
|
84
|
+
zip.welcome(ctx);
|
|
85
|
+
return r;
|
|
53
86
|
});
|
|
54
87
|
pi.on("before_agent_start", (_e, ctx) => {
|
|
55
|
-
checkRecall();
|
|
88
|
+
checkRecall(ctx);
|
|
89
|
+
zip.refreshFolded(ctx); // the branch may have changed (/tree, fork)
|
|
56
90
|
return zip.beforeAgentStart(ctx);
|
|
57
91
|
});
|
|
58
92
|
pi.on("context_with_system", (e, ctx) => zip.context(e, ctx)); // the complete transcript: system messages stay where Pi put them
|
package/src/notice.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// User-facing text (F15, F16): one notice line per turn, honest stats, the /zip command. Notices never enter model context.
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type Any, PRODUCT } from "./util.ts";
|
|
4
|
+
import type { CardData, StateWord } from "./ui.ts";
|
|
4
5
|
|
|
5
6
|
export interface NoticeAction {
|
|
6
7
|
kind: "fold" | "summary";
|
|
@@ -23,6 +24,10 @@ export interface NoticeItem {
|
|
|
23
24
|
/** 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
25
|
export interface NoticeData {
|
|
25
26
|
v: 2;
|
|
27
|
+
kind?: "state" | "card"; // absent: a fold/summary notice
|
|
28
|
+
word?: StateWord;
|
|
29
|
+
reason?: string;
|
|
30
|
+
card?: CardData;
|
|
26
31
|
text: string;
|
|
27
32
|
before: number;
|
|
28
33
|
after: number;
|
|
@@ -118,6 +123,9 @@ export class Stats {
|
|
|
118
123
|
|
|
119
124
|
export interface ZipControl {
|
|
120
125
|
status(ctx?: Any): string;
|
|
126
|
+
card?(ctx?: Any): CardData;
|
|
127
|
+
/** Show a /zip result in the transcript; false = no transcript surface here (print/json/rpc or an older Pi). */
|
|
128
|
+
show?(ctx: Any, data: NoticeData): boolean;
|
|
121
129
|
statsLine(ctx?: Any): string;
|
|
122
130
|
setOff(off: boolean): string;
|
|
123
131
|
toggleQuiet(): string;
|
|
@@ -125,16 +133,25 @@ export interface ZipControl {
|
|
|
125
133
|
|
|
126
134
|
export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
|
|
127
135
|
pi.registerCommand("zip", {
|
|
128
|
-
description: `${PRODUCT}: status |
|
|
136
|
+
description: `${PRODUCT}: status | off | on | quiet`,
|
|
129
137
|
handler: async (args: string, cctx: Any) => {
|
|
130
138
|
const sub = (args ?? "").trim().toLowerCase() || "status";
|
|
139
|
+
if ((sub === "status" || sub === "stats") && zip.card && zip.show) {
|
|
140
|
+
const card = zip.card(cctx);
|
|
141
|
+
if (zip.show(cctx, { v: 2, kind: "card", card, text: zip.status(cctx), before: 0, after: 0, desc: "" })) return;
|
|
142
|
+
}
|
|
131
143
|
const text =
|
|
132
144
|
sub === "status" ? zip.status(cctx)
|
|
133
145
|
: sub === "stats" ? zip.statsLine(cctx)
|
|
134
146
|
: sub === "off" ? zip.setOff(true)
|
|
135
147
|
: sub === "on" ? zip.setOff(false)
|
|
136
148
|
: sub === "quiet" ? zip.toggleQuiet()
|
|
137
|
-
: `${PRODUCT}: unknown subcommand "${sub}" (use status |
|
|
149
|
+
: `${PRODUCT}: unknown subcommand "${sub}" (use status | off | on | quiet)`;
|
|
150
|
+
if (zip.show && (sub === "off" || sub === "on" || sub === "quiet")) {
|
|
151
|
+
const word: StateWord = sub === "quiet" ? (/notices off/.test(text) ? "quiet" : "notices on") : sub;
|
|
152
|
+
const reason = text.replace(/^pi-zip: (off|on)\.? ?/, "").replace(/^pi-zip: /, "");
|
|
153
|
+
if (zip.show(cctx, { v: 2, kind: "state", word, reason: reason || "folding resumes", text, before: 0, after: 0, desc: "" })) return;
|
|
154
|
+
}
|
|
138
155
|
if (cctx?.ui?.notify && (cctx.mode === "tui" || cctx.mode === "rpc")) {
|
|
139
156
|
try {
|
|
140
157
|
cctx.ui.notify(text);
|
package/src/recall.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// zip_recall (F3): exact, batched retrieval of folded originals from the session file (I2).
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { handleFor, RECALL_TOOL } from "./placeholder.ts";
|
|
3
|
+
import { handleFor, RECALL_TOOL, shortArgs } from "./placeholder.ts";
|
|
4
|
+
import { measure, recallCallLine, recallResultLines, type RecallSectionInfo } from "./ui.ts";
|
|
4
5
|
import { type Any, clamp, textOf } from "./util.ts";
|
|
5
6
|
|
|
6
7
|
const RECALL_PAGE_CHARS = 20_000; // default page
|
|
@@ -101,34 +102,50 @@ export interface RecallItem {
|
|
|
101
102
|
handle: string;
|
|
102
103
|
text: string | null;
|
|
103
104
|
tool: string | null;
|
|
105
|
+
label?: string; // tool + short args of the call that produced it (transcript only)
|
|
106
|
+
turn?: number;
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number } {
|
|
109
|
+
export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number; sections: RecallSectionInfo[] } {
|
|
107
110
|
const parts: string[] = [];
|
|
111
|
+
const sections: RecallSectionInfo[] = [];
|
|
108
112
|
let ok = 0;
|
|
109
113
|
let missing = 0;
|
|
110
114
|
for (const it of items) {
|
|
111
115
|
if (it.text === null) {
|
|
112
116
|
missing++;
|
|
117
|
+
sections.push({ handle: it.handle, missing: true });
|
|
113
118
|
parts.push(`[handle ${it.handle}] not found. Copy the handle exactly from the folded block's marker line or the summary's handle table.`);
|
|
114
119
|
continue;
|
|
115
120
|
}
|
|
116
121
|
ok++;
|
|
117
122
|
const slice = sliceRecall(it.text, opts);
|
|
123
|
+
const grep = opts.grep !== undefined && String(opts.grep).trim() !== "", range = opts.range !== undefined && String(opts.range).trim() !== "";
|
|
124
|
+
const how = grep ? "grep" : range ? "range" : slice.clipped || slice.offset > 0 ? "page" : "all";
|
|
125
|
+
const shown = grep ? (slice.matched ?? 0) : slice.body ? slice.body.split("\n").length : 0;
|
|
126
|
+
sections.push({ handle: it.handle, label: it.label ?? it.tool ?? undefined, turn: it.turn, totalLines: slice.totalLines, shownLines: shown, how });
|
|
118
127
|
parts.push(`[handle ${it.handle}${it.tool ? " · " + it.tool : ""} · ${it.text.length} chars · ${slice.totalLines} lines]\n${slice.text}`);
|
|
119
128
|
}
|
|
120
|
-
return { text: parts.join("\n\n"), ok, missing };
|
|
129
|
+
return { text: parts.join("\n\n"), ok, missing, sections };
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
/** Resolve handles against the session branch (which spans entries before any compaction): exact originals. */
|
|
124
133
|
export function resolveHandlesInBranch(branch: Any[], handles: string[]): { items: RecallItem[]; entryIds: (string | null)[] } {
|
|
125
134
|
const items: RecallItem[] = handles.map((h) => ({ handle: h, text: null, tool: null }));
|
|
126
135
|
const entryIds: (string | null)[] = handles.map(() => null);
|
|
136
|
+
const calls = new Map<string, Any>();
|
|
137
|
+
let turn = 0;
|
|
127
138
|
for (const en of branch) {
|
|
128
|
-
|
|
139
|
+
const msg = en?.type === "message" ? en.message : null;
|
|
140
|
+
if (msg?.role === "user") turn++;
|
|
141
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) for (const c of msg.content) if (c?.type === "toolCall" && c.id) calls.set(c.id, c);
|
|
142
|
+
if (msg?.role !== "toolResult") continue;
|
|
129
143
|
const k = handles.indexOf(handleFor(en.id));
|
|
130
144
|
if (k < 0) continue;
|
|
131
|
-
|
|
145
|
+
const call = calls.get(msg.toolCallId);
|
|
146
|
+
const tool = msg.toolName ?? call?.name ?? null;
|
|
147
|
+
const args = call ? shortArgs(call.arguments) : "";
|
|
148
|
+
items[k] = { handle: handles[k], text: textOf(msg.content), tool, label: tool ? `${tool}${args ? " " + args : ""}` : undefined, turn: turn || undefined };
|
|
132
149
|
entryIds[k] = en.id;
|
|
133
150
|
}
|
|
134
151
|
return { items, entryIds };
|
|
@@ -177,6 +194,14 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
|
|
|
177
194
|
},
|
|
178
195
|
required: [],
|
|
179
196
|
} as Any,
|
|
197
|
+
renderCall(args: Any, theme: Any) {
|
|
198
|
+
return lines((w) => recallCallLine(args ?? {}, w, theme, measure));
|
|
199
|
+
},
|
|
200
|
+
renderResult(result: Any, options: Any, theme: Any) {
|
|
201
|
+
const text = textOf(result?.content);
|
|
202
|
+
const secs: RecallSectionInfo[] = Array.isArray(result?.details?.sections) ? result.details.sections : [];
|
|
203
|
+
return lines((w) => recallResultLines(secs, text, !!options?.expanded, w, theme, measure));
|
|
204
|
+
},
|
|
180
205
|
async execute(_id: string, params: Any, _signal: Any, _onUpdate: Any, ctx: Any): Promise<Any> {
|
|
181
206
|
const grep = params?.grep !== undefined ? String(params.grep) : undefined;
|
|
182
207
|
const range = params?.range !== undefined ? String(params.range) : undefined;
|
|
@@ -190,10 +215,15 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
|
|
|
190
215
|
const { items, entryIds } = resolveHandlesInBranch(ctx.sessionManager.getBranch() as Any[], handles);
|
|
191
216
|
const out = recallSections(items, { grep, range, offset, limit });
|
|
192
217
|
hooks.onRecall(handles, items.reduce((a, it) => a + (it.text?.length ?? 0), 0), entryIds);
|
|
193
|
-
return { content: [{ type: "text", text: out.text }], details: { handles, resolved: out.ok, missing: out.missing }, isError: out.ok === 0 };
|
|
218
|
+
return { content: [{ type: "text", text: out.text }], details: { handles, resolved: out.ok, missing: out.missing, sections: out.sections }, isError: out.ok === 0 };
|
|
194
219
|
} catch (err) {
|
|
195
220
|
return { content: [{ type: "text", text: `${RECALL_TOOL} failed: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
|
|
196
221
|
}
|
|
197
222
|
},
|
|
198
223
|
});
|
|
199
224
|
}
|
|
225
|
+
|
|
226
|
+
/** A minimal pi-tui Component: lines computed for the width at render time. */
|
|
227
|
+
function lines(fn: (width: number) => string[]): Any {
|
|
228
|
+
return { render: (width: number) => fn(Math.max(1, width)), invalidate: () => {} };
|
|
229
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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, mkdirSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
|
|
7
|
-
import { describe, lawPrices, loadStats, pWarm, record, sample, GAP_EDGES, type Entry } from "./learn.ts";
|
|
7
|
+
import { 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
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";
|
|
@@ -12,6 +12,7 @@ import { handleFor, PH_MARK, RECALL_TOOL } from "./placeholder.ts";
|
|
|
12
12
|
import { recalledHandlesFromBranch } from "./recall.ts";
|
|
13
13
|
import { buildCut } from "./summary.ts";
|
|
14
14
|
import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
|
|
15
|
+
import { fmtLife, SPARK_GAPS, type CardData, type StateWord } from "./ui.ts";
|
|
15
16
|
|
|
16
17
|
export const PLAN_CUSTOM = "pi-zip/plan";
|
|
17
18
|
export const STATE_CUSTOM = "pi-zip/state";
|
|
@@ -103,9 +104,62 @@ export class Zip implements ZipControl {
|
|
|
103
104
|
private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
|
|
104
105
|
/** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
|
|
105
106
|
summarizedWarm = false;
|
|
106
|
-
setRecallOk(ok: boolean) {
|
|
107
|
+
setRecallOk(ok: boolean, ctx?: Any) {
|
|
107
108
|
if (ok !== this.recallOk) this.ledger({ type: "recall_available", ok });
|
|
108
109
|
this.recallOk = ok;
|
|
110
|
+
if (!ok && ctx) this.stateLine(ctx, "reread-only", "a tool allowlist hides zip_recall · only re-readable outputs fold · allow zip_recall to fold more");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Folded tool outputs of the current branch by toolCallId -> handle (the transcript marks them). */
|
|
114
|
+
readonly foldedCalls = new Map<string, string>();
|
|
115
|
+
foldedHandle = (toolCallId: string): string | undefined => this.foldedCalls.get(toolCallId);
|
|
116
|
+
refreshFolded(ctx: Any) {
|
|
117
|
+
const branch = this.branch(ctx);
|
|
118
|
+
const byId = new Map(branch.map((e: Any) => [e?.id, e]));
|
|
119
|
+
this.foldedCalls.clear();
|
|
120
|
+
for (const e of branch) {
|
|
121
|
+
if (e?.type !== "context_edit" || !String(e?.replacement?.content?.[0]?.text ?? "").startsWith(PH_MARK)) continue;
|
|
122
|
+
const id = byId.get(e.targetId)?.message?.toolCallId;
|
|
123
|
+
if (typeof id === "string") this.foldedCalls.set(id, handleFor(e.targetId));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** /zip output in the transcript (custom entry, never sent to the model). */
|
|
128
|
+
show(ctx: Any, data: NoticeData): boolean {
|
|
129
|
+
if (ctx?.mode !== "tui" || !this.entryRenderer) return false;
|
|
130
|
+
try {
|
|
131
|
+
this.pi.appendEntry(NOTICE_CUSTOM, data);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A state change as one transcript line ("▸ pi-zip paused …"); each word once per session unless always. */
|
|
139
|
+
stateLine(ctx: Any, word: StateWord, reason: string, once = true) {
|
|
140
|
+
const text = `${PRODUCT} ${word} ${reason}`;
|
|
141
|
+
if (ctx?.mode !== "tui" && word !== "paused") return; // print/json/rpc (sub-agents): no transcript to keep it in
|
|
142
|
+
if (once && (this.statesShown.has(word) || this.branch(ctx).some((e: Any) => e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.kind === "state" && e.data?.word === word))) return;
|
|
143
|
+
this.statesShown.add(word);
|
|
144
|
+
this.ledger({ type: "state", word, reason });
|
|
145
|
+
// 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);
|
|
147
|
+
}
|
|
148
|
+
private statesShown = new Set<string>();
|
|
149
|
+
|
|
150
|
+
/** First pi-zip session on this machine: one line that sets the expectation (nothing visible happens until the cache expires). */
|
|
151
|
+
welcome(ctx: Any) {
|
|
152
|
+
if (ctx?.mode !== "tui" || !this.entryRenderer || this.off || this.conflict) return;
|
|
153
|
+
const flag = `${dirname(process.env.PI_ZIP_CACHE_STATS || statsPath())}/welcomed`;
|
|
154
|
+
try {
|
|
155
|
+
if (existsSync(flag)) return;
|
|
156
|
+
mkdirSync(dirname(flag), { recursive: true });
|
|
157
|
+
writeFileSync(flag, new Date().toISOString() + "\n");
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const ttl = ttlFor(ctx.model ?? this.model);
|
|
162
|
+
this.stateLine(ctx, "on", `folds old tool output after the prompt cache expires (${fmtLife(ttl / 1000).slice(1)} here) · nothing to set up · /zip for status`);
|
|
109
163
|
}
|
|
110
164
|
|
|
111
165
|
ledger(rec: Record<string, unknown>) {
|
|
@@ -202,7 +256,7 @@ export class Zip implements ZipControl {
|
|
|
202
256
|
this.conflict = found;
|
|
203
257
|
if (found && !this.conflictNoticed) {
|
|
204
258
|
this.conflictNoticed = true;
|
|
205
|
-
this.
|
|
259
|
+
this.stateLine(ctx, "paused", `${found} also manages context, so pi-zip only guards requests · to use pi-zip: pi remove the other one`, true);
|
|
206
260
|
}
|
|
207
261
|
}
|
|
208
262
|
|
|
@@ -272,7 +326,7 @@ export class Zip implements ZipControl {
|
|
|
272
326
|
this.ledger({ type: "prompt", cold, reason, ttlMs: ttl.ms, ttlSource: ttl.source, settleTargets: this.settleIds.length, gapS: gapS === null ? null : Math.round(gapS), pWarm: Math.round(p * 1000) / 1000, survSrc: src, cls: this.ent?.cls ?? null });
|
|
273
327
|
if (ttl.note && !this.ttlNoticed) {
|
|
274
328
|
this.ttlNoticed = true; // once per session
|
|
275
|
-
if (!this.quiet) this.
|
|
329
|
+
if (!this.quiet) this.stateLine(ctx, "cache", ttl.note.replace(`${PRODUCT} · `, ""));
|
|
276
330
|
}
|
|
277
331
|
}
|
|
278
332
|
|
|
@@ -323,14 +377,29 @@ export class Zip implements ZipControl {
|
|
|
323
377
|
}
|
|
324
378
|
|
|
325
379
|
/** A prepared summary for this cut, or null. A summary still being written is waited for (only the remainder); Esc stops the waiting, not the work. */
|
|
326
|
-
|
|
380
|
+
/** While the user waits for a summary, Pi's working line says what for (and that Esc skips a prepared one). */
|
|
381
|
+
private async working<T>(ctx: Any, text: string, p: Promise<T>): Promise<T> {
|
|
382
|
+
const ui = ctx?.mode === "tui" ? ctx?.ui : null;
|
|
383
|
+
try {
|
|
384
|
+
ui?.setWorkingMessage?.(text);
|
|
385
|
+
} catch {}
|
|
386
|
+
try {
|
|
387
|
+
return await p;
|
|
388
|
+
} finally {
|
|
389
|
+
try {
|
|
390
|
+
ui?.setWorkingMessage?.(undefined);
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async takeBg(key: string | null, signal?: AbortSignal, ctx?: Any): Promise<{ cut: Cut | null; adopted: boolean }> {
|
|
327
396
|
const b = this.bg;
|
|
328
397
|
if (!b || key === null || b.key !== key) {
|
|
329
398
|
this.discardBg();
|
|
330
399
|
return { cut: null, adopted: false };
|
|
331
400
|
}
|
|
332
401
|
const w0 = performance.now();
|
|
333
|
-
const cut = b.done ?? (await waitFor(b.promise, signal));
|
|
402
|
+
const cut = b.done ?? (await this.working(ctx, `${PRODUCT}: finishing the summary it started while you were away · Esc skips it`, waitFor(b.promise, signal)));
|
|
334
403
|
this.bgWaitMs = performance.now() - w0;
|
|
335
404
|
if (cut) {
|
|
336
405
|
this.bg = null;
|
|
@@ -364,7 +433,7 @@ export class Zip implements ZipControl {
|
|
|
364
433
|
let cut: Cut | null = null;
|
|
365
434
|
let adopted = false;
|
|
366
435
|
if (p && p.cutIdx !== null) {
|
|
367
|
-
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
|
|
436
|
+
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
|
|
368
437
|
cut = got.cut;
|
|
369
438
|
adopted = got.adopted;
|
|
370
439
|
} else {
|
|
@@ -373,7 +442,7 @@ export class Zip implements ZipControl {
|
|
|
373
442
|
const sameSet = this.settleIds.length === folds.length && this.settleIds.every((id) => folds.some((u) => u.entryId === id));
|
|
374
443
|
const source: RunPlan["source"] = !this.cold ? "valve" : adopted || (this.settleIds.length > 0 && sameSet) ? "settle" : "runstart";
|
|
375
444
|
const foldMs = performance.now() - t0;
|
|
376
|
-
if (!cut && p && p.cutIdx !== null && !ctx.signal?.aborted) cut = await buildCut(p, ctx); // produced now, before the first request: the user waits
|
|
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
|
|
377
446
|
// a plan that cannot be persisted must never be sent: the request view would differ from what turn_end can write (I1)
|
|
378
447
|
if (p) {
|
|
379
448
|
const invalid = checkEdits(p.blocks, p.userTurns, folds, cut);
|
|
@@ -518,8 +587,8 @@ export class Zip implements ZipControl {
|
|
|
518
587
|
if (!p || (!p.folds.length && p.cutIdx === null)) return undefined;
|
|
519
588
|
let cut: Cut | null = null;
|
|
520
589
|
if (p.cutIdx !== null) {
|
|
521
|
-
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
|
|
522
|
-
cut = got.cut ?? (await buildCut(p, ctx)); // the user is here and the cache is warm; the alternative is Pi's lossy compaction
|
|
590
|
+
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
|
|
591
|
+
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
|
|
523
592
|
} else this.discardBg();
|
|
524
593
|
const plan: RunPlan = { source: "valve", folds: p.folds, cut, ctxBefore: p.ctxTokens, ctxAfter: p.ctxAfterFolds, ms: performance.now() - t0, k: p.k, persisted: false, untouched: p.k * untouchedEst(p.blocks, p.folds, !!cut, popts.sys) };
|
|
525
594
|
return this.commit(e, ctx, plan, o);
|
|
@@ -547,6 +616,7 @@ export class Zip implements ZipControl {
|
|
|
547
616
|
}
|
|
548
617
|
if (!plan.sent) this.nextEdit = plan.untouched ?? 0; // persisted now, first sent with the next request
|
|
549
618
|
const ours: Any[] = live.map((t) => ({ type: "context_edit", targetId: t.entryId, replacement: { content: [{ type: "text", text: t.ph }] } }));
|
|
619
|
+
for (const t of live) if (t.toolCallId) this.foldedCalls.set(t.toolCallId, handleFor(t.entryId));
|
|
550
620
|
if (cut) ours.push({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger }, usage: cut.usage });
|
|
551
621
|
const before = Math.round(k * live.reduce((a, t) => a + t.entryTokens, 0));
|
|
552
622
|
const after = Math.round(k * live.reduce((a, t) => a + t.phTokens, 0));
|
|
@@ -668,10 +738,11 @@ export class Zip implements ZipControl {
|
|
|
668
738
|
} catch {}
|
|
669
739
|
}
|
|
670
740
|
/** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
|
|
671
|
-
private
|
|
741
|
+
private totals(ctx: Any) {
|
|
672
742
|
const branch = ctx ? this.branch(ctx) : [];
|
|
673
743
|
const byId = new Map(branch.map((e: Any) => [e?.id, e]));
|
|
674
744
|
let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
|
|
745
|
+
let last: Any = null;
|
|
675
746
|
for (const e of branch) {
|
|
676
747
|
const ph = String(e?.replacement?.content?.[0]?.text ?? "");
|
|
677
748
|
if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
|
|
@@ -681,11 +752,36 @@ export class Zip implements ZipControl {
|
|
|
681
752
|
summaries++;
|
|
682
753
|
usd += Number(e.usage?.cost?.total) || 0;
|
|
683
754
|
} else if (e?.type === "custom" && e.customType === UNUSED_SUMMARY_CUSTOM) usd += Number(e.data?.usd) || 0;
|
|
755
|
+
else if (e?.type === "custom" && e.customType === NOTICE_CUSTOM && e.data?.v === 2 && !e.data?.kind) last = e;
|
|
684
756
|
else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
|
|
685
757
|
}
|
|
758
|
+
return { folds, folded, summaries, recalls, usd, last };
|
|
759
|
+
}
|
|
760
|
+
/** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
|
|
761
|
+
private sessionTotals(ctx: Any): string {
|
|
762
|
+
const { folds, folded, summaries, recalls, usd } = this.totals(ctx);
|
|
686
763
|
const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
|
|
687
764
|
return `this session: ${n(folds, "folded output", "folded outputs")} (~${fmtK(folded)} tokens), ${n(summaries, "summary", "summaries")}${usd > 0 ? ` (summary calls $${usd.toFixed(4)})` : ""}, ${n(recalls, "recall", "recalls")}`;
|
|
688
765
|
}
|
|
766
|
+
/** The /zip status card (rendered by ui.ts renderCard). */
|
|
767
|
+
card(ctx?: Any): CardData {
|
|
768
|
+
const t = this.totals(ctx);
|
|
769
|
+
const model = ctx?.model ?? this.model;
|
|
770
|
+
const key = modelKey(model), ttlS = ttlFor(model) / 1000, ent = key ? loadStats().models[key] : undefined;
|
|
771
|
+
const alive = key ? SPARK_GAPS.map((g) => pWarm(ent, g, ttlS).p) : undefined;
|
|
772
|
+
let lifeS = 0;
|
|
773
|
+
for (let g = 30; g <= 7200; g += 30) if (pWarm(ent, g, ttlS).p >= 0.5) lifeS = g;
|
|
774
|
+
const learned = !!ent && ent.n > 0 && pWarm(ent, Math.max(30, lifeS), ttlS).src === "learned";
|
|
775
|
+
const life = key ? `${lifeS >= 7200 ? "over 2 h" : fmtLife(lifeS || ttlS)} (${learned ? `learned from ${Math.round(ent!.n)} ${Math.round(ent!.n) === 1 ? "reply" : "replies"}` : "declared"})` : undefined;
|
|
776
|
+
const ts = t.last?.timestamp ? new Date(t.last.timestamp) : null;
|
|
777
|
+
const hhmm = ts && !Number.isNaN(ts.getTime()) ? `${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")} ` : "";
|
|
778
|
+
const last = t.last ? `${hhmm}${t.last.data.desc}${t.last.data.why ? " · " + t.last.data.why : ""}` : undefined;
|
|
779
|
+
const state = this.off ? "off" : this.conflict ? "paused" : this.recallOk ? "on" : "reread-only";
|
|
780
|
+
return {
|
|
781
|
+
state, stateNote: state === "paused" ? `${this.conflict} also manages context` : state === "off" ? "/zip on to resume" : undefined,
|
|
782
|
+
model: key || undefined, cls: ent?.cls, life, alive, folds: t.folds, foldedTokens: fmtK(t.folded), summaries: t.summaries, summaryUsd: t.usd, recalls: t.recalls, last, quiet: this.quiet,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
689
785
|
status(ctx?: Any): string {
|
|
690
786
|
const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
|
|
691
787
|
const key = modelKey(this.model), ttl = ttlFor(this.model), ent = loadStats().models[key];
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Transcript surfaces (pure, testable): the status card, state lines, the zip_recall row and the fold mark on a folded output.
|
|
2
|
+
// One visual grammar everywhere: "▸ pi-zip" in the accent colour, numbers in the text colour, everything else dim, no boxes;
|
|
3
|
+
// collapsed = the conclusion, ctrl+o = the detail. Every returned line fits the given width.
|
|
4
|
+
import { PRODUCT } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
export interface Th {
|
|
7
|
+
fg(color: string, text: string): string;
|
|
8
|
+
}
|
|
9
|
+
/** Display width and truncation: Pi's visibleWidth / truncateToWidth in the renderer, string length in tests. */
|
|
10
|
+
export interface Measure {
|
|
11
|
+
vw(s: string): number;
|
|
12
|
+
cut(s: string, width: number): string; // plain text, at most width columns, with "…" when cut
|
|
13
|
+
}
|
|
14
|
+
export const plainMeasure: Measure = {
|
|
15
|
+
vw: (s) => s.length,
|
|
16
|
+
cut: (s, w) => (s.length <= w ? s : w <= 0 ? "" : s.slice(0, w - 1) + "…"),
|
|
17
|
+
};
|
|
18
|
+
/** The measure renderers use: index.ts swaps in Pi's own (pi-tui) when it loads. */
|
|
19
|
+
export let measure: Measure = plainMeasure;
|
|
20
|
+
export function setMeasure(m: Measure) {
|
|
21
|
+
measure = m;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MARK = "▸ ";
|
|
25
|
+
const HEAD = MARK + PRODUCT;
|
|
26
|
+
const LABEL_W = 9;
|
|
27
|
+
|
|
28
|
+
/** Plain text without control characters (tabs become two spaces): recalled output goes into the TUI as text, never as escape codes. */
|
|
29
|
+
export const clean = (s: string): string => s.replace(/\t/g, " ").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
30
|
+
|
|
31
|
+
// ---- state lines -------------------------------------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type StateWord = "on" | "off" | "paused" | "reread-only" | "quiet" | "notices on" | "cache";
|
|
34
|
+
const WARN = new Set<StateWord>(["off", "paused", "reread-only"]);
|
|
35
|
+
|
|
36
|
+
/** "▸ pi-zip paused billion-context also manages context · run: pi remove npm:billion-context" */
|
|
37
|
+
export function renderState(word: StateWord, reason: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
38
|
+
const wordC = th.fg(WARN.has(word) ? "warning" : "accent", word);
|
|
39
|
+
const base = `${HEAD} ${word}`;
|
|
40
|
+
if (m.vw(base) > width) return m.vw(word) <= width ? [wordC] : [];
|
|
41
|
+
const room = width - m.vw(base) - 2;
|
|
42
|
+
const r = reason && room >= 12 ? m.cut(reason, room) : "";
|
|
43
|
+
return [`${th.fg("accent", HEAD)} ${wordC}${r ? " " + th.fg("dim", r) : ""}`];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- status card ------------------------------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface CardData {
|
|
49
|
+
state: "on" | "off" | "paused" | "reread-only";
|
|
50
|
+
stateNote?: string; // why paused / reread-only
|
|
51
|
+
model?: string; // provider/id
|
|
52
|
+
cls?: string; // explicit | automatic
|
|
53
|
+
life?: string; // "~5 min (declared)" | "~1 h (learned from 42 returns)"
|
|
54
|
+
alive?: number[]; // P(warm) at SPARK_GAPS
|
|
55
|
+
folds: number;
|
|
56
|
+
foldedTokens: string; // "310K"
|
|
57
|
+
summaries: number;
|
|
58
|
+
summaryUsd: number;
|
|
59
|
+
recalls: number;
|
|
60
|
+
last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
|
|
61
|
+
quiet: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Gaps (seconds) the "alive" sparkline samples: 30 s to 2 h, denser where caches usually expire. */
|
|
65
|
+
export const SPARK_GAPS = [30, 60, 120, 180, 240, 300, 360, 420, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200];
|
|
66
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
67
|
+
export const sparkline = (ps: number[]): string => ps.map((p) => SPARK[Math.max(0, Math.min(7, Math.round(p * 7)))]).join("");
|
|
68
|
+
|
|
69
|
+
/** "~5 min", "~1 h", "~2.5 h" */
|
|
70
|
+
export function fmtLife(s: number): string {
|
|
71
|
+
if (s < 90) return `~${Math.round(s)} s`;
|
|
72
|
+
if (s < 5400) return `~${Math.round(s / 60)} min`;
|
|
73
|
+
return `~${+(s / 3600).toFixed(1)} h`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function cardText(c: CardData): string[] {
|
|
77
|
+
const rows: [string, string][] = [];
|
|
78
|
+
if (c.model) rows.push(["cache", [c.model, c.cls ?? "class unknown until the first reply", c.life ? `lives ${c.life}` : ""].filter(Boolean).join(" · ")]);
|
|
79
|
+
if (c.alive?.length) rows.push(["alive", `${sparkline(c.alive)} 30 s → 2 h`]);
|
|
80
|
+
const n = (x: number, one: string, many = one + "s") => `${x} ${x === 1 ? one : many}`;
|
|
81
|
+
rows.push(["session", `${n(c.folds, "fold")} ~${c.foldedTokens} · ${n(c.summaries, "summary", "summaries")}${c.summaryUsd > 0 ? ` $${c.summaryUsd.toFixed(2)}` : ""} · ${n(c.recalls, "recall")}`]);
|
|
82
|
+
if (c.last) rows.push(["last", c.last]);
|
|
83
|
+
const mode = c.state === "reread-only" ? "reread-only (zip_recall hidden by a tool allowlist)" : "full (zip_recall available)";
|
|
84
|
+
rows.push(["mode", `${mode}${c.quiet ? " · notices off" : ""}`]);
|
|
85
|
+
return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
89
|
+
const out = renderState(c.state, c.stateNote ?? "", width, th, m);
|
|
90
|
+
const indent = " ";
|
|
91
|
+
for (const row of cardText(c)) {
|
|
92
|
+
const line = m.cut(indent + row, width);
|
|
93
|
+
if (!line) continue;
|
|
94
|
+
const warn = row.startsWith("mode") && c.state === "reread-only";
|
|
95
|
+
const k = line.slice(0, indent.length + LABEL_W), v = line.slice(indent.length + LABEL_W);
|
|
96
|
+
out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg(warn ? "warning" : "dim", v)));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- zip_recall row ------------------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export interface RecallSectionInfo {
|
|
104
|
+
handle: string;
|
|
105
|
+
label?: string; // "bash npm test"
|
|
106
|
+
turn?: number;
|
|
107
|
+
totalLines?: number;
|
|
108
|
+
shownLines?: number;
|
|
109
|
+
how?: "all" | "grep" | "range" | "page";
|
|
110
|
+
missing?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** "↺ recall k3x9q2m7ab grep "Expected"" */
|
|
114
|
+
export function recallCallLine(args: { handle?: string; handles?: string[]; grep?: string; range?: string; offset?: number }, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
115
|
+
const hs = [...(typeof args?.handle === "string" ? [args.handle] : []), ...(Array.isArray(args?.handles) ? args.handles.filter((h) => typeof h === "string") : [])];
|
|
116
|
+
const what = hs.length > 1 ? `${hs.length} handles` : (hs[0] ?? "");
|
|
117
|
+
const opt = [args?.grep ? `grep "${args.grep}"` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
|
|
118
|
+
const plain = m.cut(`↺ recall ${what}${opt ? " " + opt : ""}`, width);
|
|
119
|
+
const head = "↺ recall";
|
|
120
|
+
if (!plain.startsWith(head)) return [th.fg("toolTitle", plain)];
|
|
121
|
+
return [th.fg("toolTitle", head) + th.fg("accent", plain.slice(head.length, head.length + 1 + what.length)) + th.fg("dim", plain.slice(head.length + 1 + what.length))];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sectionLine(s: RecallSectionInfo): string {
|
|
125
|
+
if (s.missing) return `${s.handle} not found`;
|
|
126
|
+
const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
|
|
127
|
+
const total = s.totalLines ?? 0;
|
|
128
|
+
const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
|
|
129
|
+
return `${where || s.handle} ${got}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Collapsed: one line per handle ("bash npm test · turn 1 3 of 812 lines"). Expanded: the recalled text, at most maxLines. */
|
|
133
|
+
export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
|
|
134
|
+
const out: string[] = [];
|
|
135
|
+
for (const s of sections) {
|
|
136
|
+
const l = m.cut(sectionLine(s), width);
|
|
137
|
+
if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
|
|
138
|
+
}
|
|
139
|
+
if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
|
|
140
|
+
if (!expanded || !text) return out;
|
|
141
|
+
const lines = clean(text).split("\n");
|
|
142
|
+
for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
|
|
143
|
+
if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- the mark on a folded output ---------------------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/** Right-aligns "▸ folded · <handle>" on the first line of a tool call row, if it fits; otherwise leaves the row alone. */
|
|
150
|
+
export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
151
|
+
if (!lines.length) return lines;
|
|
152
|
+
const mark = `${MARK}folded · ${handle}`;
|
|
153
|
+
// renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
|
|
154
|
+
const first = lines[0].replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
|
|
155
|
+
const gap = width - m.vw(first) - m.vw(mark);
|
|
156
|
+
if (gap < 2) return lines;
|
|
157
|
+
return [first + " ".repeat(gap) + th.fg("dim", mark), ...lines.slice(1)];
|
|
158
|
+
}
|