pi-zip 0.0.0-stage → 0.2.0

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/recall.ts ADDED
@@ -0,0 +1,199 @@
1
+ // zip_recall (F3): exact, batched retrieval of folded originals from the session file (I2).
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { handleFor, RECALL_TOOL } from "./placeholder.ts";
4
+ import { type Any, clamp, textOf } from "./util.ts";
5
+
6
+ const RECALL_PAGE_CHARS = 20_000; // default page
7
+ const RECALL_MAX_PAGE_CHARS = 50_000;
8
+ const RECALL_MAX_HITS = 400;
9
+ const GREP_MAX_PATTERN = 256;
10
+ const GREP_BUDGET_MS = 2000;
11
+
12
+ export function parseRange(spec: string): { from: number; to: number } | null {
13
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(spec ?? "");
14
+ if (!m) return null;
15
+ const from = parseInt(m[1], 10);
16
+ const to = m[2] ? parseInt(m[2], 10) : from;
17
+ return from >= 1 && to >= from && to - from <= 100_000 ? { from, to } : null;
18
+ }
19
+
20
+ // Regex shapes that can backtrack catastrophically on a long line: a quantified group that itself holds a quantifier or an
21
+ // alternation, and back-references. They are searched as literal text instead (a sync regex cannot be interrupted).
22
+ const RISKY_REGEX = /\((?:[^()\\]|\\.)*(?:[+*]|\{\d*,\d*\}|\|)(?:[^()\\]|\\.)*\)\s*(?:[+*]|\{\d)|\\[1-9]|\\k</;
23
+
24
+ /** Case-insensitive regex for a grep pattern, or the pattern as literal text when it does not compile or could backtrack badly. */
25
+ export function grepMatcher(p: string): { re: RegExp; literal: boolean } {
26
+ const escaped = () => new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
27
+ if (p.length > GREP_MAX_PATTERN || RISKY_REGEX.test(p)) return { re: escaped(), literal: true };
28
+ try {
29
+ return { re: new RegExp(p, "i"), literal: false };
30
+ } catch {
31
+ return { re: escaped(), literal: true };
32
+ }
33
+ }
34
+
35
+ export interface RecallSlice {
36
+ text: string; // what the model sees: header, the page, and a hint on how to get the rest
37
+ body: string; // the page itself, exactly as a slice of the selected original text
38
+ totalLines: number;
39
+ matched: number | null;
40
+ clipped: boolean;
41
+ offset: number;
42
+ nextOffset: number | null; // where the next page starts; null = this page reaches the end of the selection
43
+ selectionChars: number;
44
+ }
45
+
46
+ const int = (v: unknown): number | null => {
47
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
48
+ return typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : null;
49
+ };
50
+ const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff;
51
+ const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
52
+
53
+ /**
54
+ * Full text, a line range or grep matches of an original, then one page of it by CHARACTERS (offset, limit): a single
55
+ * 45,000-character line is reachable too. Pages never split a surrogate pair, so concatenated pages are the selection byte for byte.
56
+ */
57
+ export function sliceRecall(text: string, opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): RecallSlice {
58
+ const lines = text.split("\n");
59
+ const totalLines = lines.length;
60
+ let sel: string;
61
+ let matched: number | null = null;
62
+ let header = "";
63
+ const empty = (msg: string): RecallSlice => ({ text: msg, body: "", totalLines, matched: null, clipped: false, offset: 0, nextOffset: null, selectionChars: 0 });
64
+ if (opts.grep !== undefined && String(opts.grep).trim() !== "") {
65
+ const { re, literal } = grepMatcher(String(opts.grep));
66
+ const hits: string[] = [];
67
+ const t0 = Date.now();
68
+ let examined = 0;
69
+ for (; examined < lines.length && hits.length < RECALL_MAX_HITS; examined++) {
70
+ if (re.test(lines[examined])) hits.push(`${examined + 1}: ${lines[examined]}`);
71
+ if (Date.now() - t0 > GREP_BUDGET_MS) { examined++; break; }
72
+ }
73
+ matched = hits.length;
74
+ sel = hits.join("\n");
75
+ header = `[grep${literal ? " (searched as literal text: the pattern is not a safe regular expression)" : ""}: ${matched} matching line(s) of ${totalLines}${examined < lines.length && hits.length < RECALL_MAX_HITS ? `; stopped after ${examined} lines (time budget), use range for the rest` : ""}]\n`;
76
+ } else if (opts.range !== undefined && String(opts.range).trim() !== "") {
77
+ const r = parseRange(String(opts.range));
78
+ if (!r) return empty(`(invalid range "${opts.range}"; use e.g. range="120-240")`);
79
+ sel = lines.slice(r.from - 1, r.to).join("\n");
80
+ header = `[range: lines ${r.from}-${Math.min(r.to, totalLines)} of ${totalLines}]\n`;
81
+ } else {
82
+ sel = text;
83
+ }
84
+ const limitIn = int(opts.limit);
85
+ const limit = clamp(limitIn ?? RECALL_PAGE_CHARS, 1, RECALL_MAX_PAGE_CHARS);
86
+ let offset = clamp(int(opts.offset) ?? 0, 0, sel.length);
87
+ if (offset > 0 && offset < sel.length && isLow(sel.charCodeAt(offset))) offset--; // never start inside a surrogate pair
88
+ let end = Math.min(sel.length, offset + limit);
89
+ if (end < sel.length && isHigh(sel.charCodeAt(end - 1))) end += end - 1 > offset ? -1 : 1; // ... or end inside one (a 1-char page takes the whole pair)
90
+ const body = sel.slice(offset, end);
91
+ const more = end < sel.length;
92
+ const paged = more || offset > 0;
93
+ const pageHeader = paged ? `[chars ${offset}-${end} of ${sel.length}${header ? " of the selection" : ""}]\n` : "";
94
+ const hint = more
95
+ ? `\n[… ${sel.length - end} more chars: call ${RECALL_TOOL} again with offset=${end} for the next page (limit up to ${RECALL_MAX_PAGE_CHARS}), or narrow it with grep or range]`
96
+ : "";
97
+ return { text: header + pageHeader + body + hint, body, totalLines, matched, clipped: more, offset, nextOffset: more ? end : null, selectionChars: sel.length };
98
+ }
99
+
100
+ export interface RecallItem {
101
+ handle: string;
102
+ text: string | null;
103
+ tool: string | null;
104
+ }
105
+
106
+ export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number } {
107
+ const parts: string[] = [];
108
+ let ok = 0;
109
+ let missing = 0;
110
+ for (const it of items) {
111
+ if (it.text === null) {
112
+ missing++;
113
+ 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
+ continue;
115
+ }
116
+ ok++;
117
+ const slice = sliceRecall(it.text, opts);
118
+ parts.push(`[handle ${it.handle}${it.tool ? " · " + it.tool : ""} · ${it.text.length} chars · ${slice.totalLines} lines]\n${slice.text}`);
119
+ }
120
+ return { text: parts.join("\n\n"), ok, missing };
121
+ }
122
+
123
+ /** Resolve handles against the session branch (which spans entries before any compaction): exact originals. */
124
+ export function resolveHandlesInBranch(branch: Any[], handles: string[]): { items: RecallItem[]; entryIds: (string | null)[] } {
125
+ const items: RecallItem[] = handles.map((h) => ({ handle: h, text: null, tool: null }));
126
+ const entryIds: (string | null)[] = handles.map(() => null);
127
+ for (const en of branch) {
128
+ if (en?.type !== "message" || en.message?.role !== "toolResult") continue;
129
+ const k = handles.indexOf(handleFor(en.id));
130
+ if (k < 0) continue;
131
+ items[k] = { handle: handles[k], text: textOf(en.message.content), tool: en.message.toolName ?? null };
132
+ entryIds[k] = en.id;
133
+ }
134
+ return { items, entryIds };
135
+ }
136
+
137
+ /** Handles the model already recalled in this branch (F4: never refold them). */
138
+ export function recalledHandlesFromBranch(branch: Any[]): Set<string> {
139
+ const out = new Set<string>();
140
+ for (const en of branch) {
141
+ const m = en?.type === "message" ? en.message : null;
142
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
143
+ for (const c of m.content) {
144
+ if (c?.type !== "toolCall" || c.name !== RECALL_TOOL) continue;
145
+ const a: Any = c.arguments ?? {};
146
+ if (typeof a.handle === "string" && a.handle.trim()) out.add(a.handle.trim());
147
+ if (Array.isArray(a.handles)) for (const h of a.handles) if (typeof h === "string" && h.trim()) out.add(h.trim());
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export interface RecallHooks {
154
+ onRecall(handles: string[], chars: number, entryIds: (string | null)[]): void;
155
+ }
156
+
157
+ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
158
+ pi.registerTool({
159
+ name: RECALL_TOOL,
160
+ label: "Recall folded output",
161
+ description:
162
+ "Get back the EXACT original content of tool outputs that pi-zip folded earlier in this session. It is instant, free and has no side effects: " +
163
+ "prefer it over re-running a command or re-reading a file when you need the exact earlier output (a re-run may give different results). " +
164
+ "Every folded block shows a handle (10 characters) in its marker line, and summaries carry a handle table; handles stay valid " +
165
+ "after later summaries or compaction. Pass one handle, or several at once with handles for a batch. " +
166
+ "The text may be large and comes in pages of characters: prefer grep (case-insensitive regular expression; returns matching lines with their line numbers) " +
167
+ 'or range (e.g. "120-240" for that line range); when a page says more is left, call again with offset to continue. Never guess the content of a folded block.',
168
+ parameters: {
169
+ type: "object",
170
+ properties: {
171
+ handle: { type: "string", description: "A single handle from a folded block's marker line or the summary's handle table" },
172
+ handles: { type: "array", items: { type: "string" }, description: "Optional batch: several handles at once; each is returned as its own labelled section" },
173
+ grep: { type: "string", description: "Optional: only lines matching this case-insensitive regular expression, with line numbers (applied to every handle)" },
174
+ range: { type: "string", description: 'Optional: only this line range, e.g. "120-240" (applied to every handle)' },
175
+ offset: { type: "number", description: "Optional: start this many characters into the (selected) text, for the next page of a large output or a very long single line (applied to every handle)" },
176
+ limit: { type: "number", description: `Optional: page size in characters (default ${RECALL_PAGE_CHARS}, at most ${RECALL_MAX_PAGE_CHARS})` },
177
+ },
178
+ required: [],
179
+ } as Any,
180
+ async execute(_id: string, params: Any, _signal: Any, _onUpdate: Any, ctx: Any): Promise<Any> {
181
+ const grep = params?.grep !== undefined ? String(params.grep) : undefined;
182
+ const range = params?.range !== undefined ? String(params.range) : undefined;
183
+ const offset = params?.offset;
184
+ const limit = params?.limit;
185
+ const handles: string[] = [];
186
+ if (typeof params?.handle === "string" && params.handle.trim()) handles.push(params.handle.trim());
187
+ if (Array.isArray(params?.handles)) for (const h of params.handles) if (typeof h === "string" && h.trim() && !handles.includes(h.trim())) handles.push(h.trim());
188
+ if (!handles.length) return { content: [{ type: "text", text: "Pass handle (one string) or handles (array), copied exactly from the folded block marker lines or the summary handle table." }], details: {}, isError: true };
189
+ try {
190
+ const { items, entryIds } = resolveHandlesInBranch(ctx.sessionManager.getBranch() as Any[], handles);
191
+ const out = recallSections(items, { grep, range, offset, limit });
192
+ 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 };
194
+ } catch (err) {
195
+ return { content: [{ type: "text", text: `${RECALL_TOOL} failed: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
196
+ }
197
+ },
198
+ });
199
+ }