@ryan_nookpi/pi-extension-memory-layer 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.
package/index.ts ADDED
@@ -0,0 +1,698 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { copyToClipboard } from "@mariozechner/pi-coding-agent";
3
+ import { Text } from "@mariozechner/pi-tui";
4
+
5
+ import { buildMemoryPrompt } from "./inject.ts";
6
+ import { resolveProjectId } from "./project-id.ts";
7
+ import {
8
+ ensureDir,
9
+ findMemoryById,
10
+ listTopics,
11
+ loadTopicEntries,
12
+ memoryEntryId,
13
+ memoryExistsInScope,
14
+ migrateFromJson,
15
+ readMemoryMd,
16
+ readTopicFile,
17
+ removeMemory,
18
+ type SearchResult,
19
+ sanitizeTopic,
20
+ saveMemory,
21
+ searchMemories,
22
+ } from "./storage.ts";
23
+ import type { MemoryScope } from "./types.ts";
24
+ import { ForgetParams, MemoryListParams, RecallParams, RememberParams } from "./types.ts";
25
+ import {
26
+ MemoryActionMenuComponent,
27
+ MemoryDeleteConfirmComponent,
28
+ MemoryDetailOverlayComponent,
29
+ type MemoryMenuAction,
30
+ MemorySelectorComponent,
31
+ } from "./ui.ts";
32
+
33
+ function resolveCurrentProjectId(cwd: string): string | undefined {
34
+ try {
35
+ return resolveProjectId(cwd).id;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ function truncateTitle(content: string, maxLen = 60): string {
42
+ const firstLine = content.split("\n")[0]?.trim() ?? content.trim();
43
+ if (firstLine.length <= maxLen) return firstLine;
44
+ return `${firstLine.slice(0, maxLen - 1)}…`;
45
+ }
46
+
47
+ /** Convert slug to a human-readable heading. */
48
+ function slugToHeading(slug: string): string {
49
+ return slug
50
+ .split("-")
51
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
52
+ .join(" ");
53
+ }
54
+
55
+ /**
56
+ * Normalize topic input from tool callers.
57
+ * Accepts both `general` and `general.md` and always returns a safe slug.
58
+ */
59
+ function normalizeTopicInput(topic: string): string {
60
+ const trimmed = topic.trim();
61
+ const withoutMd = trimmed.replace(/\.md$/i, "").trim();
62
+ return sanitizeTopic(withoutMd);
63
+ }
64
+
65
+ async function promptTopic(
66
+ ctx: ExtensionContext,
67
+ scope: MemoryScope,
68
+ projectId: string | undefined,
69
+ ): Promise<{ slug: string; heading: string } | null> {
70
+ const existing = await listTopics(scope, projectId);
71
+ const options = [...existing, "πŸ“ μƒˆ 주제 λ§Œλ“€κΈ°", "μ·¨μ†Œ"];
72
+ const choice = await ctx.ui.select("주제λ₯Ό μ„ νƒν•˜μ„Έμš”:", options);
73
+ if (!choice || choice === "μ·¨μ†Œ") return null;
74
+
75
+ if (choice !== "πŸ“ μƒˆ 주제 λ§Œλ“€κΈ°") {
76
+ return { slug: choice, heading: slugToHeading(choice) };
77
+ }
78
+
79
+ const name = await ctx.ui.input("μƒˆ 주제 이름 (영문 slug λ˜λŠ” ν•œκΈ€):");
80
+ if (!name?.trim()) return null;
81
+ try {
82
+ const trimmedName = name.trim();
83
+ return { slug: sanitizeTopic(trimmedName), heading: trimmedName };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function parseRememberArgs(raw: string): { scope: MemoryScope; content: string } {
90
+ const scopeMatch = raw.match(/^(user|project)\s+([\s\S]+)$/);
91
+ if (scopeMatch) {
92
+ return { scope: scopeMatch[1] as MemoryScope, content: scopeMatch[2].trim() };
93
+ }
94
+ return { scope: "project", content: raw };
95
+ }
96
+
97
+ function buildTextResult(text: string) {
98
+ return { content: [{ type: "text" as const, text }], details: undefined };
99
+ }
100
+
101
+ async function openMemoryDetail(ctx: ExtensionContext, entry: SearchResult): Promise<void> {
102
+ await ctx.ui.custom<void>(
103
+ (tui, theme, _kb, done) => new MemoryDetailOverlayComponent(tui, theme, entry, () => done()),
104
+ { overlay: true, overlayOptions: { width: "80%", maxHeight: "80%", anchor: "center" } },
105
+ );
106
+ }
107
+
108
+ async function openMemoryTopicDetail(ctx: ExtensionContext, entry: SearchResult): Promise<void> {
109
+ const fullTopic = await readTopicFile(entry.scope, entry.projectId, entry.topic);
110
+ await openMemoryDetail(ctx, {
111
+ ...entry,
112
+ title: `πŸ“ ${entry.topic}.md (full)`,
113
+ content: fullTopic || "(empty)",
114
+ });
115
+ }
116
+
117
+ function copyMemoryEntry(ctx: ExtensionContext, entry: SearchResult): void {
118
+ try {
119
+ copyToClipboard(`${entry.title}\n\n${entry.content}`);
120
+ ctx.ui.notify("Copied to clipboard", "info");
121
+ } catch (e) {
122
+ ctx.ui.notify(`Copy failed: ${e instanceof Error ? e.message : "unknown"}`, "error");
123
+ }
124
+ }
125
+
126
+ function throwIfProjectScopeInvalid(projectId: string | undefined, scope: MemoryScope | undefined, action: string) {
127
+ if (scope === "project" && !projectId) {
128
+ throw new Error(`project scope ${action} requires project context (projectId not resolved)`);
129
+ }
130
+ }
131
+
132
+ async function executeRecallById(id: string, projectId: string | undefined) {
133
+ const entry = await findMemoryById(id, projectId);
134
+ if (!entry) {
135
+ throw new Error(`Memory not found with id: ${id}`);
136
+ }
137
+ return buildTextResult(`[${entry.scope}] ${entry.topic}/${entry.title}\n\n${entry.content}`);
138
+ }
139
+
140
+ async function executeRecallQuery(query: string, projectId: string | undefined, scope?: MemoryScope) {
141
+ let results = await searchMemories(query, projectId);
142
+ if (scope) {
143
+ results = results.filter((r) => r.scope === scope);
144
+ }
145
+ if (results.length === 0) {
146
+ return buildTextResult("No matching memories found.");
147
+ }
148
+ const maxResults = 20;
149
+ const lines = results.slice(0, maxResults).map((r) => {
150
+ const id = memoryEntryId(r.scope, r.projectId, r.topic, r.title, r.content);
151
+ const firstLine = r.content.split("\n")[0] ?? "";
152
+ const snippet = firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine;
153
+ return `- [${id}] [${r.scope}] ${r.topic}/${r.title}${snippet ? `\n ${snippet}` : ""}`;
154
+ });
155
+ const shown = Math.min(results.length, maxResults);
156
+ const header =
157
+ results.length > shown
158
+ ? `Found ${results.length} memories (showing top ${shown}):`
159
+ : `Found ${results.length} memories:`;
160
+ return buildTextResult(`${header}\n\n${lines.join("\n")}\n\nUse recall with id to view full content.`);
161
+ }
162
+
163
+ async function executeRecallIndex(projectId: string | undefined, scope?: MemoryScope) {
164
+ const parts: string[] = [];
165
+ if (!scope || scope === "user") {
166
+ const userIndex = (await readMemoryMd("user")).trim();
167
+ if (userIndex) parts.push(userIndex);
168
+ }
169
+ if ((!scope || scope === "project") && projectId) {
170
+ const projectIndex = (await readMemoryMd("project", projectId)).trim();
171
+ if (projectIndex) parts.push(projectIndex);
172
+ }
173
+ return buildTextResult(parts.filter(Boolean).join("\n\n") || "No memories stored.");
174
+ }
175
+
176
+ function normalizeForgetTitle(title: string) {
177
+ const normalizedTitle = title?.trim();
178
+ return normalizedTitle ? normalizedTitle : null;
179
+ }
180
+
181
+ async function executeForgetTopic(
182
+ topic: string,
183
+ normalizedTitle: string,
184
+ scope: MemoryScope | undefined,
185
+ currentProjectId: string | undefined,
186
+ ) {
187
+ let normalizedTopic: string;
188
+ try {
189
+ normalizedTopic = normalizeTopicInput(topic);
190
+ } catch {
191
+ throw new Error(`Invalid topic: ${topic}`);
192
+ }
193
+
194
+ if (scope) {
195
+ const pid = scope === "project" ? currentProjectId : undefined;
196
+ const removed = await removeMemory(scope, pid, normalizedTopic, normalizedTitle);
197
+ if (!removed) {
198
+ throw new Error(`Memory not found in ${scope} scope: ${normalizedTopic} / "${normalizedTitle}"`);
199
+ }
200
+ return buildTextResult(`Deleted from ${scope}: ${normalizedTopic} / "${normalizedTitle}"`);
201
+ }
202
+
203
+ const existsInUser = await memoryExistsInScope("user", undefined, normalizedTopic, normalizedTitle);
204
+ const existsInProject = currentProjectId
205
+ ? await memoryExistsInScope("project", currentProjectId, normalizedTopic, normalizedTitle)
206
+ : false;
207
+
208
+ if (existsInUser && existsInProject) {
209
+ throw new Error(
210
+ `Ambiguous: "${normalizedTitle}" exists in both user and project scopes for topic "${normalizedTopic}". Specify scope parameter: scope="user" or scope="project" to resolve.`,
211
+ );
212
+ }
213
+ if (!existsInUser && !existsInProject) {
214
+ throw new Error(`Memory not found: ${normalizedTopic} / "${normalizedTitle}"`);
215
+ }
216
+
217
+ const targetScope: MemoryScope = existsInUser ? "user" : "project";
218
+ const pid = targetScope === "project" ? currentProjectId : undefined;
219
+ const removed = await removeMemory(targetScope, pid, normalizedTopic, normalizedTitle);
220
+ if (!removed) {
221
+ throw new Error(`Memory not found: ${normalizedTopic} / "${normalizedTitle}"`);
222
+ }
223
+ return buildTextResult(`Deleted from ${targetScope}: ${normalizedTopic} / "${normalizedTitle}"`);
224
+ }
225
+
226
+ async function executeForgetByTitle(
227
+ normalizedTitle: string,
228
+ scope: MemoryScope | undefined,
229
+ currentProjectId: string | undefined,
230
+ ) {
231
+ const entries = await collectDisplayEntries(currentProjectId);
232
+ const scopedEntries = scope ? entries.filter((entry) => entry.scope === scope) : entries;
233
+
234
+ let matches = scopedEntries.filter((entry) => entry.title === normalizedTitle);
235
+ let caseInsensitive = false;
236
+ if (matches.length === 0) {
237
+ const lower = normalizedTitle.toLowerCase();
238
+ matches = scopedEntries.filter((entry) => entry.title.toLowerCase() === lower);
239
+ caseInsensitive = matches.length > 0;
240
+ }
241
+
242
+ if (matches.length === 0) {
243
+ throw new Error(
244
+ `Memory not found by title: "${normalizedTitle}".\nTip: provide topic as well (e.g. topic: 'general' or 'general.md') for precise deletion.`,
245
+ );
246
+ }
247
+ if (matches.length > 1) {
248
+ const preview = matches
249
+ .slice(0, 6)
250
+ .map((entry) => `- [${entry.scope}] ${entry.topic} / "${entry.title}"`)
251
+ .join("\n");
252
+ const more = matches.length > 6 ? `\n... and ${matches.length - 6} more` : "";
253
+ throw new Error(
254
+ `Ambiguous title: "${normalizedTitle}" matches ${matches.length} memories.\nSpecify topic (and scope if needed) to delete safely.\n\n${preview}${more}`,
255
+ );
256
+ }
257
+
258
+ const target = matches[0];
259
+ const removed = await removeMemory(target.scope, target.projectId, target.topic, target.title);
260
+ if (!removed) {
261
+ throw new Error(`Memory not found: ${target.topic} / "${target.title}"`);
262
+ }
263
+ const caseMatchNote = caseInsensitive ? ` (matched title: "${target.title}")` : "";
264
+ return buildTextResult(`Deleted from ${target.scope}: ${target.topic} / "${target.title}"${caseMatchNote}`);
265
+ }
266
+
267
+ // ── Extension Entry Point ────────────────────────────────────────────────────
268
+
269
+ export default function memoryLayerExtension(pi: ExtensionAPI) {
270
+ let currentProjectId: string | undefined;
271
+ let migrationDone = false;
272
+
273
+ /**
274
+ * Core save logic shared by /remember command and remember tool.
275
+ *
276
+ * @param scope - Explicit storage scope ("user" | "project").
277
+ * @param interactive - If true (default), prompts for topic selection.
278
+ * If false, auto-selects "general" topic with no UI prompts.
279
+ */
280
+ async function saveContent(
281
+ content: string,
282
+ title: string | undefined,
283
+ scope: MemoryScope,
284
+ ctx: ExtensionContext,
285
+ interactive = true,
286
+ ): Promise<{ topic: string; title: string; scope: MemoryScope } | { cancelled: true } | { error: string }> {
287
+ try {
288
+ const displayTitle = title ?? truncateTitle(content);
289
+
290
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
291
+
292
+ // Fail-fast: project scope requires a resolved projectId
293
+ if (scope === "project" && !currentProjectId) {
294
+ return { error: "project scope memory requires project context (projectId not resolved)" };
295
+ }
296
+
297
+ let topicSlug: string;
298
+ let topicHeading: string;
299
+
300
+ if (interactive) {
301
+ // /remember command path: show topic selection UI
302
+ const topicChoice = await promptTopic(ctx, scope, scope === "project" ? currentProjectId : undefined);
303
+ if (!topicChoice) return { cancelled: true };
304
+ topicSlug = topicChoice.slug;
305
+ topicHeading = topicChoice.heading;
306
+ } else {
307
+ // remember tool path: auto-select "general" β€” NO UI prompts
308
+ topicSlug = "general";
309
+ topicHeading = "General";
310
+ }
311
+
312
+ await saveMemory(
313
+ scope,
314
+ scope === "project" ? currentProjectId : undefined,
315
+ topicSlug,
316
+ topicHeading,
317
+ displayTitle,
318
+ content,
319
+ );
320
+
321
+ return { topic: topicSlug, title: displayTitle, scope };
322
+ } catch (err: unknown) {
323
+ return { error: `μ €μž₯ μ‹€νŒ¨: ${err instanceof Error ? err.message : "unknown"}` };
324
+ }
325
+ }
326
+
327
+ // ── /remember Command ─────────────────────────────────────────────────
328
+
329
+ pi.registerCommand("remember", {
330
+ description: "Store a memory. Usage: /remember [user|project] <content>",
331
+ handler: async (args, ctx) => {
332
+ const raw = args.trim();
333
+ if (!raw) {
334
+ ctx.ui.notify("μ‚¬μš©λ²•: /remember [user|project] <κΈ°μ–΅ν•  λ‚΄μš©>", "warning");
335
+ return;
336
+ }
337
+ const { scope, content } = parseRememberArgs(raw);
338
+ const result = await saveContent(content, undefined, scope, ctx);
339
+ if ("cancelled" in result) {
340
+ ctx.ui.notify("κΈ°μ–΅ μ €μž₯을 μ·¨μ†Œν–ˆμŠ΅λ‹ˆλ‹€.", "info");
341
+ } else if ("error" in result) {
342
+ ctx.ui.notify(result.error, "error");
343
+ } else {
344
+ ctx.ui.notify(
345
+ `πŸ“ μ €μž₯: "${result.title}" β†’ ${result.topic}.md (scope: ${result.scope}) β€” /memoryμ—μ„œ 이동/정리 κ°€λŠ₯`,
346
+ "info",
347
+ );
348
+ }
349
+ },
350
+ });
351
+
352
+ // ── /memory Command (Overlay UI) ──────────────────────────────────────
353
+
354
+ pi.registerCommand("memory", {
355
+ description: "Browse and manage stored memories",
356
+ handler: async (args, ctx) => {
357
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
358
+
359
+ // Collect all entries for display
360
+ const displayEntries = await collectDisplayEntries(currentProjectId);
361
+
362
+ if (!ctx.hasUI) {
363
+ if (!displayEntries.length) {
364
+ ctx.ui.notify("No memories stored.", "info");
365
+ return;
366
+ }
367
+ for (const e of displayEntries) {
368
+ ctx.ui.notify(`[${e.scope}] ${e.topic}/${e.title}`, "info");
369
+ }
370
+ return;
371
+ }
372
+
373
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
374
+ let selector: MemorySelectorComponent | null = null;
375
+ let actionMenu: MemoryActionMenuComponent | null = null;
376
+ let deleteConfirm: MemoryDeleteConfirmComponent | null = null;
377
+ let activeComponent: {
378
+ render: (width: number) => string[];
379
+ invalidate: () => void;
380
+ handleInput?: (data: string) => void;
381
+ focused?: boolean;
382
+ } | null = null;
383
+ let wrapperFocused = false;
384
+
385
+ const setActive = (
386
+ c: {
387
+ render: (w: number) => string[];
388
+ invalidate: () => void;
389
+ handleInput?: (data: string) => void;
390
+ focused?: boolean;
391
+ } | null,
392
+ ) => {
393
+ if (activeComponent && "focused" in activeComponent) activeComponent.focused = false;
394
+ activeComponent = c;
395
+ if (activeComponent && "focused" in activeComponent) activeComponent.focused = wrapperFocused;
396
+ tui.requestRender();
397
+ };
398
+
399
+ const refresh = async () => {
400
+ const updated = await collectDisplayEntries(currentProjectId);
401
+ selector?.setEntries(updated);
402
+ };
403
+
404
+ const deleteEntry = async (entry: SearchResult) => {
405
+ try {
406
+ const ok = await removeMemory(entry.scope, entry.projectId, entry.topic, entry.title);
407
+ ctx.ui.notify(ok ? `Deleted: "${entry.title}"` : "Not found", ok ? "info" : "error");
408
+ } catch (e) {
409
+ ctx.ui.notify(`Error: ${e instanceof Error ? e.message : "unknown"}`, "error");
410
+ }
411
+ await refresh();
412
+ setActive(selector);
413
+ };
414
+
415
+ const handleAction = async (entry: SearchResult, action: MemoryMenuAction) => {
416
+ switch (action) {
417
+ case "view":
418
+ await openMemoryDetail(ctx, entry);
419
+ if (actionMenu) setActive(actionMenu);
420
+ return;
421
+ case "viewTopic":
422
+ await openMemoryTopicDetail(ctx, entry);
423
+ if (actionMenu) setActive(actionMenu);
424
+ return;
425
+ case "copyContent":
426
+ copyMemoryEntry(ctx, entry);
427
+ setActive(selector);
428
+ return;
429
+ case "delete":
430
+ deleteConfirm = new MemoryDeleteConfirmComponent(
431
+ theme,
432
+ `μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?\n[${entry.scope}] ${entry.topic} / "${entry.title}"`,
433
+ (confirmed) => {
434
+ if (!confirmed) {
435
+ setActive(actionMenu);
436
+ return;
437
+ }
438
+ void deleteEntry(entry);
439
+ },
440
+ );
441
+ setActive(deleteConfirm);
442
+ return;
443
+ }
444
+ };
445
+
446
+ selector = new MemorySelectorComponent(
447
+ tui,
448
+ theme,
449
+ displayEntries,
450
+ (entry) => showActionMenu(entry),
451
+ () => done(),
452
+ (args ?? "").trim() || undefined,
453
+ );
454
+ setActive(selector);
455
+
456
+ const showActionMenu = (entry: SearchResult) => {
457
+ actionMenu = new MemoryActionMenuComponent(
458
+ theme,
459
+ entry,
460
+ (action) => void handleAction(entry, action),
461
+ () => setActive(selector),
462
+ );
463
+ setActive(actionMenu);
464
+ };
465
+
466
+ return {
467
+ get focused() {
468
+ return wrapperFocused;
469
+ },
470
+ set focused(value: boolean) {
471
+ wrapperFocused = value;
472
+ if (activeComponent && "focused" in activeComponent) activeComponent.focused = value;
473
+ },
474
+ render(width: number) {
475
+ return activeComponent ? activeComponent.render(width) : [];
476
+ },
477
+ invalidate() {
478
+ activeComponent?.invalidate();
479
+ },
480
+ handleInput(data: string) {
481
+ activeComponent?.handleInput?.(data);
482
+ },
483
+ };
484
+ });
485
+ },
486
+ });
487
+
488
+ // ── remember Tool (LLM-callable, fully non-interactive) ───────────────
489
+
490
+ pi.registerTool({
491
+ name: "remember",
492
+ label: "Remember",
493
+ description:
494
+ "Save a fact, rule, or lesson to the user's long-term memory. " +
495
+ "Call this when the user says 'κΈ°μ–΅ν•΄', 'μ•žμœΌλ‘œ μ΄λ ‡κ²Œ ν•΄', '이 κ·œμΉ™ μ μš©ν•΄', 'remember this', etc. " +
496
+ "You must choose the appropriate scope: " +
497
+ "'user' for personal profile, global preferences, or cross-project rules; " +
498
+ "'project' for repo-specific tech decisions, env, tooling, configs. " +
499
+ "Defaults to 'project' when ambiguous.",
500
+ parameters: RememberParams,
501
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
502
+ const { content, title, scope } = params as { content: string; title?: string; scope: MemoryScope };
503
+
504
+ if (!content?.trim()) {
505
+ throw new Error("contentκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.");
506
+ }
507
+
508
+ const result = await saveContent(content, title, scope, ctx, false);
509
+
510
+ if ("cancelled" in result) {
511
+ return { content: [{ type: "text" as const, text: "μ‚¬μš©μžκ°€ κΈ°μ–΅ μ €μž₯을 μ·¨μ†Œν–ˆμŠ΅λ‹ˆλ‹€." }], details: undefined };
512
+ }
513
+ if ("error" in result) {
514
+ throw new Error(result.error);
515
+ }
516
+
517
+ return {
518
+ content: [
519
+ {
520
+ type: "text" as const,
521
+ text: `Memory saved.\nScope: ${result.scope}\nTopic: ${result.topic}.md\nTitle: ${result.title}`,
522
+ },
523
+ ],
524
+ details: undefined,
525
+ };
526
+ },
527
+ });
528
+
529
+ // ── recall Tool ───────────────────────────────────────────────────────
530
+
531
+ pi.registerTool({
532
+ name: "recall",
533
+ label: "Recall",
534
+ description:
535
+ "Search the user's long-term memory for relevant information. " +
536
+ "Use this when you need to check if there are stored rules, preferences, or lessons " +
537
+ "related to the current task. " +
538
+ "Three usage patterns: recall({ query }) to search and get a summary list with IDs, " +
539
+ "recall({ id }) to get the full content of a specific memory, " +
540
+ "or recall({ scope }) to list all memories filtered by scope.",
541
+ parameters: RecallParams,
542
+
543
+ renderCall(args, theme) {
544
+ const query = typeof args.query === "string" ? args.query : undefined;
545
+ const id = typeof args.id === "string" ? args.id : undefined;
546
+ const scope = typeof args.scope === "string" ? args.scope : undefined;
547
+ let text = theme.fg("toolTitle", theme.bold("recall"));
548
+ if (id) text += ` ${theme.fg("accent", `id:${id}`)}`;
549
+ if (query) text += ` ${theme.fg("accent", `"${query}"`)}`;
550
+ if (scope) text += ` ${theme.fg("accent", `scope:${scope}`)}`;
551
+ if (!query && !id) text += ` ${theme.fg("muted", "(index)")}`;
552
+ return new Text(text, 0, 0);
553
+ },
554
+
555
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
556
+ try {
557
+ const { query, id, scope } = params as { query?: string; id?: string; scope?: MemoryScope };
558
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
559
+
560
+ throwIfProjectScopeInvalid(currentProjectId, scope, "recall");
561
+ if (id) return await executeRecallById(id, currentProjectId);
562
+ if (query) return await executeRecallQuery(query, currentProjectId, scope);
563
+ return await executeRecallIndex(currentProjectId, scope);
564
+ } catch (err: unknown) {
565
+ throw new Error(`Recall failed: ${err instanceof Error ? err.message : "unknown"}`);
566
+ }
567
+ },
568
+ });
569
+
570
+ // ── P2-2: forget Tool (scope ambiguity check) ─────────────────────────
571
+
572
+ pi.registerTool({
573
+ name: "forget",
574
+ label: "Forget",
575
+ description:
576
+ "Permanently delete a memory. This action is irreversible. " +
577
+ "Use when the user says 'μžŠμ–΄μ€˜', 'forget this', or a stored rule is no longer valid. " +
578
+ "Provide title and optional topic/scope; if topic is omitted, the title must resolve uniquely.",
579
+ parameters: ForgetParams,
580
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
581
+ try {
582
+ const { topic, title, scope } = params as { topic?: string; title: string; scope?: MemoryScope };
583
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
584
+
585
+ throwIfProjectScopeInvalid(currentProjectId, scope, "forget");
586
+
587
+ const normalizedTitle = normalizeForgetTitle(title);
588
+ if (!normalizedTitle) {
589
+ throw new Error("forget requires non-empty title.");
590
+ }
591
+
592
+ if (topic) {
593
+ return await executeForgetTopic(topic, normalizedTitle, scope, currentProjectId);
594
+ }
595
+ return await executeForgetByTitle(normalizedTitle, scope, currentProjectId);
596
+ } catch (err: unknown) {
597
+ throw new Error(`Forget failed: ${err instanceof Error ? err.message : "unknown"}`);
598
+ }
599
+ },
600
+ });
601
+
602
+ // ── memory_list Tool ──────────────────────────────────────────────────
603
+
604
+ pi.registerTool({
605
+ name: "memory_list",
606
+ label: "Memory List",
607
+ description: "List all active memories. Optionally filter by scope (user or project).",
608
+ parameters: MemoryListParams,
609
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
610
+ try {
611
+ const { scope } = params as { scope?: MemoryScope };
612
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
613
+
614
+ const parts: string[] = [];
615
+
616
+ if (!scope || scope === "user") {
617
+ const idx = (await readMemoryMd("user")).trim();
618
+ if (idx) {
619
+ parts.push("[User Memory]");
620
+ parts.push(idx);
621
+ }
622
+ }
623
+
624
+ if ((!scope || scope === "project") && currentProjectId) {
625
+ const idx = (await readMemoryMd("project", currentProjectId)).trim();
626
+ if (idx) {
627
+ if (parts.length) parts.push("");
628
+ parts.push("[Project Memory]");
629
+ parts.push(idx);
630
+ }
631
+ }
632
+
633
+ const text = parts.join("\n") || "No active memories.";
634
+ return { content: [{ type: "text" as const, text }], details: undefined };
635
+ } catch (err: unknown) {
636
+ throw new Error(`List failed: ${err instanceof Error ? err.message : "unknown"}`);
637
+ }
638
+ },
639
+ });
640
+
641
+ // ── Lifecycle Events ──────────────────────────────────────────────────
642
+
643
+ pi.on("session_start", async (_event, ctx) => {
644
+ try {
645
+ await ensureDir();
646
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
647
+
648
+ // One-time migration from JSON
649
+ if (!migrationDone) {
650
+ migrationDone = true;
651
+ const { migrated, errors } = await migrateFromJson();
652
+ if (migrated > 0) {
653
+ ctx.ui.notify(`Memory: migrated ${migrated} entries to markdown.`, "info");
654
+ }
655
+ if (errors.length > 0) {
656
+ ctx.ui.notify(`Memory migration errors: ${errors.join("; ")}`, "warning");
657
+ }
658
+ }
659
+ } catch {
660
+ // Graceful degradation
661
+ }
662
+ });
663
+
664
+ // ── before_agent_start: Memory Injection ──────────────────────────────
665
+
666
+ pi.on("before_agent_start", async (event, ctx) => {
667
+ try {
668
+ currentProjectId = resolveCurrentProjectId(ctx.cwd);
669
+ const hint = await buildMemoryPrompt(currentProjectId);
670
+ if (hint) {
671
+ return { systemPrompt: event.systemPrompt + hint };
672
+ }
673
+ } catch {
674
+ // Graceful degradation
675
+ }
676
+ return undefined;
677
+ });
678
+ }
679
+
680
+ // ── Helpers ──────────────────────────────────────────────────────────────────
681
+
682
+ async function collectDisplayEntries(projectId?: string): Promise<SearchResult[]> {
683
+ const results: SearchResult[] = [];
684
+ const scopes: Array<{ scope: MemoryScope; pid?: string }> = [
685
+ { scope: "user" },
686
+ ...(projectId ? [{ scope: "project" as MemoryScope, pid: projectId }] : []),
687
+ ];
688
+ for (const { scope, pid } of scopes) {
689
+ const topics = await listTopics(scope, pid);
690
+ for (const topic of topics) {
691
+ const entries = await loadTopicEntries(scope, pid, topic);
692
+ for (const entry of entries) {
693
+ results.push({ scope, projectId: pid, topic, title: entry.title, content: entry.content });
694
+ }
695
+ }
696
+ }
697
+ return results;
698
+ }