pi-zip 0.2.7 → 0.3.0-rc.1
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 +60 -42
- package/package.json +1 -1
- package/src/cache.ts +50 -12
- package/src/guard.ts +3 -2
- package/src/index.ts +45 -8
- package/src/learn.ts +18 -8
- package/src/notice.ts +91 -33
- package/src/placeholder.ts +12 -4
- package/src/plan.ts +434 -106
- package/src/recall.ts +111 -27
- package/src/recallfile.ts +165 -0
- package/src/run.ts +782 -116
- package/src/state.ts +53 -0
- package/src/summary.ts +22 -10
- package/src/ui.ts +107 -44
- package/src/util.ts +57 -2
package/src/notice.ts
CHANGED
|
@@ -1,7 +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
|
|
4
|
+
import { brand, type CardData, type StateWord, SYM } from "./ui.ts";
|
|
5
5
|
|
|
6
6
|
export interface NoticeAction {
|
|
7
7
|
kind: "fold" | "summary";
|
|
@@ -9,6 +9,7 @@ export interface NoticeAction {
|
|
|
9
9
|
tokensBefore: number;
|
|
10
10
|
tokensAfter: number;
|
|
11
11
|
ms: number;
|
|
12
|
+
waitMs?: number; // summary: how long the user waited for it (ms = how long it took to write)
|
|
12
13
|
prepared?: boolean; // summary computed while the user was away
|
|
13
14
|
pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
|
|
14
15
|
}
|
|
@@ -26,80 +27,126 @@ export interface NoticeData {
|
|
|
26
27
|
v: 2;
|
|
27
28
|
kind?: "state" | "card"; // absent: a fold/summary notice
|
|
28
29
|
word?: StateWord;
|
|
30
|
+
key?: string; // a state line shown once per session under this key (default: its word)
|
|
29
31
|
reason?: string;
|
|
30
32
|
card?: CardData;
|
|
31
33
|
text: string;
|
|
32
34
|
before: number;
|
|
33
35
|
after: number;
|
|
34
|
-
desc: string; // "
|
|
36
|
+
desc: string; // "zipped 12 old outputs in 6 ms · summarized 64 requests in 38.2 s · ready while you were away" (older entries: "folded …")
|
|
35
37
|
why?: string; // expanded view: why now
|
|
36
38
|
items?: NoticeItem[];
|
|
37
39
|
more?: number; // folded outputs not listed
|
|
38
40
|
first?: boolean; // the session's first notice: say once that originals are kept
|
|
41
|
+
reqTs?: number; // when the first request carrying this plan went out (ms): the renderer shows that request's real size as "after"
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
|
|
42
45
|
|
|
43
46
|
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
44
47
|
const secs = (ms: number) => `${(Math.round(ms / 100) / 10).toFixed(1)} s`;
|
|
48
|
+
const dur = (ms: number) => (ms < 950 ? `${Math.max(1, Math.round(ms))} ms` : secs(ms));
|
|
45
49
|
|
|
46
50
|
/** The words after the numbers: what happened, plus how long the user waited for a summary (if at all). */
|
|
47
51
|
export function noticeDesc(actions: NoticeAction[]): string {
|
|
48
52
|
return actions
|
|
49
53
|
.map((a) => {
|
|
50
|
-
if (a.kind === "fold") return `
|
|
51
|
-
|
|
54
|
+
if (a.kind === "fold") return `zipped ${plural(a.count, "old output")} in ${dur(a.ms)}`;
|
|
55
|
+
const wait = a.prepared ? 0 : (a.waitMs ?? a.ms);
|
|
56
|
+
const took = `summarized ${plural(a.count, "request")}`;
|
|
57
|
+
if (a.prepared) return `${took} in ${dur(a.ms)} · ready while you were away`;
|
|
58
|
+
if (wait >= a.ms - 500) return `${took} · waited ${secs(wait)}`; // the user waited for all of it
|
|
59
|
+
return `${took} in ${dur(a.ms)} · waited ${secs(wait)}`; // started while away, finished after the return
|
|
52
60
|
})
|
|
53
61
|
.join(" · ");
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
/** The description split for the line: what happened (verbs) and how long it took / waited (tail). Reads old entries too. */
|
|
65
|
+
export function splitDesc(desc: string): { verbs: string[]; tail: string[] } {
|
|
66
|
+
const verbs: string[] = [], tail: string[] = [];
|
|
67
|
+
for (const part of desc.split(" · ").filter(Boolean)) {
|
|
68
|
+
const m = /^(?:folded|zipped|summarized) (.*?)(?: in ([\d.]+ m?s))?$/.exec(part);
|
|
69
|
+
if (m) {
|
|
70
|
+
verbs.push(part.replace(/^folded /, "zipped ").replace(/ in [\d.]+ m?s$/, ""));
|
|
71
|
+
if (m[2]) tail.push(m[2]);
|
|
72
|
+
} else tail.push(part);
|
|
73
|
+
}
|
|
74
|
+
return { verbs, tail };
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
return "
|
|
77
|
+
export function noticeText(actions: NoticeAction[]): string {
|
|
78
|
+
const first = actions[0], last = actions[actions.length - 1];
|
|
79
|
+
const { verbs, tail } = splitDesc(noticeDesc(actions));
|
|
80
|
+
return `${PRODUCT} ${verbs.join(" · ")} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)}${tail.length ? " " + tail.join(" · ") : ""}`;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
export interface NoticeTheme {
|
|
68
84
|
fg(color: string, text: string): string;
|
|
85
|
+
bold?(text: string): string;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
const INDENT = " ".repeat(
|
|
88
|
+
/** Detail lines sit under the words, after the monogram and its space. */
|
|
89
|
+
const INDENT = " ".repeat(SYM.length + 1);
|
|
90
|
+
const GAP = " ";
|
|
91
|
+
/** Waiting at least this long is a cost the user paid: it gets the warning colour. Shorter waits are not worth the eye. */
|
|
92
|
+
const WAIT_NOTICEABLE_MS = 2000;
|
|
73
93
|
|
|
74
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* "ƶ zipped 12 old outputs 74K → 43K 6 ms"
|
|
96
|
+
* Colour, by meaning (theme tokens only, so dark/light/custom themes keep working; monochrome still reads):
|
|
97
|
+
* accent the monogram (bold): whose line it is
|
|
98
|
+
* muted the words: what happened
|
|
99
|
+
* success the size now (bold): the one bright thing on the line
|
|
100
|
+
* dim the old size, the arrow, durations, "ready while you were away", the detail lines
|
|
101
|
+
* warning only a wait the user actually sat through (>= 2 s): the one thing pi-zip cost them
|
|
102
|
+
*/
|
|
103
|
+
export function colorTail(tail: string[], th: NoticeTheme): string {
|
|
104
|
+
return tail.map((part) => {
|
|
105
|
+
const waited = /^waited ([\d.]+) s$/.exec(part);
|
|
106
|
+
return th.fg(waited && Number(waited[1]) * 1000 >= WAIT_NOTICEABLE_MS ? "warning" : "dim", part);
|
|
107
|
+
}).join(th.fg("dim", " · "));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Lines for the transcript. Narrow terminals drop whole segments in order (tail, then words); the numbers always stay.
|
|
75
111
|
* `w` measures display width (Pi's visibleWidth in the renderer; string length in tests). Every line fits `width`. */
|
|
76
112
|
export function renderNotice(d: NoticeData, expanded: boolean, width: number, th: NoticeTheme, w: (s: string) => number = (s) => s.length): string[] {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
113
|
+
const b = fmtK(d.before), a = fmtK(d.after);
|
|
114
|
+
const nums = `${b} → ${a}`;
|
|
115
|
+
const strong = (t: string) => (th.bold ? th.bold(t) : t);
|
|
116
|
+
const numsC = `${th.fg("dim", b)}${th.fg("dim", " → ")}${th.fg("success", strong(a))}`;
|
|
117
|
+
const { verbs, tail } = splitDesc(d.desc);
|
|
118
|
+
const said = verbs.join(" · "), saidC = verbs.map((v) => th.fg("muted", v)).join(th.fg("dim", " · "));
|
|
119
|
+
const tailP = tail.join(" · "), tailC = colorTail(tail, th);
|
|
120
|
+
const mark = brand(th);
|
|
79
121
|
const tries: [string, string][] = [
|
|
80
|
-
[`${
|
|
81
|
-
[`${
|
|
82
|
-
[`${
|
|
83
|
-
[nums,
|
|
122
|
+
...(said && tailP ? [[`${SYM} ${said}${GAP}${nums}${GAP}${tailP}`, `${mark} ${saidC}${GAP}${numsC}${GAP}${tailC}`] as [string, string]] : []),
|
|
123
|
+
...(said ? [[`${SYM} ${said}${GAP}${nums}`, `${mark} ${saidC}${GAP}${numsC}`] as [string, string]] : []),
|
|
124
|
+
[`${SYM} ${nums}`, `${mark} ${numsC}`],
|
|
125
|
+
[nums, numsC],
|
|
84
126
|
];
|
|
85
127
|
const head = tries.find(([plain]) => w(plain) <= width);
|
|
86
128
|
if (!head) return [];
|
|
87
129
|
const out = [head[1]];
|
|
130
|
+
const fit = (plain: string) => (w(INDENT + plain) <= width ? INDENT : w(plain) <= width ? "" : null);
|
|
88
131
|
const sub = (plain: string) => {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
else if (w(plain) <= width) out.push(th.fg("dim", plain));
|
|
132
|
+
const ind = fit(plain);
|
|
133
|
+
if (ind !== null) out.push(th.fg("dim", ind + plain));
|
|
92
134
|
};
|
|
93
|
-
if (d.first) sub("originals are kept
|
|
135
|
+
if (d.first) sub("originals are kept · the model can unzip any of them");
|
|
94
136
|
if (!expanded) return out;
|
|
95
|
-
if (d.why) sub(d.why);
|
|
96
137
|
const items = d.items ?? [];
|
|
97
|
-
const lw = Math.min(
|
|
138
|
+
const lw = Math.min(32, Math.max(0, ...items.map((i) => i.label.length)));
|
|
98
139
|
for (const i of items) {
|
|
99
140
|
const label = i.label.length > lw ? i.label.slice(0, lw - 1) + "…" : i.label.padEnd(lw);
|
|
100
|
-
|
|
141
|
+
const size = fmtK(i.tokens).padStart(6);
|
|
142
|
+
const plain = `${label}${size} ${i.handle}`;
|
|
143
|
+
const ind = fit(plain);
|
|
144
|
+
if (ind === null) continue;
|
|
145
|
+
// an item row: what it was (muted, like the words above), how big and its handle (dim: there if you need it)
|
|
146
|
+
out.push(th.fg("dim", ind) + th.fg("muted", label) + th.fg("dim", size) + th.fg("dim", ` ${i.handle}`));
|
|
101
147
|
}
|
|
102
148
|
if (d.more) sub(`… ${d.more} more`);
|
|
149
|
+
if (d.why) sub(d.why);
|
|
103
150
|
return out;
|
|
104
151
|
}
|
|
105
152
|
|
|
@@ -126,27 +173,38 @@ export interface ZipControl {
|
|
|
126
173
|
card?(ctx?: Any): CardData;
|
|
127
174
|
/** Show a /zip result in the transcript; false = no transcript surface here (print/json/rpc or an older Pi). */
|
|
128
175
|
show?(ctx: Any, data: NoticeData): boolean;
|
|
129
|
-
statsLine(ctx?: Any): string;
|
|
130
176
|
setOff(off: boolean): string;
|
|
131
177
|
toggleQuiet(): string;
|
|
132
178
|
}
|
|
133
179
|
|
|
180
|
+
const ZIP_SUBS: [string, string][] = [
|
|
181
|
+
["off", "stop folding; requests left untouched"],
|
|
182
|
+
["on", "resume folding"],
|
|
183
|
+
["quiet", "toggle the per-turn notice"],
|
|
184
|
+
];
|
|
185
|
+
|
|
134
186
|
export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
|
|
135
187
|
pi.registerCommand("zip", {
|
|
136
|
-
description: `${PRODUCT}:
|
|
188
|
+
description: `${PRODUCT}: off | on | quiet`,
|
|
189
|
+
getArgumentCompletions: (prefix: string) => {
|
|
190
|
+
const p = (prefix ?? "").trim().toLowerCase();
|
|
191
|
+
const items = ZIP_SUBS.filter(([v]) => v.startsWith(p)).map(([value, description]) => ({ value, label: value, description }));
|
|
192
|
+
return items.length ? items : null;
|
|
193
|
+
},
|
|
137
194
|
handler: async (args: string, cctx: Any) => {
|
|
138
|
-
|
|
139
|
-
|
|
195
|
+
// "/zip" alone shows the card. "status" and "stats" are older spellings of the same thing: still accepted, never advertised.
|
|
196
|
+
const raw = (args ?? "").trim().toLowerCase();
|
|
197
|
+
const sub = raw === "" || raw === "stats" ? "status" : raw;
|
|
198
|
+
if (sub === "status" && zip.card && zip.show) {
|
|
140
199
|
const card = zip.card(cctx);
|
|
141
200
|
if (zip.show(cctx, { v: 2, kind: "card", card, text: zip.status(cctx), before: 0, after: 0, desc: "" })) return;
|
|
142
201
|
}
|
|
143
202
|
const text =
|
|
144
203
|
sub === "status" ? zip.status(cctx)
|
|
145
|
-
: sub === "stats" ? zip.statsLine(cctx)
|
|
146
204
|
: sub === "off" ? zip.setOff(true)
|
|
147
205
|
: sub === "on" ? zip.setOff(false)
|
|
148
206
|
: sub === "quiet" ? zip.toggleQuiet()
|
|
149
|
-
: `${PRODUCT}: unknown subcommand "${sub}" (use
|
|
207
|
+
: `${PRODUCT}: unknown subcommand "${sub}" (use /zip, or /zip off | on | quiet)`;
|
|
150
208
|
if (zip.show && (sub === "off" || sub === "on" || sub === "quiet")) {
|
|
151
209
|
const word: StateWord = sub === "quiet" ? (/notices off/.test(text) ? "quiet" : "notices on") : sub;
|
|
152
210
|
const reason = text.replace(/^pi-zip: (off|on)\.? ?/, "").replace(/^pi-zip: /, "");
|
package/src/placeholder.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Placeholders (F2): tool + args + size + key lines + handle. Handles are deterministic in the session entry id.
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import type { Block, Calls } from "./plan.ts";
|
|
4
|
+
import { type FileRecall, fileRecallHow } from "./recallfile.ts";
|
|
4
5
|
import { type Any, clip, textOf } from "./util.ts";
|
|
5
6
|
|
|
6
7
|
export const PH_MARK = "[folded by pi-zip";
|
|
@@ -67,7 +68,11 @@ export function shortArgs(args: Any): string {
|
|
|
67
68
|
if (typeof args.command === "string") return clipMid(args.command, ARG_CHARS);
|
|
68
69
|
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
|
|
69
70
|
if (path !== null && typeof args.pattern !== "string") {
|
|
70
|
-
|
|
71
|
+
// which slice of the file, written the way Pi's own read row writes it: "src/a.ts:1-2000", "src/a.ts:50-"
|
|
72
|
+
const num = (k: string) => (typeof args[k] === "number" && Number.isFinite(args[k]) ? (args[k] as number) : undefined);
|
|
73
|
+
const off = num("offset"), lim = num("limit");
|
|
74
|
+
const start = off ?? 1;
|
|
75
|
+
const win = off !== undefined || lim !== undefined ? `:${start}${lim !== undefined ? `-${start + lim - 1}` : ""}` : "";
|
|
71
76
|
return clipMid(path, ARG_CHARS) + win;
|
|
72
77
|
}
|
|
73
78
|
if (typeof args.pattern === "string") return clipMid(args.pattern + (args.path ? " " + args.path : ""), ARG_CHARS);
|
|
@@ -109,6 +114,7 @@ export interface PlaceholderMeta {
|
|
|
109
114
|
turn?: number; // user turn the output belongs to (1-based, as seen in the projection when it was folded)
|
|
110
115
|
isError?: boolean;
|
|
111
116
|
noRecall?: boolean; // zip_recall is not declared to the model (`--tools` allowlist): point at re-reading instead
|
|
117
|
+
file?: FileRecall; // ... but read, grep or bash is: point at the recall file (written on demand from the session)
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
/** Placeholder text, or null when the output is too short to be worth folding. The text is stored with the fold (context_edit),
|
|
@@ -122,17 +128,19 @@ export function makePlaceholder(text: string, tool: string, args: string, handle
|
|
|
122
128
|
return (
|
|
123
129
|
`${PH_MARK} · ${tool}${args ? " " + args : ""}${meta.turn ? ` · turn ${meta.turn}` : ""}${outcome ? ` · ${outcome}` : ""} · ${text.length} chars, ${lines} lines · handle ${handle}]\n` +
|
|
124
130
|
(keys.length ? `key lines kept (original line numbers; up to ${keep}):\n${body}\n` : "") +
|
|
125
|
-
(
|
|
131
|
+
(meta.file
|
|
132
|
+
? `Original kept byte for byte, even after summaries or compaction, in ${meta.file.dir}/${handle}.txt: ${fileRecallHow(meta.file)}; prefer that to re-running or re-reading the source (output may differ). Do not guess its content.`
|
|
133
|
+
: !meta.noRecall
|
|
126
134
|
? `Original kept byte for byte, recallable even after summaries or compaction: ${RECALL_TOOL}("${handle}") (optional grep/range) is instant, free, no side effects; prefer it to re-running or re-reading (output may differ). Do not guess its content.`
|
|
127
135
|
: `Original not shown (${RECALL_TOOL} is not enabled in this session): re-read the file or re-run the read-only command if you need it. Do not guess its content.`)
|
|
128
136
|
);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
/** Placeholder for a toolResult block (null for image results, entries without an id, or short outputs). */
|
|
132
|
-
export function makePlaceholderFor(b: Block, calls: Calls, keep = 8, noRecall = false): string | null {
|
|
140
|
+
export function makePlaceholderFor(b: Block, calls: Calls, keep = 8, noRecall = false, file?: FileRecall): string | null {
|
|
133
141
|
const content = b.msg.content;
|
|
134
142
|
if (Array.isArray(content) && content.some((c: Any) => c?.type !== "text")) return null;
|
|
135
143
|
if (!b.entryId) return null;
|
|
136
144
|
const call = calls.get(b.msg.toolCallId);
|
|
137
|
-
return makePlaceholder(textOf(content), call?.name ?? b.msg.toolName ?? "tool", call ? shortArgs(call.args) : "", handleFor(b.entryId), keep, { turn: b.userTurn, isError: !!b.msg.isError, noRecall });
|
|
145
|
+
return makePlaceholder(textOf(content), call?.name ?? b.msg.toolName ?? "tool", call ? shortArgs(call.args) : "", handleFor(b.entryId), keep, { turn: b.userTurn, isError: !!b.msg.isError, noRecall, file });
|
|
138
146
|
}
|