pi-zip 0.2.4 → 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 +59 -14
- package/package.json +1 -1
- package/src/index.ts +68 -7
- package/src/notice.ts +100 -12
- package/src/recall.ts +36 -6
- package/src/run.ts +135 -15
- 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
|
|
|
@@ -34,14 +35,59 @@ Known limits:
|
|
|
34
35
|
|
|
35
36
|
## What you see
|
|
36
37
|
|
|
37
|
-
At most one line per turn, only when
|
|
38
|
+
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
39
|
|
|
39
40
|
```
|
|
40
|
-
pi-zip
|
|
41
|
-
|
|
41
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
42
|
+
originals are kept; the model can recall any of them with zip_recall
|
|
43
|
+
▸ pi-zip 182K → 41K ▰▰▱▱▱▱▱▱▱▱ summarized 64 requests · ready while you were away
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
The line
|
|
46
|
+
The second line appears once per session. Expand tool output (ctrl+o) to see why it happened now and what was folded:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
50
|
+
cache cold (away 47 min): this request rewrites it anyway, so editing is free
|
|
51
|
+
bash npm test turn 1 14K k3x9q2m7ab
|
|
52
|
+
read src/payment.ts turn 1 9K p8d2x1qa0m
|
|
53
|
+
… 10 more
|
|
54
|
+
```
|
|
55
|
+
|
|
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).
|
|
45
91
|
|
|
46
92
|
## The three rules
|
|
47
93
|
|
|
@@ -49,11 +95,11 @@ The line goes to the UI only; it never enters the model's context. Nothing else
|
|
|
49
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.
|
|
50
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.
|
|
51
97
|
|
|
52
|
-
**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.
|
|
53
99
|
|
|
54
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.
|
|
55
101
|
|
|
56
|
-
**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.
|
|
57
103
|
|
|
58
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.
|
|
59
105
|
|
|
@@ -61,13 +107,12 @@ The cold cap is kept below Pi's own compaction trigger (window minus `compaction
|
|
|
61
107
|
|
|
62
108
|
| Command | Effect |
|
|
63
109
|
|---|---|
|
|
64
|
-
| `/zip status` |
|
|
65
|
-
| `/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) |
|
|
66
111
|
| `/zip off` | strict no-op: no folds, no summaries, requests left untouched (earlier folds stay recallable) |
|
|
67
112
|
| `/zip on` | resume |
|
|
68
113
|
| `/zip quiet` | toggle the per-turn notice (folding continues) |
|
|
69
114
|
|
|
70
|
-
`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.
|
|
71
116
|
|
|
72
117
|
## Recall
|
|
73
118
|
|
|
@@ -109,9 +154,9 @@ The guard has two parts. Before saving a fold or a summary it checks that the ed
|
|
|
109
154
|
|
|
110
155
|
## FAQ
|
|
111
156
|
|
|
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
|
|
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.
|
|
113
158
|
|
|
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
|
|
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.
|
|
115
160
|
|
|
116
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.
|
|
117
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
|
@@ -1,31 +1,92 @@
|
|
|
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";
|
|
8
|
+
import { markFirstLine, measure, renderCard, renderState, setMeasure } from "./ui.ts";
|
|
7
9
|
|
|
8
10
|
export default function piZip(pi: ExtensionAPI) {
|
|
9
11
|
if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
|
|
10
12
|
const zip = new Zip(pi);
|
|
11
13
|
registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
|
|
12
14
|
registerZipCommand(pi, zip);
|
|
15
|
+
try {
|
|
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.
|
|
18
|
+
import("@earendil-works/pi-tui").then((m: Any) => {
|
|
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, "…")) });
|
|
21
|
+
}, () => {});
|
|
22
|
+
pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
|
|
23
|
+
const d = entry?.data;
|
|
24
|
+
if (!d?.text) return undefined;
|
|
25
|
+
return {
|
|
26
|
+
render: (width: number) => {
|
|
27
|
+
try {
|
|
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);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
invalidate: () => {},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
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
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
/* older Pi: notices fall back to the status line */
|
|
69
|
+
}
|
|
13
70
|
// A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
|
|
14
71
|
// zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
|
|
15
|
-
const checkRecall = () => {
|
|
72
|
+
const checkRecall = (ctx: Any) => {
|
|
16
73
|
try {
|
|
17
74
|
const active = pi.getActiveTools?.();
|
|
18
|
-
if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL));
|
|
75
|
+
if (Array.isArray(active)) zip.setRecallOk(active.includes(RECALL_TOOL), ctx);
|
|
19
76
|
} catch {
|
|
20
77
|
/* never in the way */
|
|
21
78
|
}
|
|
22
79
|
};
|
|
23
80
|
pi.on("session_start", (_e, ctx) => {
|
|
24
|
-
|
|
25
|
-
|
|
81
|
+
const r = zip.sessionStart(ctx);
|
|
82
|
+
checkRecall(ctx);
|
|
83
|
+
zip.refreshFolded(ctx);
|
|
84
|
+
zip.welcome(ctx);
|
|
85
|
+
return r;
|
|
26
86
|
});
|
|
27
87
|
pi.on("before_agent_start", (_e, ctx) => {
|
|
28
|
-
checkRecall();
|
|
88
|
+
checkRecall(ctx);
|
|
89
|
+
zip.refreshFolded(ctx); // the branch may have changed (/tree, fork)
|
|
29
90
|
return zip.beforeAgentStart(ctx);
|
|
30
91
|
});
|
|
31
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";
|
|
@@ -12,19 +13,94 @@ export interface NoticeAction {
|
|
|
12
13
|
pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** One folded output, for the expanded (ctrl+o) view of a notice. */
|
|
17
|
+
export interface NoticeItem {
|
|
18
|
+
label: string; // tool + short args
|
|
19
|
+
turn?: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
handle: string;
|
|
22
|
+
}
|
|
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). */
|
|
25
|
+
export interface NoticeData {
|
|
26
|
+
v: 2;
|
|
27
|
+
kind?: "state" | "card"; // absent: a fold/summary notice
|
|
28
|
+
word?: StateWord;
|
|
29
|
+
reason?: string;
|
|
30
|
+
card?: CardData;
|
|
31
|
+
text: string;
|
|
32
|
+
before: number;
|
|
33
|
+
after: number;
|
|
34
|
+
desc: string; // "folded 12 old outputs · summarized 64 requests · ready while you were away"
|
|
35
|
+
why?: string; // expanded view: why now
|
|
36
|
+
items?: NoticeItem[];
|
|
37
|
+
more?: number; // folded outputs not listed
|
|
38
|
+
first?: boolean; // the session's first notice: say once that originals are kept
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
|
|
16
42
|
|
|
43
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
44
|
+
const secs = (ms: number) => `${(Math.round(ms / 100) / 10).toFixed(1)} s`;
|
|
45
|
+
|
|
46
|
+
/** The words after the numbers: what happened, plus how long the user waited for a summary (if at all). */
|
|
47
|
+
export function noticeDesc(actions: NoticeAction[]): string {
|
|
48
|
+
return actions
|
|
49
|
+
.map((a) => {
|
|
50
|
+
if (a.kind === "fold") return `folded ${plural(a.count, "old output")}`;
|
|
51
|
+
return `summarized ${plural(a.count, "request")} · ${a.prepared ? "ready while you were away" : `waited ${secs(a.ms)}`}`;
|
|
52
|
+
})
|
|
53
|
+
.join(" · ");
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
export function noticeText(actions: NoticeAction[]): string {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
const first = actions[0], last = actions[actions.length - 1];
|
|
58
|
+
return `${PRODUCT} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)} ${noticeDesc(actions)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ten cells, filled in proportion to what is left: length is read before any digit is. */
|
|
62
|
+
export function ratioBar(before: number, after: number, cells = 10): string {
|
|
63
|
+
const f = before > 0 ? Math.min(cells, Math.max(1, Math.round((cells * after) / before))) : cells;
|
|
64
|
+
return "▰".repeat(f) + "▱".repeat(cells - f);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface NoticeTheme {
|
|
68
|
+
fg(color: string, text: string): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MARK = "▸ ";
|
|
72
|
+
const INDENT = " ".repeat(MARK.length + PRODUCT.length + 2);
|
|
73
|
+
|
|
74
|
+
/** Lines for the transcript. Narrow terminals drop whole segments in order (description, then bar); the numbers always stay.
|
|
75
|
+
* `w` measures display width (Pi's visibleWidth in the renderer; string length in tests). Every line fits `width`. */
|
|
76
|
+
export function renderNotice(d: NoticeData, expanded: boolean, width: number, th: NoticeTheme, w: (s: string) => number = (s) => s.length): string[] {
|
|
77
|
+
const nums = `${fmtK(d.before)} → ${fmtK(d.after)}`;
|
|
78
|
+
const bar = ratioBar(d.before, d.after);
|
|
79
|
+
const tries: [string, string][] = [
|
|
80
|
+
[`${MARK}${PRODUCT} ${nums} ${bar} ${d.desc}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)} ${th.fg("dim", d.desc)}`],
|
|
81
|
+
[`${MARK}${PRODUCT} ${nums} ${bar}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)}`],
|
|
82
|
+
[`${MARK}${PRODUCT} ${nums}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)}`],
|
|
83
|
+
[nums, th.fg("text", nums)],
|
|
84
|
+
];
|
|
85
|
+
const head = tries.find(([plain]) => w(plain) <= width);
|
|
86
|
+
if (!head) return [];
|
|
87
|
+
const out = [head[1]];
|
|
88
|
+
const sub = (plain: string) => {
|
|
89
|
+
const line = INDENT + plain;
|
|
90
|
+
if (w(line) <= width) out.push(th.fg("dim", line));
|
|
91
|
+
else if (w(plain) <= width) out.push(th.fg("dim", plain));
|
|
92
|
+
};
|
|
93
|
+
if (d.first) sub("originals are kept; the model can recall any of them with zip_recall");
|
|
94
|
+
if (!expanded) return out;
|
|
95
|
+
if (d.why) sub(d.why);
|
|
96
|
+
const items = d.items ?? [];
|
|
97
|
+
const lw = Math.min(28, Math.max(0, ...items.map((i) => i.label.length)));
|
|
98
|
+
for (const i of items) {
|
|
99
|
+
const label = i.label.length > lw ? i.label.slice(0, lw - 1) + "…" : i.label.padEnd(lw);
|
|
100
|
+
sub(`${label} ${(i.turn ? `turn ${i.turn}` : "").padEnd(8)}${fmtK(i.tokens).padStart(6)} ${i.handle}`);
|
|
101
|
+
}
|
|
102
|
+
if (d.more) sub(`… ${d.more} more`);
|
|
103
|
+
return out;
|
|
28
104
|
}
|
|
29
105
|
|
|
30
106
|
export class Stats {
|
|
@@ -47,6 +123,9 @@ export class Stats {
|
|
|
47
123
|
|
|
48
124
|
export interface ZipControl {
|
|
49
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;
|
|
50
129
|
statsLine(ctx?: Any): string;
|
|
51
130
|
setOff(off: boolean): string;
|
|
52
131
|
toggleQuiet(): string;
|
|
@@ -54,16 +133,25 @@ export interface ZipControl {
|
|
|
54
133
|
|
|
55
134
|
export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
|
|
56
135
|
pi.registerCommand("zip", {
|
|
57
|
-
description: `${PRODUCT}: status |
|
|
136
|
+
description: `${PRODUCT}: status | off | on | quiet`,
|
|
58
137
|
handler: async (args: string, cctx: Any) => {
|
|
59
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
|
+
}
|
|
60
143
|
const text =
|
|
61
144
|
sub === "status" ? zip.status(cctx)
|
|
62
145
|
: sub === "stats" ? zip.statsLine(cctx)
|
|
63
146
|
: sub === "off" ? zip.setOff(true)
|
|
64
147
|
: sub === "on" ? zip.setOff(false)
|
|
65
148
|
: sub === "quiet" ? zip.toggleQuiet()
|
|
66
|
-
: `${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
|
+
}
|
|
67
155
|
if (cctx?.ui?.notify && (cctx.mode === "tui" || cctx.mode === "rpc")) {
|
|
68
156
|
try {
|
|
69
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,21 +1,24 @@
|
|
|
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
|
-
import { fmtK, Stats, noticeText, type NoticeAction, type ZipControl } from "./notice.ts";
|
|
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
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
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";
|
|
18
19
|
export const STEER_CUSTOM = "pi-zip/steer";
|
|
20
|
+
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`);
|
|
21
|
+
export const NOTICE_CUSTOM = "pi-zip/notice"; // a fold/summary notice kept in the transcript (rendered by index.ts, never sent to the model)
|
|
19
22
|
export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
|
|
20
23
|
export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
|
|
21
24
|
// Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
|
|
@@ -95,11 +98,68 @@ export class Zip implements ZipControl {
|
|
|
95
98
|
|
|
96
99
|
/** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
|
|
97
100
|
recallOk = true;
|
|
101
|
+
/** index.ts registered the transcript renderer for NOTICE_CUSTOM (Pi versions without registerEntryRenderer fall back to a status line). */
|
|
102
|
+
entryRenderer = false;
|
|
103
|
+
private coldWhy = ""; // the expanded notice's "why now" for this run
|
|
104
|
+
private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
|
|
98
105
|
/** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
|
|
99
106
|
summarizedWarm = false;
|
|
100
|
-
setRecallOk(ok: boolean) {
|
|
107
|
+
setRecallOk(ok: boolean, ctx?: Any) {
|
|
101
108
|
if (ok !== this.recallOk) this.ledger({ type: "recall_available", ok });
|
|
102
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`);
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
ledger(rec: Record<string, unknown>) {
|
|
@@ -111,9 +171,19 @@ export class Zip implements ZipControl {
|
|
|
111
171
|
} catch {}
|
|
112
172
|
}
|
|
113
173
|
|
|
114
|
-
|
|
174
|
+
/** keep = a fold/summary notice: written to the session as a custom entry, so it stays in the transcript (also after a restart)
|
|
175
|
+
* next to Pi's own "[compaction]" block instead of a status line the next status overwrites. Custom entries never reach the model. */
|
|
176
|
+
private notify(ctx: Any, text: string, keep?: NoticeData) {
|
|
115
177
|
this.stats.notices++;
|
|
116
178
|
this.ledger({ type: "notice", text });
|
|
179
|
+
if (keep && ctx?.mode === "tui" && this.entryRenderer) {
|
|
180
|
+
setTimeout(() => { // after Pi has appended this turn's edits
|
|
181
|
+
try {
|
|
182
|
+
this.pi.appendEntry(NOTICE_CUSTOM, keep);
|
|
183
|
+
} catch {}
|
|
184
|
+
}, 0);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
117
187
|
if (ctx?.ui?.notify && (ctx.mode === "tui" || ctx.mode === "rpc")) {
|
|
118
188
|
try {
|
|
119
189
|
ctx.ui.notify(text, "info");
|
|
@@ -186,7 +256,7 @@ export class Zip implements ZipControl {
|
|
|
186
256
|
this.conflict = found;
|
|
187
257
|
if (found && !this.conflictNoticed) {
|
|
188
258
|
this.conflictNoticed = true;
|
|
189
|
-
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);
|
|
190
260
|
}
|
|
191
261
|
}
|
|
192
262
|
|
|
@@ -243,6 +313,7 @@ export class Zip implements ZipControl {
|
|
|
243
313
|
this.survSrc = src;
|
|
244
314
|
this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
|
|
245
315
|
this.coldReason = reason;
|
|
316
|
+
this.coldWhy = src === "model switch" ? "model switched: its cache starts empty, so this request rewrites it anyway" : cold && gapS !== null ? `cache cold (away ${fmtAway(gapS)}): this request rewrites it anyway, so editing is free` : "";
|
|
246
317
|
for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
|
|
247
318
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
248
319
|
const en = branch[i];
|
|
@@ -255,7 +326,7 @@ export class Zip implements ZipControl {
|
|
|
255
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 });
|
|
256
327
|
if (ttl.note && !this.ttlNoticed) {
|
|
257
328
|
this.ttlNoticed = true; // once per session
|
|
258
|
-
if (!this.quiet) this.
|
|
329
|
+
if (!this.quiet) this.stateLine(ctx, "cache", ttl.note.replace(`${PRODUCT} · `, ""));
|
|
259
330
|
}
|
|
260
331
|
}
|
|
261
332
|
|
|
@@ -306,14 +377,29 @@ export class Zip implements ZipControl {
|
|
|
306
377
|
}
|
|
307
378
|
|
|
308
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. */
|
|
309
|
-
|
|
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 }> {
|
|
310
396
|
const b = this.bg;
|
|
311
397
|
if (!b || key === null || b.key !== key) {
|
|
312
398
|
this.discardBg();
|
|
313
399
|
return { cut: null, adopted: false };
|
|
314
400
|
}
|
|
315
401
|
const w0 = performance.now();
|
|
316
|
-
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)));
|
|
317
403
|
this.bgWaitMs = performance.now() - w0;
|
|
318
404
|
if (cut) {
|
|
319
405
|
this.bg = null;
|
|
@@ -347,7 +433,7 @@ export class Zip implements ZipControl {
|
|
|
347
433
|
let cut: Cut | null = null;
|
|
348
434
|
let adopted = false;
|
|
349
435
|
if (p && p.cutIdx !== null) {
|
|
350
|
-
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
|
|
436
|
+
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal, ctx);
|
|
351
437
|
cut = got.cut;
|
|
352
438
|
adopted = got.adopted;
|
|
353
439
|
} else {
|
|
@@ -356,7 +442,7 @@ export class Zip implements ZipControl {
|
|
|
356
442
|
const sameSet = this.settleIds.length === folds.length && this.settleIds.every((id) => folds.some((u) => u.entryId === id));
|
|
357
443
|
const source: RunPlan["source"] = !this.cold ? "valve" : adopted || (this.settleIds.length > 0 && sameSet) ? "settle" : "runstart";
|
|
358
444
|
const foldMs = performance.now() - t0;
|
|
359
|
-
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
|
|
360
446
|
// a plan that cannot be persisted must never be sent: the request view would differ from what turn_end can write (I1)
|
|
361
447
|
if (p) {
|
|
362
448
|
const invalid = checkEdits(p.blocks, p.userTurns, folds, cut);
|
|
@@ -501,8 +587,8 @@ export class Zip implements ZipControl {
|
|
|
501
587
|
if (!p || (!p.folds.length && p.cutIdx === null)) return undefined;
|
|
502
588
|
let cut: Cut | null = null;
|
|
503
589
|
if (p.cutIdx !== null) {
|
|
504
|
-
const got = await this.takeBg(p.firstKeptEntryId, ctx.signal);
|
|
505
|
-
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
|
|
506
592
|
} else this.discardBg();
|
|
507
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) };
|
|
508
594
|
return this.commit(e, ctx, plan, o);
|
|
@@ -530,6 +616,7 @@ export class Zip implements ZipControl {
|
|
|
530
616
|
}
|
|
531
617
|
if (!plan.sent) this.nextEdit = plan.untouched ?? 0; // persisted now, first sent with the next request
|
|
532
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));
|
|
533
620
|
if (cut) ours.push({ type: "compaction", summary: cut.text, firstKeptEntryId: cut.firstKeptEntryId, details: { by: PRODUCT, trigger: cut.trigger }, usage: cut.usage });
|
|
534
621
|
const before = Math.round(k * live.reduce((a, t) => a + t.entryTokens, 0));
|
|
535
622
|
const after = Math.round(k * live.reduce((a, t) => a + t.phTokens, 0));
|
|
@@ -565,7 +652,14 @@ export class Zip implements ZipControl {
|
|
|
565
652
|
const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
|
|
566
653
|
notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: ctxTokens - (before - after) - cutSaved, ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
|
|
567
654
|
}
|
|
568
|
-
if (notices.length && !this.quiet)
|
|
655
|
+
if (notices.length && !this.quiet) {
|
|
656
|
+
const ITEMS = 8;
|
|
657
|
+
const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, turn: byId.get(t.entryId)?.userTurn, tokens: Math.round(k * t.entryTokens), handle: handleFor(t.entryId) }));
|
|
658
|
+
const why = valve ? "cache still warm, but the context passed the warm-cache limit" : this.cold ? this.coldWhy : "cache warm: the reads saved pay for the rewrite";
|
|
659
|
+
const data: NoticeData = { v: 2, text: noticeText(notices), before: Math.round(notices[0].tokensBefore), after: Math.round(notices[notices.length - 1].tokensAfter), desc: noticeDesc(notices), why: why || undefined, items, more: Math.max(0, live.length - ITEMS) || undefined, first: !this.noticed && !this.branch(ctx).some((en: Any) => en?.type === "custom" && en.customType === NOTICE_CUSTOM) };
|
|
660
|
+
this.noticed = true;
|
|
661
|
+
this.notify(ctx, data.text, data);
|
|
662
|
+
}
|
|
569
663
|
return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
|
|
570
664
|
}
|
|
571
665
|
|
|
@@ -644,10 +738,11 @@ export class Zip implements ZipControl {
|
|
|
644
738
|
} catch {}
|
|
645
739
|
}
|
|
646
740
|
/** What this session carries: folds and summaries persisted in its file (they survive restarts) and the recalls in its branch. */
|
|
647
|
-
private
|
|
741
|
+
private totals(ctx: Any) {
|
|
648
742
|
const branch = ctx ? this.branch(ctx) : [];
|
|
649
743
|
const byId = new Map(branch.map((e: Any) => [e?.id, e]));
|
|
650
744
|
let folds = 0, folded = 0, summaries = 0, recalls = 0, usd = 0;
|
|
745
|
+
let last: Any = null;
|
|
651
746
|
for (const e of branch) {
|
|
652
747
|
const ph = String(e?.replacement?.content?.[0]?.text ?? "");
|
|
653
748
|
if (e?.type === "context_edit" && ph.startsWith(PH_MARK)) {
|
|
@@ -657,11 +752,36 @@ export class Zip implements ZipControl {
|
|
|
657
752
|
summaries++;
|
|
658
753
|
usd += Number(e.usage?.cost?.total) || 0;
|
|
659
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;
|
|
660
756
|
else if (e?.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === RECALL_TOOL) recalls++;
|
|
661
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);
|
|
662
763
|
const n = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
|
|
663
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")}`;
|
|
664
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
|
+
}
|
|
665
785
|
status(ctx?: Any): string {
|
|
666
786
|
const state = this.off ? "off" : this.conflict ? `paused ("${this.conflict}" also manages context; only the request guard is on)` : "on";
|
|
667
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
|
+
}
|