pi-zip 0.2.8 → 0.3.0-rc.2

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/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 { CardData, StateWord } from "./ui.ts";
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; // "folded 12 old outputs · summarized 64 requests · ready while you were away"
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 `folded ${plural(a.count, "old output")}`;
51
- return `summarized ${plural(a.count, "request")} · ${a.prepared ? "ready while you were away" : `waited ${secs(a.ms)}`}`;
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
- export function noticeText(actions: NoticeAction[]): string {
57
- const first = actions[0], last = actions[actions.length - 1];
58
- return `${PRODUCT} ${fmtK(first.tokensBefore)} → ${fmtK(last.tokensAfter)} ${noticeDesc(actions)}`;
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
- /** 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);
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
- const MARK = "▸ ";
72
- const INDENT = " ".repeat(MARK.length + PRODUCT.length + 2);
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
- /** Lines for the transcript. Narrow terminals drop whole segments in order (description, then bar); the numbers always stay.
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 nums = `${fmtK(d.before)} → ${fmtK(d.after)}`;
78
- const bar = ratioBar(d.before, d.after);
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
- [`${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)],
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 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));
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; the model can recall any of them with zip_recall");
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(28, Math.max(0, ...items.map((i) => i.label.length)));
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
- sub(`${label} ${(i.turn ? `turn ${i.turn}` : "").padEnd(8)}${fmtK(i.tokens).padStart(6)} ${i.handle}`);
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}: status | off | on | quiet`,
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
- const sub = (args ?? "").trim().toLowerCase() || "status";
139
- if ((sub === "status" || sub === "stats") && zip.card && zip.show) {
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 status | off | on | quiet)`;
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: /, "");
@@ -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
- const win = ["offset", "limit"].filter((k) => typeof args[k] === "number" && Number.isFinite(args[k])).map((k) => ` ${k}=${args[k]}`).join(""); // which slice of the file
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
- (!meta.noRecall
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
  }