pi-zip 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/package.json +1 -1
- package/src/index.ts +29 -2
- package/src/notice.ts +81 -10
- package/src/run.ts +27 -3
package/README.md
CHANGED
|
@@ -34,14 +34,25 @@ Known limits:
|
|
|
34
34
|
|
|
35
35
|
## What you see
|
|
36
36
|
|
|
37
|
-
At most one line per turn, only when
|
|
37
|
+
At most one line per turn in the transcript, only when the context was folded or summarized. The numbers come first, and the bar shows how much is left:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
pi-zip
|
|
41
|
-
|
|
40
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
41
|
+
originals are kept; the model can recall any of them with zip_recall
|
|
42
|
+
▸ pi-zip 182K → 41K ▰▰▱▱▱▱▱▱▱▱ summarized 64 requests · ready while you were away
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
The line
|
|
45
|
+
The second line appears once per session. Expand tool output (ctrl+o) to see why it happened now and what was folded:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
▸ pi-zip 74K → 43K ▰▰▰▰▰▰▱▱▱▱ folded 12 old outputs
|
|
49
|
+
cache cold (away 47 min): this request rewrites it anyway, so editing is free
|
|
50
|
+
bash npm test turn 1 14K k3x9q2m7ab
|
|
51
|
+
read src/payment.ts turn 1 9K p8d2x1qa0m
|
|
52
|
+
… 10 more
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
These lines are saved in the session, so they are still there after a restart, but they are never sent to the model. On narrow terminals the words go first, then the bar; the numbers always stay. A summary still shows up as Pi's own `[compaction]` block as well; the pi-zip line next to it tells you who made it. Recalls show up as ordinary `zip_recall` tool calls.
|
|
45
56
|
|
|
46
57
|
## The three rules
|
|
47
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zip",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Keeps long Pi sessions cheap without losing anything: folds old tool output only when the prompt cache has already gone cold, and every fold can be recalled byte for byte. Zero config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
// pi-zip: keeps long Pi sessions cheap without losing anything. Wiring only; the logic lives in the sibling modules.
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
3
|
+
import type { Any } from "./util.ts";
|
|
4
|
+
import { type NoticeData, registerZipCommand, renderNotice } from "./notice.ts";
|
|
4
5
|
import { RECALL_TOOL } from "./placeholder.ts";
|
|
5
6
|
import { registerRecallTool } from "./recall.ts";
|
|
6
|
-
import { Zip } from "./run.ts";
|
|
7
|
+
import { NOTICE_CUSTOM, Zip } from "./run.ts";
|
|
7
8
|
|
|
8
9
|
export default function piZip(pi: ExtensionAPI) {
|
|
9
10
|
if (process.env.PI_ZIP_OFF === "1") return; // test only: behave exactly as if not installed (registers nothing)
|
|
10
11
|
const zip = new Zip(pi);
|
|
11
12
|
registerRecallTool(pi, zip); // stays registered even when off: earlier folds must stay recallable, and a tool-list change would bust the cache
|
|
12
13
|
registerZipCommand(pi, zip);
|
|
14
|
+
try {
|
|
15
|
+
// fold/summary notices stay in the transcript as one dim line (a custom entry: never part of the model's context)
|
|
16
|
+
let vw: (s: string) => number = (s) => s.length;
|
|
17
|
+
import("@earendil-works/pi-tui").then((m: Any) => {
|
|
18
|
+
if (typeof m?.visibleWidth === "function") vw = m.visibleWidth;
|
|
19
|
+
}, () => {});
|
|
20
|
+
pi.registerEntryRenderer?.(NOTICE_CUSTOM, (entry: Any, o: Any, theme: Any) => {
|
|
21
|
+
const d = entry?.data;
|
|
22
|
+
if (!d?.text) return undefined;
|
|
23
|
+
const data: NoticeData = d.v === 2 ? d : { v: 2, text: d.text, before: 0, after: 0, desc: "" };
|
|
24
|
+
return {
|
|
25
|
+
render: (width: number) => {
|
|
26
|
+
if (d.v !== 2) return [theme.fg("dim", vw(d.text) > width ? d.text.slice(0, Math.max(0, width - 1)) + "…" : d.text)];
|
|
27
|
+
try {
|
|
28
|
+
return renderNotice(data, !!o?.expanded, width, theme, vw);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
invalidate: () => {},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
zip.entryRenderer = typeof pi.registerEntryRenderer === "function";
|
|
37
|
+
} catch {
|
|
38
|
+
/* older Pi: notices fall back to the status line */
|
|
39
|
+
}
|
|
13
40
|
// A tool allowlist (`pi --tools read,bash`, sub-agent launchers) replaces the whole selection and Pi then does not even register
|
|
14
41
|
// zip_recall; folds of outputs the model could not get back would be lost, so only rereadable outputs fold then (plan rereadOnly).
|
|
15
42
|
const checkRecall = () => {
|
package/src/notice.ts
CHANGED
|
@@ -12,19 +12,90 @@ export interface NoticeAction {
|
|
|
12
12
|
pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** One folded output, for the expanded (ctrl+o) view of a notice. */
|
|
16
|
+
export interface NoticeItem {
|
|
17
|
+
label: string; // tool + short args
|
|
18
|
+
turn?: number;
|
|
19
|
+
tokens: number;
|
|
20
|
+
handle: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a transcript notice stores (custom entry data; never sent to the model). `text` is the plain one-line form (status-line fallback, ledger). */
|
|
24
|
+
export interface NoticeData {
|
|
25
|
+
v: 2;
|
|
26
|
+
text: string;
|
|
27
|
+
before: number;
|
|
28
|
+
after: number;
|
|
29
|
+
desc: string; // "folded 12 old outputs · summarized 64 requests · ready while you were away"
|
|
30
|
+
why?: string; // expanded view: why now
|
|
31
|
+
items?: NoticeItem[];
|
|
32
|
+
more?: number; // folded outputs not listed
|
|
33
|
+
first?: boolean; // the session's first notice: say once that originals are kept
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
|
|
16
37
|
|
|
38
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
39
|
+
const secs = (ms: number) => `${(Math.round(ms / 100) / 10).toFixed(1)} s`;
|
|
40
|
+
|
|
41
|
+
/** The words after the numbers: what happened, plus how long the user waited for a summary (if at all). */
|
|
42
|
+
export function noticeDesc(actions: NoticeAction[]): string {
|
|
43
|
+
return actions
|
|
44
|
+
.map((a) => {
|
|
45
|
+
if (a.kind === "fold") return `folded ${plural(a.count, "old output")}`;
|
|
46
|
+
return `summarized ${plural(a.count, "request")} · ${a.prepared ? "ready while you were away" : `waited ${secs(a.ms)}`}`;
|
|
47
|
+
})
|
|
48
|
+
.join(" · ");
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
export function noticeText(actions: NoticeAction[]): string {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const first = actions[0], last = actions[actions.length - 1];
|
|
53
|
+
return `${PRODUCT} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)} ${noticeDesc(actions)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ten cells, filled in proportion to what is left: length is read before any digit is. */
|
|
57
|
+
export function ratioBar(before: number, after: number, cells = 10): string {
|
|
58
|
+
const f = before > 0 ? Math.min(cells, Math.max(1, Math.round((cells * after) / before))) : cells;
|
|
59
|
+
return "▰".repeat(f) + "▱".repeat(cells - f);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NoticeTheme {
|
|
63
|
+
fg(color: string, text: string): string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MARK = "▸ ";
|
|
67
|
+
const INDENT = " ".repeat(MARK.length + PRODUCT.length + 2);
|
|
68
|
+
|
|
69
|
+
/** Lines for the transcript. Narrow terminals drop whole segments in order (description, then bar); the numbers always stay.
|
|
70
|
+
* `w` measures display width (Pi's visibleWidth in the renderer; string length in tests). Every line fits `width`. */
|
|
71
|
+
export function renderNotice(d: NoticeData, expanded: boolean, width: number, th: NoticeTheme, w: (s: string) => number = (s) => s.length): string[] {
|
|
72
|
+
const nums = `${fmtK(d.before)} → ${fmtK(d.after)}`;
|
|
73
|
+
const bar = ratioBar(d.before, d.after);
|
|
74
|
+
const tries: [string, string][] = [
|
|
75
|
+
[`${MARK}${PRODUCT} ${nums} ${bar} ${d.desc}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)} ${th.fg("dim", d.desc)}`],
|
|
76
|
+
[`${MARK}${PRODUCT} ${nums} ${bar}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)} ${th.fg("dim", bar)}`],
|
|
77
|
+
[`${MARK}${PRODUCT} ${nums}`, `${th.fg("accent", MARK + PRODUCT)} ${th.fg("text", nums)}`],
|
|
78
|
+
[nums, th.fg("text", nums)],
|
|
79
|
+
];
|
|
80
|
+
const head = tries.find(([plain]) => w(plain) <= width);
|
|
81
|
+
if (!head) return [];
|
|
82
|
+
const out = [head[1]];
|
|
83
|
+
const sub = (plain: string) => {
|
|
84
|
+
const line = INDENT + plain;
|
|
85
|
+
if (w(line) <= width) out.push(th.fg("dim", line));
|
|
86
|
+
else if (w(plain) <= width) out.push(th.fg("dim", plain));
|
|
87
|
+
};
|
|
88
|
+
if (d.first) sub("originals are kept; the model can recall any of them with zip_recall");
|
|
89
|
+
if (!expanded) return out;
|
|
90
|
+
if (d.why) sub(d.why);
|
|
91
|
+
const items = d.items ?? [];
|
|
92
|
+
const lw = Math.min(28, Math.max(0, ...items.map((i) => i.label.length)));
|
|
93
|
+
for (const i of items) {
|
|
94
|
+
const label = i.label.length > lw ? i.label.slice(0, lw - 1) + "…" : i.label.padEnd(lw);
|
|
95
|
+
sub(`${label} ${(i.turn ? `turn ${i.turn}` : "").padEnd(8)}${fmtK(i.tokens).padStart(6)} ${i.handle}`);
|
|
96
|
+
}
|
|
97
|
+
if (d.more) sub(`… ${d.more} more`);
|
|
98
|
+
return out;
|
|
28
99
|
}
|
|
29
100
|
|
|
30
101
|
export class Stats {
|
package/src/run.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname } from "node:path";
|
|
|
6
6
|
import { detectCold, lastMessageMs, lastPrompt, modelKey, resolveTtl, ttlFor } from "./cache.ts";
|
|
7
7
|
import { describe, lawPrices, loadStats, pWarm, record, sample, GAP_EDGES, type Entry } from "./learn.ts";
|
|
8
8
|
import { validateEdits, repairPayload } from "./guard.ts";
|
|
9
|
-
import { 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";
|
|
@@ -16,6 +16,8 @@ import { type Any, PRODUCT, textOf, tok4 } from "./util.ts";
|
|
|
16
16
|
export const PLAN_CUSTOM = "pi-zip/plan";
|
|
17
17
|
export const STATE_CUSTOM = "pi-zip/state";
|
|
18
18
|
export const STEER_CUSTOM = "pi-zip/steer";
|
|
19
|
+
const fmtAway = (s: number) => (s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(Math.round(s / 360) / 10).toFixed(1)} h`);
|
|
20
|
+
export const NOTICE_CUSTOM = "pi-zip/notice"; // a fold/summary notice kept in the transcript (rendered by index.ts, never sent to the model)
|
|
19
21
|
export const UNUSED_SUMMARY_CUSTOM = "pi-zip/unused-summary"; // a background summary nobody adopted: its cost, so the session totals stay honest
|
|
20
22
|
export const AWAY_FRACTION = 0.8; // the away-timer fires this far into the cache lifetime
|
|
21
23
|
// Other context managers rewrite the view too (F14); two writers give unpredictable results, so we pause and keep only the Guard.
|
|
@@ -95,6 +97,10 @@ export class Zip implements ZipControl {
|
|
|
95
97
|
|
|
96
98
|
/** zip_recall declared to the model? Checked at session start and before every run (index.ts); a `--tools` allowlist hides it. */
|
|
97
99
|
recallOk = true;
|
|
100
|
+
/** index.ts registered the transcript renderer for NOTICE_CUSTOM (Pi versions without registerEntryRenderer fall back to a status line). */
|
|
101
|
+
entryRenderer = false;
|
|
102
|
+
private coldWhy = ""; // the expanded notice's "why now" for this run
|
|
103
|
+
private noticed = false; // a transcript notice was written in this process (the branch may not show it yet)
|
|
98
104
|
/** A summary was persisted since the last cold run start: later warm plans make none unless at the compaction room. */
|
|
99
105
|
summarizedWarm = false;
|
|
100
106
|
setRecallOk(ok: boolean) {
|
|
@@ -111,9 +117,19 @@ export class Zip implements ZipControl {
|
|
|
111
117
|
} catch {}
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
|
|
120
|
+
/** keep = a fold/summary notice: written to the session as a custom entry, so it stays in the transcript (also after a restart)
|
|
121
|
+
* next to Pi's own "[compaction]" block instead of a status line the next status overwrites. Custom entries never reach the model. */
|
|
122
|
+
private notify(ctx: Any, text: string, keep?: NoticeData) {
|
|
115
123
|
this.stats.notices++;
|
|
116
124
|
this.ledger({ type: "notice", text });
|
|
125
|
+
if (keep && ctx?.mode === "tui" && this.entryRenderer) {
|
|
126
|
+
setTimeout(() => { // after Pi has appended this turn's edits
|
|
127
|
+
try {
|
|
128
|
+
this.pi.appendEntry(NOTICE_CUSTOM, keep);
|
|
129
|
+
} catch {}
|
|
130
|
+
}, 0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
117
133
|
if (ctx?.ui?.notify && (ctx.mode === "tui" || ctx.mode === "rpc")) {
|
|
118
134
|
try {
|
|
119
135
|
ctx.ui.notify(text, "info");
|
|
@@ -243,6 +259,7 @@ export class Zip implements ZipControl {
|
|
|
243
259
|
this.survSrc = src;
|
|
244
260
|
this.runChecked = false; // the first request decides (cold: the cold plan; warm: only above the cap, if the law fires)
|
|
245
261
|
this.coldReason = reason;
|
|
262
|
+
this.coldWhy = src === "model switch" ? "model switched: its cache starts empty, so this request rewrites it anyway" : cold && gapS !== null ? `cache cold (away ${fmtAway(gapS)}): this request rewrites it anyway, so editing is free` : "";
|
|
246
263
|
for (const h of recalledHandlesFromBranch(branch)) this.recalled.add(h);
|
|
247
264
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
248
265
|
const en = branch[i];
|
|
@@ -565,7 +582,14 @@ export class Zip implements ZipControl {
|
|
|
565
582
|
const prepared = plan.source === "settle" && waitMs < 500; // finished while the user was away: report the real production time, not the zero wait
|
|
566
583
|
notices.push({ kind: "summary", count: cut.count, tokensBefore: ctxTokens - (before - after), tokensAfter: ctxTokens - (before - after) - cutSaved, ms: prepared ? cut.ms : waitMs, prepared, pressure: valve });
|
|
567
584
|
}
|
|
568
|
-
if (notices.length && !this.quiet)
|
|
585
|
+
if (notices.length && !this.quiet) {
|
|
586
|
+
const ITEMS = 8;
|
|
587
|
+
const items = live.slice(0, ITEMS).map((t) => ({ label: `${t.tool}${t.args ? " " + t.args : ""}`, turn: byId.get(t.entryId)?.userTurn, tokens: Math.round(k * t.entryTokens), handle: handleFor(t.entryId) }));
|
|
588
|
+
const why = valve ? "cache still warm, but the context passed the warm-cache limit" : this.cold ? this.coldWhy : "cache warm: the reads saved pay for the rewrite";
|
|
589
|
+
const data: NoticeData = { v: 2, text: noticeText(notices), before: Math.round(notices[0].tokensBefore), after: Math.round(notices[notices.length - 1].tokensAfter), desc: noticeDesc(notices), why: why || undefined, items, more: Math.max(0, live.length - ITEMS) || undefined, first: !this.noticed && !this.branch(ctx).some((en: Any) => en?.type === "custom" && en.customType === NOTICE_CUSTOM) };
|
|
590
|
+
this.noticed = true;
|
|
591
|
+
this.notify(ctx, data.text, data);
|
|
592
|
+
}
|
|
569
593
|
return { entries: [...e.entries, ...ours] }; // append, never overwrite other extensions' drafts
|
|
570
594
|
}
|
|
571
595
|
|