@askjo/pi-mem 1.0.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,680 @@
1
+ /**
2
+ * Memory Extension
3
+ *
4
+ * Plain-Markdown memory system inspired by OpenClaw's approach.
5
+ * No embeddings, no vector search — just files on disk injected into context.
6
+ *
7
+ * Layout (under ~/.pi/agent/memory/):
8
+ * MEMORY.md — curated long-term memory (decisions, preferences, durable facts)
9
+ * SCRATCHPAD.md — checklist of things to keep in mind / fix later
10
+ * daily/YYYY-MM-DD.md — daily append-only log (today + yesterday loaded at session start)
11
+ *
12
+ * Tools:
13
+ * memory_write — write to MEMORY.md or daily log
14
+ * memory_read — read any memory file or list daily logs
15
+ * scratchpad — add/check/uncheck/clear items on the scratchpad checklist
16
+ *
17
+ * Context injection:
18
+ * - MEMORY.md + SCRATCHPAD.md + today's + yesterday's daily logs injected into every turn
19
+ *
20
+ * Dashboard widget:
21
+ * - Auto-generated "Last 24h" summary from session metadata (titles, timestamps, costs)
22
+ * - Rebuilt every 15 minutes in the background
23
+ * - Shown on session_start and session_switch (so /new gets it too)
24
+ */
25
+
26
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
27
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
28
+ import { Markdown } from "@mariozechner/pi-tui";
29
+ import { Type } from "@sinclair/typebox";
30
+ import { StringEnum, completeSimple, getModel } from "@mariozechner/pi-ai";
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { execFileSync } from "node:child_process";
34
+
35
+ import {
36
+ type MemoryConfig,
37
+ type ScratchpadItem,
38
+ type SessionInfo,
39
+ buildConfig,
40
+ todayStr,
41
+ yesterdayStr,
42
+ nowTimestamp,
43
+ shortSessionId,
44
+ readFileSafe,
45
+ dailyPath,
46
+ ensureDirs,
47
+ parseScratchpad,
48
+ serializeScratchpad,
49
+ buildMemoryContext,
50
+ scanSession,
51
+ isHousekeeping,
52
+ searchMemory,
53
+ } from "./lib.ts";
54
+
55
+ const config = buildConfig();
56
+
57
+ function gitCommit(message: string) {
58
+ if (!config.autocommit) return;
59
+ try {
60
+ execFileSync("git", ["add", "-A"], { cwd: config.memoryDir, stdio: "ignore", timeout: 5000 });
61
+ execFileSync("git", ["commit", "-m", message, "--allow-empty-message", "--no-verify"], { cwd: config.memoryDir, stdio: "ignore", timeout: 5000 });
62
+ } catch {
63
+ // git not available or not a repo — silently skip
64
+ }
65
+ }
66
+
67
+ // --- Session scanner for "Last 24h" dashboard ---
68
+
69
+ const SESSIONS_DIR = path.join(process.env.HOME ?? "~", ".pi", "agent", "sessions");
70
+ const SUMMARY_CACHE = path.join(config.dailyDir, "cache.json");
71
+ const REBUILD_INTERVAL_MS = 15 * 60 * 1000;
72
+ const LOOKBACK_MS = 24 * 60 * 60 * 1000;
73
+
74
+ let modelRegistryRef: any = null;
75
+
76
+ async function collectSessions(): Promise<{ roots: SessionInfo[]; childCountMap: Map<string, number>; totalCost: number }> {
77
+ const cutoff = new Date(Date.now() - LOOKBACK_MS);
78
+ const sessionDirs: string[] = [];
79
+
80
+ try {
81
+ for (const dir of fs.readdirSync(SESSIONS_DIR)) {
82
+ if (dir.startsWith("--Users-") && !dir.includes("-T-pi-")) {
83
+ sessionDirs.push(path.join(SESSIONS_DIR, dir));
84
+ }
85
+ }
86
+ } catch { return { roots: [], childCountMap: new Map(), totalCost: 0 }; }
87
+
88
+ const recentFiles: string[] = [];
89
+ for (const dir of sessionDirs) {
90
+ try {
91
+ for (const file of fs.readdirSync(dir)) {
92
+ if (!file.endsWith(".jsonl")) continue;
93
+ const filePath = path.join(dir, file);
94
+ try {
95
+ if (fs.statSync(filePath).mtime >= cutoff) recentFiles.push(filePath);
96
+ } catch { continue; }
97
+ }
98
+ } catch { continue; }
99
+ }
100
+
101
+ if (recentFiles.length === 0) return { roots: [], childCountMap: new Map(), totalCost: 0 };
102
+
103
+ const results = await Promise.all(recentFiles.map(scanSession));
104
+ const sessions = results.filter((s): s is SessionInfo => s !== null);
105
+
106
+ const roots = sessions.filter(s => !s.isChild);
107
+ const children = sessions.filter(s => s.isChild);
108
+
109
+ const childCountMap = new Map<string, number>();
110
+ for (const child of children) {
111
+ if (child.parentSession) {
112
+ childCountMap.set(child.parentSession, (childCountMap.get(child.parentSession) || 0) + 1);
113
+ }
114
+ }
115
+
116
+ const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
117
+ return { roots, childCountMap, totalCost };
118
+ }
119
+
120
+ async function summarizeWithLLM(sessions: SessionInfo[], childCountMap: Map<string, number>, totalCost: number): Promise<string> {
121
+ if (!modelRegistryRef) return "";
122
+
123
+ const candidates = [
124
+ getModel("openai", "gpt-4.1-mini"),
125
+ getModel("openai", "gpt-4o-mini"),
126
+ modelRegistryRef.find("jo-proxy", "jo-gpt-4.1-mini"),
127
+ ];
128
+
129
+ let model: any = null;
130
+ let apiKey: string | undefined;
131
+ for (const candidate of candidates) {
132
+ if (!candidate) continue;
133
+ const key = await modelRegistryRef.getApiKey(candidate);
134
+ if (key) { model = candidate; apiKey = key; break; }
135
+ }
136
+ if (!model || !apiKey) return "";
137
+
138
+ const listing = sessions.map((s, _i) => {
139
+ const childCount = childCountMap.get(s.file) || 0;
140
+ const parts = [`${s.title}`];
141
+ if (childCount > 0) parts.push(`[${childCount} sub-agents]`);
142
+ if (s.cost > 0.05) parts.push(`[$${s.cost.toFixed(2)}]`);
143
+ return parts.join(" ");
144
+ }).join("\n");
145
+
146
+ const response = await completeSimple(model, {
147
+ systemPrompt: [
148
+ "You are summarizing a developer's last 24 hours of coding sessions for a dashboard.",
149
+ "Write a concise grouped summary in markdown. Rules:",
150
+ "",
151
+ "- Group by TOPIC (not time). 3-7 groups. Short bold header per group (2-4 words).",
152
+ "- Under each header, write 1-3 bullet points summarizing WHAT WAS ACCOMPLISHED.",
153
+ " Synthesize multiple related sessions into a single clear statement.",
154
+ " e.g. 10 sessions about 'Run eval suite X' → '**Eval suite runs**: ran all 10 suites in sprite mode across weather, routing, memory, calendar, email, browser, and security'",
155
+ "- Be specific about outcomes: fixes applied, features built, bugs found, tools created.",
156
+ "- Collapse repetitive runs (eval runs, debugging attempts) into one line with the count.",
157
+ "- Mention sub-agent counts where relevant — it shows parallel work.",
158
+ "- Keep total output under 25 lines. Dense and useful, not a laundry list.",
159
+ "- Order: oldest topic first, most recent topic last.",
160
+ "- Do NOT include a header line — the caller adds that.",
161
+ "- Do NOT repeat session titles verbatim. Summarize.",
162
+ ].join("\n"),
163
+ messages: [{
164
+ role: "user" as const,
165
+ content: [{ type: "text" as const, text: `${sessions.length} sessions, $${totalCost.toFixed(2)} total cost:\n\n${listing}` }],
166
+ timestamp: Date.now(),
167
+ }],
168
+ }, { apiKey });
169
+
170
+ return response.content
171
+ .filter((c: any) => c.type === "text")
172
+ .map((c: any) => c.text)
173
+ .join("")
174
+ .trim();
175
+ }
176
+
177
+ async function buildSessionSummary(): Promise<string> {
178
+ const { roots, childCountMap, totalCost } = await collectSessions();
179
+ if (roots.length === 0) return "";
180
+
181
+ const sorted = [...roots]
182
+ .filter(s => !isHousekeeping(s.title))
183
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
184
+
185
+ if (sorted.length === 0) return "";
186
+
187
+ const header = `## Last 24h — ${sorted.length} sessions, $${totalCost.toFixed(2)}`;
188
+
189
+ try {
190
+ const summary = await summarizeWithLLM(sorted, childCountMap, totalCost);
191
+ if (summary) return `${header}\n\n${summary}`;
192
+ } catch {}
193
+
194
+ const lines = [header, ""];
195
+ for (const s of sorted) {
196
+ const childCount = childCountMap.get(s.file) || 0;
197
+ const childTag = childCount > 0 ? ` (+${childCount} sub-agents)` : "";
198
+ lines.push(`- ${s.title}${childTag}`);
199
+ }
200
+ return lines.join("\n");
201
+ }
202
+
203
+ let cachedSummary = "";
204
+ let lastRebuildTime = 0;
205
+
206
+ async function getOrRebuildSummary(): Promise<string> {
207
+ const now = Date.now();
208
+ if (now - lastRebuildTime < REBUILD_INTERVAL_MS && cachedSummary) {
209
+ return cachedSummary;
210
+ }
211
+
212
+ if (!cachedSummary) {
213
+ try {
214
+ const cache = JSON.parse(fs.readFileSync(SUMMARY_CACHE, "utf-8"));
215
+ if (cache.summary && now - cache.timestamp < REBUILD_INTERVAL_MS) {
216
+ cachedSummary = cache.summary;
217
+ lastRebuildTime = cache.timestamp;
218
+ return cachedSummary;
219
+ }
220
+ } catch {}
221
+ }
222
+
223
+ cachedSummary = await buildSessionSummary();
224
+ lastRebuildTime = now;
225
+
226
+ try {
227
+ ensureDirs(config);
228
+ fs.writeFileSync(SUMMARY_CACHE, JSON.stringify({ summary: cachedSummary, timestamp: now }), "utf-8");
229
+ } catch {}
230
+
231
+ return cachedSummary;
232
+ }
233
+
234
+ export default function (pi: ExtensionAPI) {
235
+ let rebuildTimer: ReturnType<typeof setInterval> | null = null;
236
+
237
+ async function showDashboard(ctx: any) {
238
+ if (!ctx.hasUI) return;
239
+
240
+ const summary = await getOrRebuildSummary();
241
+ const scratchContent = readFileSafe(config.scratchpadFile);
242
+
243
+ const sections: string[] = [];
244
+
245
+ if (summary) {
246
+ sections.push(summary);
247
+ }
248
+
249
+ if (scratchContent?.trim()) {
250
+ const items = scratchContent
251
+ .trim()
252
+ .split("\n")
253
+ .filter((l: string) => l.match(/^- \[ \]/))
254
+ .filter((l: string) => !l.match(/^<!--.*-->$/))
255
+ .map((l: string) => l.replace(/^- /, ""));
256
+ if (items.length > 0) {
257
+ sections.push(`## Scratchpad\n\n${items.join("\n")}`);
258
+ }
259
+ }
260
+
261
+ if (sections.length === 0) return;
262
+
263
+ const md = sections.join("\n\n---\n\n");
264
+
265
+ ctx.ui.setWidget("memory-dashboard", (_tui: any, _theme: any) => {
266
+ const mdTheme = getMarkdownTheme();
267
+ const markdown = new Markdown(md, 1, 0, mdTheme);
268
+ return markdown;
269
+ });
270
+ }
271
+
272
+ pi.on("session_start", async (_event, ctx) => {
273
+ modelRegistryRef = ctx.modelRegistry;
274
+ await showDashboard(ctx);
275
+
276
+ if (!rebuildTimer) {
277
+ rebuildTimer = setInterval(async () => {
278
+ cachedSummary = await buildSessionSummary();
279
+ lastRebuildTime = Date.now();
280
+ try {
281
+ ensureDirs(config);
282
+ fs.writeFileSync(SUMMARY_CACHE, JSON.stringify({ summary: cachedSummary, timestamp: lastRebuildTime }), "utf-8");
283
+ } catch {}
284
+ }, REBUILD_INTERVAL_MS);
285
+ }
286
+ });
287
+
288
+ pi.on("session_switch", async (_event, ctx) => {
289
+ modelRegistryRef = ctx.modelRegistry;
290
+ await showDashboard(ctx);
291
+ });
292
+
293
+ pi.on("agent_start", async (_event, ctx) => {
294
+ ctx.ui.setWidget("memory-dashboard", undefined);
295
+ });
296
+
297
+ pi.on("session_shutdown", async () => {
298
+ if (rebuildTimer) { clearInterval(rebuildTimer); rebuildTimer = null; }
299
+ });
300
+
301
+ pi.on("before_agent_start", async (event, _ctx) => {
302
+ const memoryContext = buildMemoryContext(config);
303
+ if (!memoryContext) return;
304
+
305
+ const memoryInstructions = [
306
+ "\n\n## Memory",
307
+ "The following memory files have been loaded. Use the memory_write tool to persist important information.",
308
+ "- Decisions, preferences, and durable facts \u2192 MEMORY.md",
309
+ "- Day-to-day notes and running context \u2192 daily/<YYYY-MM-DD>.md",
310
+ "- Things to fix later or keep in mind \u2192 scratchpad tool",
311
+ '- If someone says "remember this," write it immediately.',
312
+ "",
313
+ memoryContext,
314
+ ].join("\n");
315
+
316
+ return {
317
+ systemPrompt: event.systemPrompt + memoryInstructions,
318
+ };
319
+ });
320
+
321
+ pi.on("session_before_compact", async (_event, ctx) => {
322
+ const memoryContext = buildMemoryContext(config);
323
+ const hasMemory = memoryContext.length > 0;
324
+
325
+ if (hasMemory) {
326
+ ctx.ui.notify("Memory files available \u2014 consider persisting important context before compaction", "info");
327
+ }
328
+ });
329
+
330
+ // memory_write tool
331
+ pi.registerTool({
332
+ name: "memory_write",
333
+ label: "Memory Write",
334
+ description: [
335
+ "Write to memory files. Three targets:",
336
+ "- 'long_term': Write to MEMORY.md (curated durable facts, decisions, preferences). Mode: 'append' or 'overwrite'.",
337
+ "- 'daily': Append to today's daily log (daily/<YYYY-MM-DD>.md). Always appends.",
338
+ "- 'note': Create or update a file in notes/ (e.g. lessons.md, self-review.md). Pass filename. Mode: 'append' or 'overwrite'.",
339
+ "Use this when the user asks you to remember something, or when you learn important preferences/decisions.",
340
+ ].join("\n"),
341
+ parameters: Type.Object({
342
+ target: StringEnum(["long_term", "daily", "note"] as const, {
343
+ description: "Where to write: 'long_term' for MEMORY.md, 'daily' for today's daily log, 'note' for notes/<filename>",
344
+ }),
345
+ content: Type.String({ description: "Content to write (Markdown)" }),
346
+ mode: Type.Optional(
347
+ StringEnum(["append", "overwrite"] as const, {
348
+ description: "Write mode. Default: 'append'. Daily always appends.",
349
+ }),
350
+ ),
351
+ filename: Type.Optional(
352
+ Type.String({ description: "Filename for 'note' target (e.g. 'lessons.md')" }),
353
+ ),
354
+ }),
355
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
356
+ ensureDirs(config);
357
+ const { target, content, mode, filename } = params;
358
+ const sid = shortSessionId(ctx.sessionManager.getSessionId());
359
+ const ts = nowTimestamp();
360
+
361
+ if (target === "note") {
362
+ if (!filename) {
363
+ return { content: [{ type: "text", text: "Error: 'filename' is required for target 'note'." }], details: {} };
364
+ }
365
+ const safe = path.basename(filename);
366
+ const filePath = path.join(config.notesDir, safe);
367
+ const existing = readFileSafe(filePath) ?? "";
368
+
369
+ if (mode === "overwrite") {
370
+ const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
371
+ fs.writeFileSync(filePath, stamped, "utf-8");
372
+ gitCommit(`note: ${safe}`);
373
+ return {
374
+ content: [{ type: "text", text: `Wrote notes/${safe}` }],
375
+ details: { path: filePath, target, mode: "overwrite", sessionId: sid, timestamp: ts },
376
+ };
377
+ }
378
+
379
+ const separator = existing.trim() ? "\n\n" : "";
380
+ const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
381
+ fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
382
+ gitCommit(`note: ${safe}`);
383
+ return {
384
+ content: [{ type: "text", text: `Appended to notes/${safe}` }],
385
+ details: { path: filePath, target, mode: "append", sessionId: sid, timestamp: ts },
386
+ };
387
+ }
388
+
389
+ if (target === "daily") {
390
+ const filePath = dailyPath(config.dailyDir, todayStr());
391
+ const existing = readFileSafe(filePath) ?? "";
392
+
393
+ const existingSnippet = existing.trim()
394
+ ? `\n\nExisting daily log content:\n${existing.trim()}`
395
+ : "\n\nDaily log was empty.";
396
+
397
+ const separator = existing.trim() ? "\n\n" : "";
398
+ const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
399
+ fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
400
+ gitCommit(`daily: ${todayStr()}`);
401
+ return {
402
+ content: [{ type: "text", text: `Appended to daily log: ${filePath}${existingSnippet}` }],
403
+ details: { path: filePath, target, mode: "append", sessionId: sid, timestamp: ts },
404
+ };
405
+ }
406
+
407
+ // long_term
408
+ const existing = readFileSafe(config.memoryFile) ?? "";
409
+ const existingSnippet = existing.trim()
410
+ ? `\n\nExisting MEMORY.md content:\n${existing.trim()}`
411
+ : "\n\nMEMORY.md was empty.";
412
+
413
+ if (mode === "overwrite") {
414
+ const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
415
+ fs.writeFileSync(config.memoryFile, stamped, "utf-8");
416
+ gitCommit("memory: overwrite");
417
+ return {
418
+ content: [{ type: "text", text: `Overwrote MEMORY.md${existingSnippet}` }],
419
+ details: { path: config.memoryFile, target, mode: "overwrite", sessionId: sid, timestamp: ts },
420
+ };
421
+ }
422
+
423
+ const separator = existing.trim() ? "\n\n" : "";
424
+ const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
425
+ fs.writeFileSync(config.memoryFile, existing + separator + stamped, "utf-8");
426
+ gitCommit("memory: append");
427
+ return {
428
+ content: [{ type: "text", text: `Appended to MEMORY.md${existingSnippet}` }],
429
+ details: { path: config.memoryFile, target, mode: "append", sessionId: sid, timestamp: ts },
430
+ };
431
+ },
432
+ });
433
+
434
+ // scratchpad tool
435
+ pi.registerTool({
436
+ name: "scratchpad",
437
+ label: "Scratchpad",
438
+ description: [
439
+ "Manage a checklist of things to fix later or keep in mind. Actions:",
440
+ "- 'add': Add a new unchecked item (- [ ] text)",
441
+ "- 'done': Mark an item as done (- [x] text). Match by substring.",
442
+ "- 'undo': Uncheck a done item back to open. Match by substring.",
443
+ "- 'clear_done': Remove all checked items from the list.",
444
+ "- 'list': Show all items.",
445
+ ].join("\n"),
446
+ parameters: Type.Object({
447
+ action: StringEnum(["add", "done", "undo", "clear_done", "list"] as const, {
448
+ description: "What to do",
449
+ }),
450
+ text: Type.Optional(
451
+ Type.String({ description: "Item text for add, or substring to match for done/undo" }),
452
+ ),
453
+ }),
454
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
455
+ ensureDirs(config);
456
+ const { action, text } = params;
457
+ const sid = shortSessionId(ctx.sessionManager.getSessionId());
458
+ const ts = nowTimestamp();
459
+
460
+ const existing = readFileSafe(config.scratchpadFile) ?? "";
461
+ let items = parseScratchpad(existing);
462
+
463
+ if (action === "list") {
464
+ if (items.length === 0) {
465
+ return { content: [{ type: "text", text: "Scratchpad is empty." }], details: {} };
466
+ }
467
+ return {
468
+ content: [{ type: "text", text: serializeScratchpad(items) }],
469
+ details: { count: items.length, open: items.filter((i) => !i.done).length },
470
+ };
471
+ }
472
+
473
+ if (action === "add") {
474
+ if (!text) {
475
+ return { content: [{ type: "text", text: "Error: 'text' is required for add." }], details: {} };
476
+ }
477
+ items.push({ done: false, text, meta: `<!-- ${ts} [${sid}] -->` });
478
+ fs.writeFileSync(config.scratchpadFile, serializeScratchpad(items), "utf-8");
479
+ gitCommit(`scratchpad: add`);
480
+ return {
481
+ content: [{ type: "text", text: `Added: - [ ] ${text}\n\n${serializeScratchpad(items)}` }],
482
+ details: { action, sessionId: sid, timestamp: ts },
483
+ };
484
+ }
485
+
486
+ if (action === "done" || action === "undo") {
487
+ if (!text) {
488
+ return { content: [{ type: "text", text: `Error: 'text' is required for ${action}.` }], details: {} };
489
+ }
490
+ const needle = text.toLowerCase();
491
+ const targetDone = action === "done";
492
+ let matched = false;
493
+ for (const item of items) {
494
+ if (item.done !== targetDone && item.text.toLowerCase().includes(needle)) {
495
+ item.done = targetDone;
496
+ matched = true;
497
+ break;
498
+ }
499
+ }
500
+ if (!matched) {
501
+ return {
502
+ content: [{ type: "text", text: `No matching ${targetDone ? "open" : "done"} item found for: "${text}"` }],
503
+ details: {},
504
+ };
505
+ }
506
+ fs.writeFileSync(config.scratchpadFile, serializeScratchpad(items), "utf-8");
507
+ gitCommit(`scratchpad: ${action}`);
508
+ return {
509
+ content: [{ type: "text", text: `Updated.\n\n${serializeScratchpad(items)}` }],
510
+ details: { action, sessionId: sid, timestamp: ts },
511
+ };
512
+ }
513
+
514
+ if (action === "clear_done") {
515
+ const before = items.length;
516
+ items = items.filter((i) => !i.done);
517
+ const removed = before - items.length;
518
+ fs.writeFileSync(config.scratchpadFile, serializeScratchpad(items), "utf-8");
519
+ gitCommit("scratchpad: clear_done");
520
+ return {
521
+ content: [{ type: "text", text: `Cleared ${removed} done item(s).\n\n${serializeScratchpad(items)}` }],
522
+ details: { action, removed },
523
+ };
524
+ }
525
+
526
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: {} };
527
+ },
528
+ });
529
+
530
+ // memory_read tool
531
+ pi.registerTool({
532
+ name: "memory_read",
533
+ label: "Memory Read",
534
+ description: [
535
+ "Read a memory file. Targets:",
536
+ "- 'long_term': Read MEMORY.md",
537
+ "- 'scratchpad': Read SCRATCHPAD.md",
538
+ "- 'daily': Read a specific day's log (default: today). Pass date as YYYY-MM-DD.",
539
+ "- 'file': Read any file by name (e.g. 'SOUL.md'). Pass filename.",
540
+ "- 'note': Read a file from notes/ (e.g. 'lessons.md'). Pass filename.",
541
+ "- 'list': List all files in the memory directory.",
542
+ ].join("\n"),
543
+ parameters: Type.Object({
544
+ target: StringEnum(["long_term", "scratchpad", "daily", "file", "note", "list"] as const, {
545
+ description: "What to read",
546
+ }),
547
+ date: Type.Optional(
548
+ Type.String({ description: "Date for daily log (YYYY-MM-DD). Default: today." }),
549
+ ),
550
+ filename: Type.Optional(
551
+ Type.String({ description: "Filename for 'file' target (e.g. 'lessons.md', 'SOUL.md')" }),
552
+ ),
553
+ }),
554
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
555
+ ensureDirs(config);
556
+ const { target, date, filename } = params;
557
+
558
+ if (target === "list") {
559
+ const sections: string[] = [];
560
+ try {
561
+ const rootFiles = fs.readdirSync(config.memoryDir).filter(f => f.endsWith(".md") || f.endsWith(".json")).sort();
562
+ if (rootFiles.length > 0) sections.push(`Files:\n${rootFiles.map(f => `- ${f}`).join("\n")}`);
563
+ } catch {}
564
+ try {
565
+ const noteFiles = fs.readdirSync(config.notesDir).filter(f => f.endsWith(".md")).sort();
566
+ if (noteFiles.length > 0) sections.push(`Notes:\n${noteFiles.map(f => `- notes/${f}`).join("\n")}`);
567
+ } catch {}
568
+ try {
569
+ const dailyFiles = fs.readdirSync(config.dailyDir).filter(f => f.endsWith(".md")).sort().reverse();
570
+ if (dailyFiles.length > 0) sections.push(`Daily logs (${dailyFiles.length}):\n${dailyFiles.slice(0, 10).map(f => `- daily/${f}`).join("\n")}${dailyFiles.length > 10 ? `\n ... and ${dailyFiles.length - 10} more` : ""}`);
571
+ } catch {}
572
+ if (sections.length === 0) {
573
+ return { content: [{ type: "text", text: "Memory directory is empty." }], details: {} };
574
+ }
575
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
576
+ }
577
+
578
+ if (target === "file") {
579
+ if (!filename) {
580
+ return { content: [{ type: "text", text: "Error: 'filename' is required for target 'file'." }], details: {} };
581
+ }
582
+ const safe = path.basename(filename);
583
+ const filePath = path.join(config.memoryDir, safe);
584
+ const content = readFileSafe(filePath);
585
+ if (!content) {
586
+ return { content: [{ type: "text", text: `File not found: ${safe}` }], details: {} };
587
+ }
588
+ return { content: [{ type: "text", text: content }], details: { path: filePath, filename: safe } };
589
+ }
590
+
591
+ if (target === "note") {
592
+ if (!filename) {
593
+ return { content: [{ type: "text", text: "Error: 'filename' is required for target 'note'." }], details: {} };
594
+ }
595
+ const safe = path.basename(filename);
596
+ const filePath = path.join(config.notesDir, safe);
597
+ const content = readFileSafe(filePath);
598
+ if (!content) {
599
+ return { content: [{ type: "text", text: `Note not found: notes/${safe}` }], details: {} };
600
+ }
601
+ return { content: [{ type: "text", text: content }], details: { path: filePath, filename: `notes/${safe}` } };
602
+ }
603
+
604
+ if (target === "daily") {
605
+ const d = date ?? todayStr();
606
+ const filePath = dailyPath(config.dailyDir, d);
607
+ const content = readFileSafe(filePath);
608
+ if (!content) {
609
+ return { content: [{ type: "text", text: `No daily log for ${d}.` }], details: {} };
610
+ }
611
+ return {
612
+ content: [{ type: "text", text: content }],
613
+ details: { path: filePath, date: d },
614
+ };
615
+ }
616
+
617
+ if (target === "scratchpad") {
618
+ const content = readFileSafe(config.scratchpadFile);
619
+ if (!content?.trim()) {
620
+ return { content: [{ type: "text", text: "SCRATCHPAD.md is empty or does not exist." }], details: {} };
621
+ }
622
+ return {
623
+ content: [{ type: "text", text: content }],
624
+ details: { path: config.scratchpadFile },
625
+ };
626
+ }
627
+
628
+ // long_term
629
+ const content = readFileSafe(config.memoryFile);
630
+ if (!content) {
631
+ return { content: [{ type: "text", text: "MEMORY.md is empty or does not exist." }], details: {} };
632
+ }
633
+ return {
634
+ content: [{ type: "text", text: content }],
635
+ details: { path: config.memoryFile },
636
+ };
637
+ },
638
+ });
639
+
640
+ // memory_search tool
641
+ pi.registerTool({
642
+ name: "memory_search",
643
+ label: "Memory Search",
644
+ description: [
645
+ "Search across all memory files (MEMORY.md, SCRATCHPAD.md, daily logs, notes/, and any other .md files).",
646
+ "Matches filenames and file contents. Case-insensitive keyword search.",
647
+ "Returns matching files and lines with paths.",
648
+ ].join("\n"),
649
+ parameters: Type.Object({
650
+ query: Type.String({ description: "Search query (case-insensitive substring match)" }),
651
+ max_results: Type.Optional(
652
+ Type.Number({ description: "Maximum results to return (default: 20)", default: 20 }),
653
+ ),
654
+ }),
655
+ async execute(_toolCallId, params) {
656
+ ensureDirs(config);
657
+ const { query, max_results } = params;
658
+ const limit = max_results ?? 20;
659
+
660
+ const result = searchMemory(config, query, limit);
661
+
662
+ if (result.fileMatches.length === 0 && result.lineResults.length === 0) {
663
+ return { content: [{ type: "text", text: `No results for "${query}".` }], details: {} };
664
+ }
665
+
666
+ const parts: string[] = [];
667
+ if (result.fileMatches.length > 0) {
668
+ parts.push(`Files matching "${query}":\n${result.fileMatches.map(f => `- ${f}`).join("\n")}`);
669
+ }
670
+ if (result.lineResults.length > 0) {
671
+ parts.push(`Content matches:\n${result.lineResults.map(r => `${r.file}:${r.line}: ${r.text}`).join("\n")}`);
672
+ }
673
+
674
+ return {
675
+ content: [{ type: "text", text: parts.join("\n\n") }],
676
+ details: { query, fileMatches: result.fileMatches.length, lineMatches: result.lineResults.length },
677
+ };
678
+ },
679
+ });
680
+ }