@pratikgajjar/pi-recall 0.1.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.
Files changed (3) hide show
  1. package/README.md +83 -0
  2. package/package.json +53 -0
  3. package/src/index.ts +522 -0
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # pi-recall
2
+
3
+ > A [pi](https://github.com/badlogic/pi-mono) extension that lets the agent search **your past AI chat history** — across Cursor, Claude Code, Codex, and pi — without you copy-pasting transcripts.
4
+
5
+ It's a thin wrapper over the [`recall`](https://github.com/pratikgajjar/recall) CLI, which indexes your conversations into a local SQLite FTS5 index. The extension shells out to that binary and exposes the index to the agent as tools.
6
+
7
+ ![recall_search running inside pi](./assets/demo.png)
8
+
9
+ _The agent calls `recall_search` to find a past conversation, then reads it in full with `recall_transcript` — no copy-paste._
10
+
11
+ ## Prerequisites
12
+
13
+ Install the `recall` CLI and build its index once:
14
+
15
+ ```bash
16
+ go install github.com/pratikgajjar/recall@latest
17
+ recall index # one-time, ~1 minute on real data
18
+ recall doctor # confirm sources are detected
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pi install npm:@pratikgajjar/pi-recall
25
+ ```
26
+
27
+ Or, for local development, drop it in `.pi/extensions/` or load it ad-hoc:
28
+
29
+ ```bash
30
+ pi -e ./packages/pi-recall/src/index.ts
31
+ ```
32
+
33
+ ## Tools
34
+
35
+ | Tool | What it does |
36
+ | --- | --- |
37
+ | `recall_search` | Full-text search over past sessions. Returns ranked hits with matched excerpts and a session id. |
38
+ | `recall_transcript` | Read a session in full — by `session_id`, or omit it for the most recent session (filterable by repo/source/since). |
39
+ | `recall_sessions` | List recent sessions (titles + ids, no bodies). |
40
+ | `recall_related` | Given a session id, find other sessions on the same topic. |
41
+
42
+ All tools accept `repo` (pass `"."` for the current project), `source` (`cursor` \| `claude` \| `codex` \| `pi`), and `since` (e.g. `7d`).
43
+
44
+ ### Recommended agent prompt
45
+
46
+ Drop into your project's `AGENTS.md` / `CLAUDE.md`:
47
+
48
+ ```markdown
49
+ When the user refers to earlier work ("how did we fix…", "continue the…"),
50
+ use the recall tools to find and read the relevant past AI session first.
51
+ ```
52
+
53
+ ## Staying fresh
54
+
55
+ Because the extension is a long-lived process, it keeps the index warm in the
56
+ background so searches always reflect your latest conversations — you never pay
57
+ an index rebuild on the query path:
58
+
59
+ - On **session start** it runs an incremental `recall index` to catch up on
60
+ anything that changed since your last pi session.
61
+ - After **each agent turn** it debounces a background refresh, so the session
62
+ you're in right now is searchable moments later.
63
+
64
+ The incremental index is append-only (it reads just the new lines of changed
65
+ session files), so each refresh is typically tens of milliseconds. Disable it
66
+ with `--recall-auto-index=false` or `RECALL_AUTO_INDEX=0` and refresh manually
67
+ via `/recall-index`.
68
+
69
+ ## Commands
70
+
71
+ - `/recall-health` — runs `recall doctor` (CLI status + detected sources).
72
+ - `/recall-index` — rebuilds the index (`recall index`; pass `--full` for a full rebuild).
73
+
74
+ ## Configuration
75
+
76
+ | | |
77
+ | --- | --- |
78
+ | `--recall-bin PATH` flag / `RECALL_BIN` env | Path to the `recall` binary. Default: `recall` on `PATH`. |
79
+ | `--recall-auto-index=false` flag / `RECALL_AUTO_INDEX=0` env | Turn off the background index refresh (on by default). |
80
+
81
+ ## License
82
+
83
+ MIT.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@pratikgajjar/pi-recall",
3
+ "version": "0.1.0",
4
+ "description": "pi extension: search your past AI chat history (Cursor, Claude Code, Codex, pi) via the recall CLI",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "pratikgajjar",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pratikgajjar/recall.git",
11
+ "directory": "packages/pi-recall"
12
+ },
13
+ "homepage": "https://github.com/pratikgajjar/recall/tree/main/packages/pi-recall",
14
+ "bugs": {
15
+ "url": "https://github.com/pratikgajjar/recall/issues"
16
+ },
17
+ "keywords": [
18
+ "pi",
19
+ "pi-package",
20
+ "pi-extension",
21
+ "recall",
22
+ "ai-agent",
23
+ "chat-history",
24
+ "search",
25
+ "cursor",
26
+ "claude-code",
27
+ "codex"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ],
33
+ "image": "https://raw.githubusercontent.com/pratikgajjar/recall/main/packages/pi-recall/assets/demo.png"
34
+ },
35
+ "files": [
36
+ "src"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*",
47
+ "typebox": "*"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.0.0"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * pi-recall: search your past AI chat history from inside pi.
3
+ *
4
+ * Shells out to the `recall` Go CLI (https://github.com/pratikgajjar/recall),
5
+ * which indexes Cursor / Claude Code / Codex / pi conversations into a local
6
+ * SQLite FTS5 index. This extension surfaces that index to the agent as tools
7
+ * so it can recall prior work without you copy-pasting transcripts.
8
+ */
9
+
10
+ import { execFile } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+
16
+ const DEFAULT_SEARCH_LIMIT = 15;
17
+ const DEFAULT_SESSIONS_LIMIT = 20;
18
+ const DEFAULT_RELATED_LIMIT = 10;
19
+ const TRANSCRIPT_MAX_LINES = 500;
20
+ const EXEC_MAX_BUFFER = 32 * 1024 * 1024; // transcripts can be large
21
+
22
+ const SOURCE_HELP = "Restrict to one tool: cursor | claude | codex | pi.";
23
+ const REPO_HELP =
24
+ "Restrict to a project folder. Pass '.' for the current working directory.";
25
+ const SINCE_HELP = "Only sessions newer than this, e.g. '24h', '7d', '30d'.";
26
+
27
+ interface RecallHit {
28
+ session_id: string;
29
+ source: string;
30
+ source_id: string;
31
+ project: string;
32
+ title: string;
33
+ started_at_ms: number;
34
+ msg_idx: number;
35
+ role: string;
36
+ snippet: string;
37
+ rank: number;
38
+ }
39
+
40
+ interface RunResult {
41
+ ok: boolean;
42
+ stdout: string;
43
+ stderr: string;
44
+ code: number | null;
45
+ }
46
+
47
+ const REFRESH_DEBOUNCE_MS = 1500;
48
+
49
+ export default function recallExtension(pi: ExtensionAPI) {
50
+ let activeCwd = process.cwd();
51
+ let warnedMissing = false;
52
+
53
+ // Background-refresh state. A pi extension is a long-lived process, so we
54
+ // keep the recall index warm out-of-band (on session start + after each
55
+ // agent turn) instead of paying an incremental rebuild on the query path.
56
+ let recallAvailable = false;
57
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined;
58
+ let refreshing = false;
59
+
60
+ function autoIndexEnabled(): boolean {
61
+ const flag = pi.getFlag("recall-auto-index") as boolean | undefined;
62
+ if (flag === false) return false;
63
+ const env = process.env.RECALL_AUTO_INDEX;
64
+ if (env === "0" || env === "false" || env === "off") return false;
65
+ return true;
66
+ }
67
+
68
+ async function runRefresh(): Promise<void> {
69
+ refreshTimer = undefined;
70
+ if (!recallAvailable || !autoIndexEnabled()) return;
71
+ if (refreshing) {
72
+ scheduleRefresh(500); // one already in flight — retry shortly
73
+ return;
74
+ }
75
+ refreshing = true;
76
+ try {
77
+ await runRecall(["index"]);
78
+ } catch {
79
+ // best-effort; a failed background refresh just leaves the index as-is
80
+ } finally {
81
+ refreshing = false;
82
+ }
83
+ }
84
+
85
+ function scheduleRefresh(delayMs = REFRESH_DEBOUNCE_MS): void {
86
+ if (!recallAvailable || !autoIndexEnabled()) return;
87
+ if (refreshTimer) clearTimeout(refreshTimer);
88
+ refreshTimer = setTimeout(() => void runRefresh(), delayMs);
89
+ }
90
+
91
+ function resolveBin(): string {
92
+ return (
93
+ (pi.getFlag("recall-bin") as string | undefined) ??
94
+ process.env.RECALL_BIN ??
95
+ "recall"
96
+ );
97
+ }
98
+
99
+ function runRecall(args: string[], signal?: AbortSignal): Promise<RunResult> {
100
+ const bin = resolveBin();
101
+ return new Promise((resolve) => {
102
+ execFile(
103
+ bin,
104
+ args,
105
+ { maxBuffer: EXEC_MAX_BUFFER, signal },
106
+ (err, stdout, stderr) => {
107
+ if (err && (err as NodeJS.ErrnoException).code === "ENOENT") {
108
+ resolve({
109
+ ok: false,
110
+ stdout: "",
111
+ stderr: `recall binary not found ('${bin}'). Install it: go install github.com/pratikgajjar/recall@latest`,
112
+ code: 127,
113
+ });
114
+ return;
115
+ }
116
+ const code =
117
+ err && typeof (err as { code?: unknown }).code === "number"
118
+ ? ((err as { code: number }).code as number)
119
+ : err
120
+ ? 1
121
+ : 0;
122
+ resolve({
123
+ ok: !err,
124
+ stdout: stdout ?? "",
125
+ stderr: (stderr ?? "").trim(),
126
+ code,
127
+ });
128
+ },
129
+ );
130
+ });
131
+ }
132
+
133
+ // --- formatting helpers ---
134
+
135
+ function shortenPath(p: string): string {
136
+ if (!p) return "";
137
+ const home = homedir();
138
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
139
+ }
140
+
141
+ function fmtDate(ms: number): string {
142
+ if (!ms) return "";
143
+ const d = new Date(ms);
144
+ const pad = (n: number) => String(n).padStart(2, "0");
145
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
146
+ }
147
+
148
+ function resolveRepo(repo?: string): string | undefined {
149
+ if (!repo) return undefined;
150
+ return repo === "." ? activeCwd : repo;
151
+ }
152
+
153
+ function buildFilterArgs(params: {
154
+ repo?: string;
155
+ source?: string;
156
+ since?: string;
157
+ limit?: number;
158
+ }): string[] {
159
+ const args: string[] = [];
160
+ const repo = resolveRepo(params.repo);
161
+ if (repo) args.push("--repo", repo);
162
+ if (params.source) args.push("--source", params.source);
163
+ if (params.since) args.push("--since", params.since);
164
+ if (params.limit !== undefined)
165
+ args.push("--limit", String(Math.max(1, params.limit)));
166
+ return args;
167
+ }
168
+
169
+ function parseHits(stdout: string): RecallHit[] {
170
+ const trimmed = stdout.trim();
171
+ if (!trimmed || trimmed === "null") return [];
172
+ try {
173
+ const parsed = JSON.parse(trimmed);
174
+ return Array.isArray(parsed) ? (parsed as RecallHit[]) : [];
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ function formatHits(hits: RecallHit[], showSnippet: boolean): string {
181
+ if (hits.length === 0) return "No matching sessions found.";
182
+ const blocks = hits.map((h) => {
183
+ const head = [fmtDate(h.started_at_ms), h.source, shortenPath(h.project)]
184
+ .filter(Boolean)
185
+ .join(" ");
186
+ const lines = [head, ` ${h.title || "(untitled)"} id=${h.session_id}`];
187
+ if (showSnippet && h.snippet) lines.push(` ${h.snippet.trim()}`);
188
+ return lines.join("\n");
189
+ });
190
+ return blocks.join("\n\n");
191
+ }
192
+
193
+ function capTranscript(text: string): string {
194
+ const lines = text.split("\n");
195
+ if (lines.length <= TRANSCRIPT_MAX_LINES) return text;
196
+ const shown = lines.slice(0, TRANSCRIPT_MAX_LINES).join("\n");
197
+ const more = lines.length - TRANSCRIPT_MAX_LINES;
198
+ return `${shown}\n\n[truncated — ${more} more lines. Open the full session in its tool with: recall open <id>]`;
199
+ }
200
+
201
+ // --- lifecycle ---
202
+
203
+ pi.registerFlag("recall-bin", {
204
+ description: "Path to the recall binary (overrides RECALL_BIN env; default: 'recall' on PATH)",
205
+ type: "string",
206
+ });
207
+
208
+ pi.registerFlag("recall-auto-index", {
209
+ description:
210
+ "Keep the recall index fresh in the background (session start + after each turn). Default: on. Disable with --recall-auto-index=false or RECALL_AUTO_INDEX=0.",
211
+ type: "boolean",
212
+ });
213
+
214
+ pi.on("session_start", async (_event, ctx) => {
215
+ activeCwd = ctx.cwd;
216
+ const res = await runRecall(["version"]);
217
+ recallAvailable = res.ok;
218
+ if (!res.ok && !warnedMissing) {
219
+ warnedMissing = true;
220
+ ctx.ui.notify(
221
+ `pi-recall: ${res.stderr || "recall CLI not available"}`,
222
+ "warning",
223
+ );
224
+ }
225
+ // Catch up on anything that changed since the last pi session. The
226
+ // incremental index is append-only, so this is cheap (~tens of ms).
227
+ if (recallAvailable) scheduleRefresh(0);
228
+ });
229
+
230
+ // After each agent response the current session's transcript has grown.
231
+ // Debounce a background refresh so the index reflects it before the next
232
+ // recall_search — the only file actively changing is this one, and we know
233
+ // exactly when it does. Tool calls then read an already-fresh index.
234
+ pi.on("agent_end", async () => {
235
+ scheduleRefresh();
236
+ });
237
+
238
+ pi.on("session_shutdown", async () => {
239
+ if (refreshTimer) {
240
+ clearTimeout(refreshTimer);
241
+ refreshTimer = undefined;
242
+ }
243
+ });
244
+
245
+ // --- shared render helpers ---
246
+
247
+ const renderTextResult = (
248
+ result: { content?: { type: string; text?: string }[] },
249
+ options: { expanded?: boolean },
250
+ theme: any,
251
+ context: any,
252
+ maxLines = 15,
253
+ ) => {
254
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
255
+ const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
256
+ if (!output) {
257
+ text.setText(theme.fg("muted", "No output"));
258
+ return text;
259
+ }
260
+ const lines = output.split("\n");
261
+ const display = lines.slice(0, options.expanded ? lines.length : maxLines);
262
+ let content = `\n${display.map((l: string) => theme.fg("toolOutput", l)).join("\n")}`;
263
+ if (lines.length > display.length)
264
+ content += theme.fg("muted", `\n... (${lines.length - display.length} more lines)`);
265
+ text.setText(content);
266
+ return text;
267
+ };
268
+
269
+ // --- recall_search ---
270
+
271
+ const searchSchema = Type.Object({
272
+ query: Type.String({
273
+ description:
274
+ "Full-text search over your past AI conversations (titles + message excerpts). Use concrete identifiers, error strings, or feature names.",
275
+ }),
276
+ repo: Type.Optional(Type.String({ description: REPO_HELP })),
277
+ source: Type.Optional(Type.String({ description: SOURCE_HELP })),
278
+ since: Type.Optional(Type.String({ description: SINCE_HELP })),
279
+ limit: Type.Optional(
280
+ Type.Number({ description: `Max hits (default ${DEFAULT_SEARCH_LIMIT})` }),
281
+ ),
282
+ });
283
+
284
+ pi.registerTool({
285
+ name: "recall_search",
286
+ label: "recall search",
287
+ description:
288
+ "Search your own past AI chat history across Cursor, Claude Code, Codex, and pi. Returns ranked sessions with matched excerpts and a session id. Use recall_transcript to read a hit in full.",
289
+ promptSnippet: "Search past AI conversations across tools",
290
+ promptGuidelines: [
291
+ "Use recall_search when the user references earlier work ('how did we fix…', 'what did I decide about…', 'continue the…') that may live in a prior chat.",
292
+ "Pass repo: '.' to scope recall_search to the current project.",
293
+ "After recall_search, call recall_transcript with the returned id to read the full session before acting.",
294
+ ],
295
+ parameters: searchSchema,
296
+
297
+ async execute(_id, params, signal) {
298
+ const args = ["find", params.query, "--json", ...buildFilterArgs({
299
+ repo: params.repo,
300
+ source: params.source,
301
+ since: params.since,
302
+ limit: params.limit ?? DEFAULT_SEARCH_LIMIT,
303
+ })];
304
+ const res = await runRecall(args, signal);
305
+ if (!res.ok) throw new Error(res.stderr || `recall exited with code ${res.code}`);
306
+ const hits = parseHits(res.stdout);
307
+ return {
308
+ content: [{ type: "text", text: formatHits(hits, true) }],
309
+ details: { count: hits.length },
310
+ };
311
+ },
312
+
313
+ renderCall(args, theme, context) {
314
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
315
+ let c =
316
+ theme.fg("toolTitle", theme.bold("recall search")) +
317
+ " " +
318
+ theme.fg("accent", `"${args?.query ?? ""}"`);
319
+ if (args?.repo) c += theme.fg("toolOutput", ` in ${args.repo}`);
320
+ if (args?.source) c += theme.fg("muted", ` [${args.source}]`);
321
+ text.setText(c);
322
+ return text;
323
+ },
324
+ renderResult(result, options, theme, context) {
325
+ return renderTextResult(result, options, theme, context, 15);
326
+ },
327
+ });
328
+
329
+ // --- recall_transcript ---
330
+
331
+ const transcriptSchema = Type.Object({
332
+ session_id: Type.Optional(
333
+ Type.String({
334
+ description:
335
+ "Session id from recall_search/recall_sessions (e.g. 'cursor:…', 'pi:…'). Omit to fetch the most recent session matching the filters below.",
336
+ }),
337
+ ),
338
+ repo: Type.Optional(Type.String({ description: REPO_HELP })),
339
+ source: Type.Optional(Type.String({ description: SOURCE_HELP })),
340
+ since: Type.Optional(Type.String({ description: SINCE_HELP })),
341
+ });
342
+
343
+ pi.registerTool({
344
+ name: "recall_transcript",
345
+ label: "recall transcript",
346
+ description:
347
+ "Read a past AI session as a full transcript. Pass a session_id from recall_search, or omit it to get the most recent session (optionally filtered by repo/source/since).",
348
+ promptSnippet: "Read a past AI session transcript",
349
+ promptGuidelines: [
350
+ "Call recall_transcript after recall_search to read a specific session before reusing its decisions.",
351
+ "Omit session_id with repo: '.' to pull the most recent conversation in this project.",
352
+ ],
353
+ parameters: transcriptSchema,
354
+
355
+ async execute(_id, params, signal) {
356
+ const args = params.session_id
357
+ ? ["show", params.session_id]
358
+ : ["last", ...buildFilterArgs({
359
+ repo: params.repo,
360
+ source: params.source,
361
+ since: params.since,
362
+ })];
363
+ const res = await runRecall(args, signal);
364
+ if (!res.ok) {
365
+ const msg = res.stderr || res.stdout.trim() || `recall exited with code ${res.code}`;
366
+ return {
367
+ content: [{ type: "text", text: msg }],
368
+ details: { found: false },
369
+ isError: true,
370
+ };
371
+ }
372
+ return {
373
+ content: [{ type: "text", text: capTranscript(res.stdout.trim()) }],
374
+ details: { found: true },
375
+ };
376
+ },
377
+
378
+ renderCall(args, theme, context) {
379
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
380
+ const target = args?.session_id ?? (args?.repo ? `last in ${args.repo}` : "last");
381
+ text.setText(
382
+ theme.fg("toolTitle", theme.bold("recall transcript")) +
383
+ " " +
384
+ theme.fg("accent", target),
385
+ );
386
+ return text;
387
+ },
388
+ renderResult(result, options, theme, context) {
389
+ return renderTextResult(result, options, theme, context, 20);
390
+ },
391
+ });
392
+
393
+ // --- recall_sessions ---
394
+
395
+ const sessionsSchema = Type.Object({
396
+ repo: Type.Optional(Type.String({ description: REPO_HELP })),
397
+ source: Type.Optional(Type.String({ description: SOURCE_HELP })),
398
+ since: Type.Optional(Type.String({ description: SINCE_HELP })),
399
+ limit: Type.Optional(
400
+ Type.Number({ description: `Max sessions (default ${DEFAULT_SESSIONS_LIMIT})` }),
401
+ ),
402
+ });
403
+
404
+ pi.registerTool({
405
+ name: "recall_sessions",
406
+ label: "recall sessions",
407
+ description:
408
+ "List recent past AI sessions (titles + ids, no bodies). Filter by repo/source/since. Use to browse what you've worked on, then recall_transcript to open one.",
409
+ promptSnippet: "List recent past AI sessions",
410
+ promptGuidelines: [
411
+ "Use recall_sessions with repo: '.' to see recent prior conversations in the current project.",
412
+ ],
413
+ parameters: sessionsSchema,
414
+
415
+ async execute(_id, params, signal) {
416
+ const args = ["sessions", "--json", ...buildFilterArgs({
417
+ repo: params.repo,
418
+ source: params.source,
419
+ since: params.since,
420
+ limit: params.limit ?? DEFAULT_SESSIONS_LIMIT,
421
+ })];
422
+ const res = await runRecall(args, signal);
423
+ if (!res.ok) throw new Error(res.stderr || `recall exited with code ${res.code}`);
424
+ const hits = parseHits(res.stdout);
425
+ return {
426
+ content: [{ type: "text", text: formatHits(hits, false) }],
427
+ details: { count: hits.length },
428
+ };
429
+ },
430
+
431
+ renderCall(args, theme, context) {
432
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
433
+ let c = theme.fg("toolTitle", theme.bold("recall sessions"));
434
+ if (args?.repo) c += theme.fg("toolOutput", ` in ${args.repo}`);
435
+ if (args?.source) c += theme.fg("muted", ` [${args.source}]`);
436
+ text.setText(c);
437
+ return text;
438
+ },
439
+ renderResult(result, options, theme, context) {
440
+ return renderTextResult(result, options, theme, context, 20);
441
+ },
442
+ });
443
+
444
+ // --- recall_related ---
445
+
446
+ const relatedSchema = Type.Object({
447
+ session_id: Type.String({
448
+ description: "Session id to find topically-similar sessions for.",
449
+ }),
450
+ limit: Type.Optional(
451
+ Type.Number({ description: `Max neighbours (default ${DEFAULT_RELATED_LIMIT})` }),
452
+ ),
453
+ });
454
+
455
+ pi.registerTool({
456
+ name: "recall_related",
457
+ label: "recall related",
458
+ description:
459
+ "Given a session id, find other past sessions covering the same topic. Useful to gather all prior work on a problem.",
460
+ promptSnippet: "Find sessions related to a given one",
461
+ promptGuidelines: [
462
+ "Use recall_related after recall_search to widen context to neighbouring conversations on the same topic.",
463
+ ],
464
+ parameters: relatedSchema,
465
+
466
+ async execute(_id, params, signal) {
467
+ const args = [
468
+ "related",
469
+ params.session_id,
470
+ "--json",
471
+ "--limit",
472
+ String(Math.max(1, params.limit ?? DEFAULT_RELATED_LIMIT)),
473
+ ];
474
+ const res = await runRecall(args, signal);
475
+ if (!res.ok) throw new Error(res.stderr || `recall exited with code ${res.code}`);
476
+ const hits = parseHits(res.stdout);
477
+ return {
478
+ content: [{ type: "text", text: formatHits(hits, true) }],
479
+ details: { count: hits.length },
480
+ };
481
+ },
482
+
483
+ renderCall(args, theme, context) {
484
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
485
+ text.setText(
486
+ theme.fg("toolTitle", theme.bold("recall related")) +
487
+ " " +
488
+ theme.fg("accent", args?.session_id ?? ""),
489
+ );
490
+ return text;
491
+ },
492
+ renderResult(result, options, theme, context) {
493
+ return renderTextResult(result, options, theme, context, 15);
494
+ },
495
+ });
496
+
497
+ // --- commands ---
498
+
499
+ pi.registerCommand("recall-health", {
500
+ description: "Show recall CLI health and detected sources (recall doctor)",
501
+ handler: async (_args, ctx) => {
502
+ const res = await runRecall(["doctor"]);
503
+ ctx.ui.notify(
504
+ res.ok ? res.stdout.trim() : `recall doctor failed: ${res.stderr}`,
505
+ res.ok ? "info" : "error",
506
+ );
507
+ },
508
+ });
509
+
510
+ pi.registerCommand("recall-index", {
511
+ description: "Rebuild the recall index from all sources (recall index)",
512
+ handler: async (args, ctx) => {
513
+ ctx.ui.notify("recall: indexing…", "info");
514
+ const indexArgs = (args || "").trim() === "--full" ? ["index", "--full"] : ["index"];
515
+ const res = await runRecall(indexArgs);
516
+ ctx.ui.notify(
517
+ res.ok ? res.stdout.trim() || "recall index complete" : `recall index failed: ${res.stderr}`,
518
+ res.ok ? "info" : "error",
519
+ );
520
+ },
521
+ });
522
+ }