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/recall.ts CHANGED
@@ -1,10 +1,12 @@
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
3
  import { handleFor, RECALL_TOOL, shortArgs } from "./placeholder.ts";
4
+ import { commandRecallHandles, parseRecallPath } from "./recallfile.ts";
4
5
  import { measure, recallCallLine, recallResultLines, type RecallSectionInfo } from "./ui.ts";
5
6
  import { type Any, clamp, textOf } from "./util.ts";
6
7
 
7
8
  const RECALL_PAGE_CHARS = 20_000; // default page
9
+ const SEARCH_PAGE_CHARS = 8_000; // default page of grep without a handle: a broad pattern over every output stays small (search results are never folded)
8
10
  const RECALL_MAX_PAGE_CHARS = 50_000;
9
11
  const RECALL_MAX_HITS = 400;
10
12
  const GREP_MAX_PATTERN = 256;
@@ -55,7 +57,7 @@ const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
55
57
  * Full text, a line range or grep matches of an original, then one page of it by CHARACTERS (offset, limit): a single
56
58
  * 45,000-character line is reachable too. Pages never split a surrogate pair, so concatenated pages are the selection byte for byte.
57
59
  */
58
- export function sliceRecall(text: string, opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): RecallSlice {
60
+ export function sliceRecall(text: string, opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown; pageChars?: number; narrow?: string }): RecallSlice {
59
61
  const lines = text.split("\n");
60
62
  const totalLines = lines.length;
61
63
  let sel: string;
@@ -83,7 +85,7 @@ export function sliceRecall(text: string, opts: { grep?: string; range?: string;
83
85
  sel = text;
84
86
  }
85
87
  const limitIn = int(opts.limit);
86
- const limit = clamp(limitIn ?? RECALL_PAGE_CHARS, 1, RECALL_MAX_PAGE_CHARS);
88
+ const limit = clamp(limitIn ?? opts.pageChars ?? RECALL_PAGE_CHARS, 1, RECALL_MAX_PAGE_CHARS);
87
89
  let offset = clamp(int(opts.offset) ?? 0, 0, sel.length);
88
90
  if (offset > 0 && offset < sel.length && isLow(sel.charCodeAt(offset))) offset--; // never start inside a surrogate pair
89
91
  let end = Math.min(sel.length, offset + limit);
@@ -93,7 +95,7 @@ export function sliceRecall(text: string, opts: { grep?: string; range?: string;
93
95
  const paged = more || offset > 0;
94
96
  const pageHeader = paged ? `[chars ${offset}-${end} of ${sel.length}${header ? " of the selection" : ""}]\n` : "";
95
97
  const hint = more
96
- ? `\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]`
98
+ ? `\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 ${opts.narrow ?? "narrow it with grep or range"}]`
97
99
  : "";
98
100
  return { text: header + pageHeader + body + hint, body, totalLines, matched, clipped: more, offset, nextOffset: more ? end : null, selectionChars: sel.length };
99
101
  }
@@ -106,6 +108,9 @@ export interface RecallItem {
106
108
  turn?: number;
107
109
  }
108
110
 
111
+ /** "[handle h · bash npm test · 812 chars · 40 lines]": the call that produced an output, so the model can tell look-alike outputs apart. */
112
+ const sectionHead = (it: RecallItem & { text: string }) => `[handle ${it.handle}${it.label || it.tool ? " · " + (it.label ?? it.tool) : ""} · ${it.text.length} chars · ${it.text.split("\n").length} lines]`;
113
+
109
114
  export function recallSections(items: RecallItem[], opts: { grep?: string; range?: string; offset?: unknown; limit?: unknown }): { text: string; ok: number; missing: number; sections: RecallSectionInfo[] } {
110
115
  const parts: string[] = [];
111
116
  const sections: RecallSectionInfo[] = [];
@@ -124,33 +129,92 @@ export function recallSections(items: RecallItem[], opts: { grep?: string; range
124
129
  const how = grep ? "grep" : range ? "range" : slice.clipped || slice.offset > 0 ? "page" : "all";
125
130
  const shown = grep ? (slice.matched ?? 0) : slice.body ? slice.body.split("\n").length : 0;
126
131
  sections.push({ handle: it.handle, label: it.label ?? it.tool ?? undefined, turn: it.turn, totalLines: slice.totalLines, shownLines: shown, how });
127
- parts.push(`[handle ${it.handle}${it.tool ? " · " + it.tool : ""} · ${it.text.length} chars · ${slice.totalLines} lines]\n${slice.text}`);
132
+ parts.push(`${sectionHead({ ...it, text: it.text })}\n${slice.text}`);
128
133
  }
129
134
  return { text: parts.join("\n\n"), ok, missing, sections };
130
135
  }
131
136
 
132
- /** Resolve handles against the session branch (which spans entries before any compaction): exact originals. */
133
- export function resolveHandlesInBranch(branch: Any[], handles: string[]): { items: RecallItem[]; entryIds: (string | null)[] } {
134
- const items: RecallItem[] = handles.map((h) => ({ handle: h, text: null, tool: null }));
135
- const entryIds: (string | null)[] = handles.map(() => null);
137
+ /** Every tool output of the session branch (which spans entries before any compaction), oldest first, with the call that produced it. */
138
+ export function branchOutputs(branch: Any[]): Array<RecallItem & { text: string; entryId: string }> {
139
+ const out: Array<RecallItem & { text: string; entryId: string }> = [];
136
140
  const calls = new Map<string, Any>();
137
141
  let turn = 0;
138
142
  for (const en of branch) {
139
143
  const msg = en?.type === "message" ? en.message : null;
140
144
  if (msg?.role === "user") turn++;
141
145
  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;
143
- const k = handles.indexOf(handleFor(en.id));
144
- if (k < 0) continue;
146
+ if (msg?.role !== "toolResult" || !en.id) continue;
145
147
  const call = calls.get(msg.toolCallId);
146
148
  const tool = msg.toolName ?? call?.name ?? null;
147
149
  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 };
149
- entryIds[k] = en.id;
150
+ out.push({ entryId: en.id, handle: handleFor(en.id), text: textOf(msg.content), tool, label: tool ? `${tool}${args ? " " + args : ""}` : undefined, turn: turn || undefined });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** Resolve handles against the session branch: exact originals. */
156
+ export function resolveHandlesInBranch(branch: Any[], handles: string[]): { items: RecallItem[]; entryIds: (string | null)[] } {
157
+ const items: RecallItem[] = handles.map((h) => ({ handle: h, text: null, tool: null }));
158
+ const entryIds: (string | null)[] = handles.map(() => null);
159
+ for (const o of branchOutputs(branch)) {
160
+ const k = handles.indexOf(o.handle);
161
+ if (k < 0) continue;
162
+ const { entryId, ...it } = o;
163
+ items[k] = it;
164
+ entryIds[k] = entryId;
150
165
  }
151
166
  return { items, entryIds };
152
167
  }
153
168
 
169
+ /**
170
+ * grep without a handle: every earlier tool output of the branch (zip_recall results excepted), newest output first, each match
171
+ * under its output's handle and call, then one page by characters (default SEARCH_PAGE_CHARS). For outputs whose handle the model
172
+ * cannot see any more (a long summary lists only the newest) and for questions that name the content rather than the output.
173
+ * `upTo` pins the pool to its first n outputs (branch order, oldest first; new outputs only append): a later page of the same search
174
+ * passes the pool size of its first page, so an output that arrived in between cannot shift the character offsets.
175
+ */
176
+ export function searchOutputs(outputs: Array<RecallItem & { text: string }>, grep: string, opts: { offset?: unknown; limit?: unknown; upTo?: number } = {}): { text: string; matched: number; hits: number; chars: number; pool: number; sections: RecallSectionInfo[] } {
177
+ const { re, literal } = grepMatcher(grep);
178
+ const all = outputs.filter((o) => o.tool !== RECALL_TOOL);
179
+ const pool = opts.upTo !== undefined && opts.upTo >= 0 && opts.upTo < all.length ? all.slice(0, opts.upTo) : all;
180
+ const parts: string[] = [];
181
+ const sections: RecallSectionInfo[] = [];
182
+ let hits = 0;
183
+ let stopped = false;
184
+ const t0 = Date.now();
185
+ for (let k = pool.length - 1; k >= 0 && !stopped; k--) {
186
+ const it = pool[k];
187
+ const lines = it.text.split("\n");
188
+ const got: string[] = [];
189
+ for (let i = 0; i < lines.length; i++) {
190
+ if (hits >= RECALL_MAX_HITS || Date.now() - t0 > GREP_BUDGET_MS) {
191
+ stopped = true;
192
+ break;
193
+ }
194
+ if (re.test(lines[i])) {
195
+ got.push(`${i + 1}: ${lines[i]}`);
196
+ hits++;
197
+ }
198
+ }
199
+ if (!got.length) continue;
200
+ parts.push(`${sectionHead(it)}\n${got.join("\n")}`);
201
+ sections.push({ handle: it.handle, label: it.label ?? it.tool ?? undefined, turn: it.turn, totalLines: lines.length, shownLines: got.length, how: "grep" });
202
+ }
203
+ const head = `[grep${literal ? " (searched as literal text: the pattern is not a safe regular expression)" : ""} over all ${pool.length} earlier tool outputs (no handle given): ${hits} matching line(s) in ${sections.length} output(s), newest output first${stopped ? "; stopped early (time or hit cap), narrow the pattern or pass a handle" : ""}]\n`;
204
+ if (!hits) return { text: head + "Nothing matched. Try a shorter or different pattern, or pass the handle shown in a folded block or a summary.", matched: 0, hits, chars: 0, pool: pool.length, sections };
205
+ const page = sliceRecall(parts.join("\n\n"), { offset: opts.offset, limit: opts.limit, pageChars: SEARCH_PAGE_CHARS, narrow: "narrow the pattern, or pass one of the handles above with grep" });
206
+ return { text: head + page.text, matched: sections.length, hits, chars: page.body.length, pool: pool.length, sections };
207
+ }
208
+
209
+ /** The pool size recorded by the newest earlier search with this same pattern (its first page), for a later page of it. */
210
+ export function searchPoolOf(branch: Any[], grep: string): number | undefined {
211
+ for (let i = branch.length - 1; i >= 0; i--) {
212
+ const m = branch[i]?.type === "message" ? branch[i].message : null;
213
+ if (m?.role === "toolResult" && m.toolName === RECALL_TOOL && m.details?.search === grep && typeof m.details.pool === "number") return m.details.pool;
214
+ }
215
+ return undefined;
216
+ }
217
+
154
218
  /** Handles the model already recalled in this branch (F4: never refold them). */
155
219
  export function recalledHandlesFromBranch(branch: Any[]): Set<string> {
156
220
  const out = new Set<string>();
@@ -158,10 +222,17 @@ export function recalledHandlesFromBranch(branch: Any[]): Set<string> {
158
222
  const m = en?.type === "message" ? en.message : null;
159
223
  if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
160
224
  for (const c of m.content) {
161
- if (c?.type !== "toolCall" || c.name !== RECALL_TOOL) continue;
225
+ if (c?.type !== "toolCall") continue;
162
226
  const a: Any = c.arguments ?? {};
163
- if (typeof a.handle === "string" && a.handle.trim()) out.add(a.handle.trim());
164
- if (Array.isArray(a.handles)) for (const h of a.handles) if (typeof h === "string" && h.trim()) out.add(h.trim());
227
+ if (c.name === RECALL_TOOL) {
228
+ if (typeof a.handle === "string" && a.handle.trim()) out.add(a.handle.trim());
229
+ if (Array.isArray(a.handles)) for (const h of a.handles) if (typeof h === "string" && h.trim()) out.add(h.trim());
230
+ } else if (c.name === "bash") {
231
+ for (const h of commandRecallHandles(a.command)) out.add(h); // file recall: a shell reading a recall file
232
+ } else if (c.name === "read" || c.name === "grep") {
233
+ const h = parseRecallPath(a.path)?.handle; // file recall: read or grep of a recall file
234
+ if (h) out.add(h);
235
+ }
165
236
  }
166
237
  }
167
238
  return out;
@@ -169,6 +240,7 @@ export function recalledHandlesFromBranch(branch: Any[]): Set<string> {
169
240
 
170
241
  export interface RecallHooks {
171
242
  onRecall(handles: string[], chars: number, entryIds: (string | null)[]): void;
243
+ labelOf?(handle: string): string | undefined; // "read src/learn.ts" for a handle folded in this branch
172
244
  }
173
245
 
174
246
  export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
@@ -176,26 +248,27 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
176
248
  name: RECALL_TOOL,
177
249
  label: "Recall folded output",
178
250
  description:
179
- "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: " +
180
- "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). " +
181
- "Every folded block shows a handle (10 characters) in its marker line, and summaries carry a handle table; handles stay valid " +
182
- "after later summaries or compaction. Pass one handle, or several at once with handles for a batch. " +
183
- "The text may be large and comes in pages of characters: prefer grep (case-insensitive regular expression; returns matching lines with their line numbers) " +
184
- '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.',
251
+ "Get back the EXACT original of earlier tool outputs of this session that are no longer shown in full: a [folded by pi-zip …] block, or an output " +
252
+ "listed with a handle in a pi-zip summary. Whenever you need what such an output said, recall it first: it is instant, free and has no side effects, " +
253
+ "while re-running a command or re-reading a file costs a step and may give a different result. Never guess the content of a folded output. " +
254
+ "Pass its handle (10 characters, from the block's marker line or the summary; handles stay valid after later summaries or compaction), or several with handles. " +
255
+ 'For a part, add grep (case-insensitive regular expression; matching lines with line numbers) or range (e.g. "120-240"). ' +
256
+ "No handle at hand? grep alone searches every earlier tool output of the session and labels each match with its output's handle and command. " +
257
+ "Large results come in pages of characters: when a page says more is left, call again with offset.",
185
258
  parameters: {
186
259
  type: "object",
187
260
  properties: {
188
- handle: { type: "string", description: "A single handle from a folded block's marker line or the summary's handle table" },
261
+ handle: { type: "string", description: "A handle from a folded block's marker line or a summary" },
189
262
  handles: { type: "array", items: { type: "string" }, description: "Optional batch: several handles at once; each is returned as its own labelled section" },
190
- grep: { type: "string", description: "Optional: only lines matching this case-insensitive regular expression, with line numbers (applied to every handle)" },
263
+ grep: { type: "string", description: "Optional: only lines matching this case-insensitive regular expression, with line numbers (applied to every handle). Without any handle: searches every earlier tool output of the session" },
191
264
  range: { type: "string", description: 'Optional: only this line range, e.g. "120-240" (applied to every handle)' },
192
265
  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)" },
193
- limit: { type: "number", description: `Optional: page size in characters (default ${RECALL_PAGE_CHARS}, at most ${RECALL_MAX_PAGE_CHARS})` },
266
+ limit: { type: "number", description: `Optional: page size in characters (default ${RECALL_PAGE_CHARS}; ${SEARCH_PAGE_CHARS} for grep without a handle; at most ${RECALL_MAX_PAGE_CHARS})` },
194
267
  },
195
268
  required: [],
196
269
  } as Any,
197
270
  renderCall(args: Any, theme: Any) {
198
- return lines((w) => recallCallLine(args ?? {}, w, theme, measure));
271
+ return lines((w) => recallCallLine(args ?? {}, w, theme, measure, (h) => hooks.labelOf?.(h)));
199
272
  },
200
273
  renderResult(result: Any, options: Any, theme: Any) {
201
274
  const text = textOf(result?.content);
@@ -210,7 +283,18 @@ export function registerRecallTool(pi: ExtensionAPI, hooks: RecallHooks) {
210
283
  const handles: string[] = [];
211
284
  if (typeof params?.handle === "string" && params.handle.trim()) handles.push(params.handle.trim());
212
285
  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());
213
- 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 };
286
+ if (!handles.length && grep !== undefined && grep.trim() !== "") {
287
+ try {
288
+ const branch = ctx.sessionManager.getBranch() as Any[];
289
+ const later = (int(offset) ?? 0) > 0; // a later page: search the pool its first page saw
290
+ const out = searchOutputs(branchOutputs(branch), grep, { offset, limit, upTo: later ? searchPoolOf(branch, grep) : undefined });
291
+ hooks.onRecall([], out.chars, []); // a search marks nothing as recalled: only what the model asks for by handle is kept unfolded
292
+ return { content: [{ type: "text", text: out.text }], details: { handles: [], search: grep, pool: out.pool, resolved: out.matched, missing: 0, sections: out.sections }, isError: false };
293
+ } catch (err) {
294
+ return { content: [{ type: "text", text: `${RECALL_TOOL} failed: ${err instanceof Error ? err.message : String(err)}` }], details: {}, isError: true };
295
+ }
296
+ }
297
+ if (!handles.length) return { content: [{ type: "text", text: "Pass handle (one string) or handles (array), copied exactly from a folded block's marker line or a summary; or grep alone to search every earlier tool output." }], details: {}, isError: true };
214
298
  try {
215
299
  const { items, entryIds } = resolveHandlesInBranch(ctx.sessionManager.getBranch() as Any[], handles);
216
300
  const out = recallSections(items, { grep, range, offset, limit });
@@ -0,0 +1,165 @@
1
+ // Recall through files (research pi-tricks §2). A tool allowlist (`pi --tools read,grep,bash`, sub-agent launchers) hides zip_recall,
2
+ // but read, grep or bash may still be there: a folded output then names a file those tools can read, and the file is written on
3
+ // demand, from the session, the moment a tool call names it. Placeholders say ~/.cache/pi-zip/recall/<session id>/<handle>.txt;
4
+ // a tool_call hook points any such path (any prefix: another machine, another user, a forked session) at this machine's copy.
5
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { isAbsolute, join, sep } from "node:path";
8
+
9
+ /** What placeholders and summaries name: portable, `~` is expanded by read, grep, find and ls, and by the shell. */
10
+ export const RECALL_DIR = "~/.cache/pi-zip/recall";
11
+ /** Copies older than this are deleted at session start; they can be written again from the session at any time. */
12
+ export const RECALL_MAX_AGE_MS = 14 * 24 * 3600_000;
13
+
14
+ /** Where the copies live on this machine: $XDG_CACHE_HOME/pi-zip/recall, else ~/.cache/pi-zip/recall. A relative XDG_CACHE_HOME is
15
+ * ignored, as the XDG spec says (it would put the copies in the project folder, where they could be committed). */
16
+ export const recallRoot = (): string => {
17
+ const xdg = process.env.XDG_CACHE_HOME;
18
+ return join(xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".cache"), "pi-zip", "recall");
19
+ };
20
+ /** recallRoot() as the user is shown it (state line, /zip card): the home directory as ~. */
21
+ export const recallRootShown = (): string => {
22
+ const r = recallRoot(), h = homedir();
23
+ return h && h !== sep && r.startsWith(h + sep) ? `~${r.slice(h.length)}` : r;
24
+ };
25
+
26
+ /** A session id as a directory name (Pi's ids are UUIDs; anything else is reduced to safe characters). */
27
+ export const sidDir = (sid: string): string => sid.replace(/[^0-9A-Za-z_-]/g, "_").slice(0, 80);
28
+
29
+ /** The tools that can read a recall file, as they are active in this session. */
30
+ export interface FileRecall {
31
+ dir: string; // what the model is told: RECALL_DIR/<session id>
32
+ read: boolean;
33
+ grep: boolean;
34
+ bash: boolean;
35
+ }
36
+
37
+ export const fileRecallFor = (sid: string, active: { read: boolean; grep: boolean; bash: boolean }): FileRecall => ({ dir: `${RECALL_DIR}/${sidDir(sid)}`, ...active });
38
+
39
+ /** How to read a recall file, naming only the tools that are active ("read it (offset/limit for pages) or grep it"). */
40
+ export function fileRecallHow(f: FileRecall): string {
41
+ const ways: string[] = [];
42
+ if (f.read) ways.push("read it (offset/limit for pages)");
43
+ else if (f.bash) ways.push("read it with bash (sed -n for pages)");
44
+ if (f.grep) ways.push("grep it");
45
+ else if (f.bash) ways.push("grep -n it with bash");
46
+ return ways.join(" or ");
47
+ }
48
+
49
+ const SID = "[0-9A-Za-z][0-9A-Za-z_-]{0,79}";
50
+ const SID_RE = new RegExp(`^${SID}$`);
51
+ /** What materialize writes in a session directory: <handle>.txt, and <handle>.txt.<pid>.tmp while writing. Cleanup deletes nothing else. */
52
+ const COPY_RE = /^[0-9a-z]{10}\.txt(\.\d+\.tmp)?$/;
53
+ const FILE_RE = new RegExp(`(?:^|/)pi-zip/recall/${SID}/([0-9a-z]{10})\\.txt$`);
54
+ const DIR_RE = new RegExp(`(?:^|/)pi-zip/recall/${SID}/?$`);
55
+
56
+ /** A path that names a recall file ({ handle }) or a session's recall directory ({}); null for anything else. */
57
+ export function parseRecallPath(p: unknown): { handle?: string } | null {
58
+ if (typeof p !== "string") return null;
59
+ const f = FILE_RE.exec(p);
60
+ if (f) return { handle: f[1] };
61
+ return DIR_RE.test(p) ? {} : null;
62
+ }
63
+
64
+ /** Recall paths in a shell command: a word that runs into pi-zip/recall/<session>, with /<handle>.txt or not (the directory, a glob in it).
65
+ * The path starts after any quote, operator, `=` (`X=<path>`, `--file=<path>`), `,`, `{` or `}` (brace expansion); `${HOME}` and the
66
+ * like are part of it. */
67
+ const CMD_RE = new RegExp(`(?:\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}|[^\\s'"\`<>|;&(){},=])*pi-zip/recall/${SID}(/[0-9a-z]{10}\\.txt\\b)?`, "g");
68
+ export const commandNamesRecall = (cmd: unknown): boolean => typeof cmd === "string" && new RegExp(CMD_RE.source).test(cmd);
69
+ /** The handles of the recall files a shell command names. */
70
+ export const commandRecallHandles = (cmd: unknown): string[] => (typeof cmd === "string" ? [...cmd.matchAll(CMD_RE)].filter((m) => m[1]).map((m) => m[1].slice(1, 11)) : []);
71
+
72
+ /** The command with every recall path pointed at `localDir`, the handles it names, and whether it names the directory itself.
73
+ * A local directory that would need shell quoting is not written in (the shell then reads ~/.cache, where the copies are when
74
+ * XDG_CACHE_HOME is unset). */
75
+ export function rewriteCommand(cmd: string, localDir: string): { command: string; handles: string[]; dir: boolean } {
76
+ const handles: string[] = [];
77
+ let dir = false;
78
+ const safe = /^[\w@%+=:,./-]+$/.test(localDir);
79
+ const command = cmd.replace(CMD_RE, (m: string, file?: string) => {
80
+ if (file) handles.push(file.slice(1, 11));
81
+ else dir = true;
82
+ return safe ? `${localDir}${file ?? ""}` : m;
83
+ });
84
+ return { command, handles, dir };
85
+ }
86
+
87
+ /** A copy on disk is the original only when it is a plain file holding exactly its bytes: a bash command may append to it or edit it
88
+ * in place (the rewrite points writes at it too), and write and edit are not hooked. */
89
+ function intact(file: string, bytes: Buffer): boolean {
90
+ try {
91
+ const st = lstatSync(file);
92
+ return st.isFile() && st.size === bytes.length && readFileSync(file).equals(bytes);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /** Write each original that is not on disk as it is (directory 0700, file 0600, written whole then renamed over anything there, a
99
+ * symlink included); this machine's path per handle. */
100
+ export function materialize(root: string, sid: string, items: { handle: string; text: string }[]): Map<string, string> {
101
+ const dir = join(root, sidDir(sid));
102
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
103
+ try {
104
+ chmodSync(dir, 0o700);
105
+ } catch {}
106
+ const out = new Map<string, string>();
107
+ for (const it of items) {
108
+ if (!/^[0-9a-z]{10}$/.test(it.handle)) continue;
109
+ const file = join(dir, `${it.handle}.txt`);
110
+ const bytes = Buffer.from(it.text, "utf8");
111
+ if (!intact(file, bytes)) {
112
+ const tmp = `${file}.${process.pid}.tmp`;
113
+ try {
114
+ unlinkSync(tmp); // a leftover (or a planted symlink): never written through
115
+ } catch {}
116
+ writeFileSync(tmp, bytes, { mode: 0o600, flag: "wx" });
117
+ renameSync(tmp, file);
118
+ }
119
+ out.set(it.handle, file);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Delete copies older than maxAgeMs and the session directories they leave empty. Returns how many files went. Only what materialize
125
+ * writes is touched: plain files named <handle>.txt (or its .tmp) in real directories named like a session id; a symlink, as a
126
+ * session directory or as a file, is never followed nor deleted. */
127
+ export function cleanupRecall(root: string = recallRoot(), now = Date.now(), maxAgeMs = RECALL_MAX_AGE_MS): number {
128
+ let removed = 0;
129
+ let sids: string[];
130
+ try {
131
+ sids = readdirSync(root);
132
+ } catch {
133
+ return 0;
134
+ }
135
+ for (const sid of sids) {
136
+ if (!SID_RE.test(sid)) continue;
137
+ const dir = join(root, sid);
138
+ let names: string[];
139
+ try {
140
+ if (!lstatSync(dir).isDirectory()) continue; // lstat: a symlinked directory is not a directory here
141
+ names = readdirSync(dir);
142
+ } catch {
143
+ continue;
144
+ }
145
+ let left = names.length;
146
+ for (const n of names) {
147
+ if (!COPY_RE.test(n)) continue;
148
+ try {
149
+ const f = join(dir, n);
150
+ const st = lstatSync(f);
151
+ if (st.isFile() && now - st.mtimeMs > maxAgeMs) {
152
+ unlinkSync(f);
153
+ removed++;
154
+ left--;
155
+ }
156
+ } catch {}
157
+ }
158
+ if (left === 0) {
159
+ try {
160
+ rmdirSync(dir);
161
+ } catch {}
162
+ }
163
+ }
164
+ return removed;
165
+ }