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/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(
|
|
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
|
-
/**
|
|
133
|
-
export function
|
|
134
|
-
const
|
|
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
|
-
|
|
149
|
-
|
|
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"
|
|
225
|
+
if (c?.type !== "toolCall") continue;
|
|
162
226
|
const a: Any = c.arguments ?? {};
|
|
163
|
-
if (
|
|
164
|
-
|
|
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
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"after later summaries or compaction
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
+
}
|