pi-memory 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,1099 @@
1
+ /**
2
+ * Memory Extension with QMD-Powered Search
3
+ *
4
+ * Plain-Markdown memory system with semantic search via qmd.
5
+ * Core memory tools (write/read/scratchpad) work without qmd installed.
6
+ * The memory_search tool requires qmd for keyword, semantic, and hybrid search.
7
+ *
8
+ * Layout (under ~/.pi/agent/memory/):
9
+ * MEMORY.md — curated long-term memory (decisions, preferences, durable facts)
10
+ * SCRATCHPAD.md — checklist of things to keep in mind / fix later
11
+ * daily/YYYY-MM-DD.md — daily append-only log (today + yesterday loaded at session start)
12
+ *
13
+ * Tools:
14
+ * memory_write — write to MEMORY.md or daily log
15
+ * memory_read — read any memory file or list daily logs
16
+ * scratchpad — add/check/uncheck/clear items on the scratchpad checklist
17
+ * memory_search — search across all memory files via qmd (keyword, semantic, or deep)
18
+ *
19
+ * Context injection:
20
+ * - MEMORY.md + SCRATCHPAD.md + today's + yesterday's daily logs injected into every turn
21
+ */
22
+
23
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
+ import { StringEnum } from "@mariozechner/pi-ai";
25
+ import { Type } from "@sinclair/typebox";
26
+ import { execFile } from "node:child_process";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Paths (mutable for testing via _setBaseDir / _resetBaseDir)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const DEFAULT_MEMORY_DIR = path.join(process.env.HOME ?? "~", ".pi", "agent", "memory");
35
+
36
+ let MEMORY_DIR = DEFAULT_MEMORY_DIR;
37
+ let MEMORY_FILE = path.join(MEMORY_DIR, "MEMORY.md");
38
+ let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md");
39
+ let DAILY_DIR = path.join(MEMORY_DIR, "daily");
40
+
41
+ /** Override base directory (for testing). */
42
+ export function _setBaseDir(baseDir: string) {
43
+ MEMORY_DIR = baseDir;
44
+ MEMORY_FILE = path.join(baseDir, "MEMORY.md");
45
+ SCRATCHPAD_FILE = path.join(baseDir, "SCRATCHPAD.md");
46
+ DAILY_DIR = path.join(baseDir, "daily");
47
+ }
48
+
49
+ /** Reset to default paths (for testing). */
50
+ export function _resetBaseDir() {
51
+ _setBaseDir(DEFAULT_MEMORY_DIR);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Utilities
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function ensureDirs() {
59
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
60
+ fs.mkdirSync(DAILY_DIR, { recursive: true });
61
+ }
62
+
63
+ export function todayStr(): string {
64
+ const d = new Date();
65
+ return d.toISOString().slice(0, 10);
66
+ }
67
+
68
+ export function yesterdayStr(): string {
69
+ const d = new Date();
70
+ d.setDate(d.getDate() - 1);
71
+ return d.toISOString().slice(0, 10);
72
+ }
73
+
74
+ export function nowTimestamp(): string {
75
+ return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
76
+ }
77
+
78
+ export function shortSessionId(sessionId: string): string {
79
+ return sessionId.slice(0, 8);
80
+ }
81
+
82
+ export function readFileSafe(filePath: string): string | null {
83
+ try {
84
+ return fs.readFileSync(filePath, "utf-8");
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function dailyPath(date: string): string {
91
+ return path.join(DAILY_DIR, `${date}.md`);
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Limits + preview helpers
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const RESPONSE_PREVIEW_MAX_CHARS = 4_000;
99
+ const RESPONSE_PREVIEW_MAX_LINES = 120;
100
+
101
+ const CONTEXT_LONG_TERM_MAX_CHARS = 4_000;
102
+ const CONTEXT_LONG_TERM_MAX_LINES = 150;
103
+ const CONTEXT_SCRATCHPAD_MAX_CHARS = 2_000;
104
+ const CONTEXT_SCRATCHPAD_MAX_LINES = 120;
105
+ const CONTEXT_DAILY_MAX_CHARS = 3_000;
106
+ const CONTEXT_DAILY_MAX_LINES = 120;
107
+ const CONTEXT_SEARCH_MAX_CHARS = 2_500;
108
+ const CONTEXT_SEARCH_MAX_LINES = 80;
109
+ const CONTEXT_MAX_CHARS = 16_000;
110
+
111
+ type TruncateMode = "start" | "end" | "middle";
112
+
113
+ interface PreviewResult {
114
+ preview: string;
115
+ truncated: boolean;
116
+ totalLines: number;
117
+ totalChars: number;
118
+ previewLines: number;
119
+ previewChars: number;
120
+ }
121
+
122
+ function normalizeContent(content: string): string {
123
+ return content.trim();
124
+ }
125
+
126
+ function truncateLines(lines: string[], maxLines: number, mode: TruncateMode) {
127
+ if (maxLines <= 0 || lines.length <= maxLines) {
128
+ return { lines, truncated: false };
129
+ }
130
+
131
+ if (mode === "end") {
132
+ return { lines: lines.slice(-maxLines), truncated: true };
133
+ }
134
+
135
+ if (mode === "middle" && maxLines > 1) {
136
+ const marker = "... (truncated) ...";
137
+ const keep = maxLines - 1;
138
+ const headCount = Math.ceil(keep / 2);
139
+ const tailCount = Math.floor(keep / 2);
140
+ const head = lines.slice(0, headCount);
141
+ const tail = tailCount > 0 ? lines.slice(-tailCount) : [];
142
+ return { lines: [...head, marker, ...tail], truncated: true };
143
+ }
144
+
145
+ return { lines: lines.slice(0, maxLines), truncated: true };
146
+ }
147
+
148
+ function truncateText(text: string, maxChars: number, mode: TruncateMode) {
149
+ if (maxChars <= 0 || text.length <= maxChars) {
150
+ return { text, truncated: false };
151
+ }
152
+
153
+ if (mode === "end") {
154
+ return { text: text.slice(-maxChars), truncated: true };
155
+ }
156
+
157
+ if (mode === "middle" && maxChars > 10) {
158
+ const marker = "... (truncated) ...";
159
+ const keep = maxChars - marker.length;
160
+ if (keep > 0) {
161
+ const headCount = Math.ceil(keep / 2);
162
+ const tailCount = Math.floor(keep / 2);
163
+ return {
164
+ text: text.slice(0, headCount) + marker + text.slice(text.length - tailCount),
165
+ truncated: true,
166
+ };
167
+ }
168
+ }
169
+
170
+ return { text: text.slice(0, maxChars), truncated: true };
171
+ }
172
+
173
+ function buildPreview(content: string, options: { maxLines: number; maxChars: number; mode: TruncateMode }): PreviewResult {
174
+ const normalized = normalizeContent(content);
175
+ if (!normalized) {
176
+ return { preview: "", truncated: false, totalLines: 0, totalChars: 0, previewLines: 0, previewChars: 0 };
177
+ }
178
+
179
+ const lines = normalized.split("\n");
180
+ const totalLines = lines.length;
181
+ const totalChars = normalized.length;
182
+
183
+ const lineResult = truncateLines(lines, options.maxLines, options.mode);
184
+ const text = lineResult.lines.join("\n");
185
+ const charResult = truncateText(text, options.maxChars, options.mode);
186
+ const preview = charResult.text;
187
+
188
+ const previewLines = preview ? preview.split("\n").length : 0;
189
+ const previewChars = preview.length;
190
+
191
+ return {
192
+ preview,
193
+ truncated: lineResult.truncated || charResult.truncated,
194
+ totalLines,
195
+ totalChars,
196
+ previewLines,
197
+ previewChars,
198
+ };
199
+ }
200
+
201
+ function formatPreviewBlock(label: string, content: string, mode: TruncateMode) {
202
+ const result = buildPreview(content, {
203
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
204
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
205
+ mode,
206
+ });
207
+
208
+ if (!result.preview) {
209
+ return `${label}: empty.`;
210
+ }
211
+
212
+ const meta = `${label} (${result.totalLines} lines, ${result.totalChars} chars)`;
213
+ const note = result.truncated
214
+ ? `\n[preview truncated: showing ${result.previewLines}/${result.totalLines} lines, ${result.previewChars}/${result.totalChars} chars]`
215
+ : "";
216
+ return `${meta}\n\n${result.preview}${note}`;
217
+ }
218
+
219
+ function formatContextSection(label: string, content: string, mode: TruncateMode, maxLines: number, maxChars: number) {
220
+ const result = buildPreview(content, { maxLines, maxChars, mode });
221
+ if (!result.preview) {
222
+ return "";
223
+ }
224
+ const note = result.truncated
225
+ ? `\n\n[truncated: showing ${result.previewLines}/${result.totalLines} lines, ${result.previewChars}/${result.totalChars} chars]`
226
+ : "";
227
+ return `${label}\n\n${result.preview}${note}`;
228
+ }
229
+
230
+ function getQmdUpdateMode(): "background" | "manual" | "off" {
231
+ const mode = (process.env.PI_MEMORY_QMD_UPDATE ?? "background").toLowerCase();
232
+ if (mode === "manual" || mode === "off" || mode === "background") {
233
+ return mode;
234
+ }
235
+ return "background";
236
+ }
237
+
238
+ async function ensureQmdAvailableForUpdate(): Promise<boolean> {
239
+ if (qmdAvailable) return true;
240
+ if (getQmdUpdateMode() !== "background") return false;
241
+ qmdAvailable = await detectQmd();
242
+ return qmdAvailable;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Scratchpad helpers
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export interface ScratchpadItem {
250
+ done: boolean;
251
+ text: string;
252
+ meta: string; // the <!-- timestamp [session] --> comment
253
+ }
254
+
255
+ export function parseScratchpad(content: string): ScratchpadItem[] {
256
+ const items: ScratchpadItem[] = [];
257
+ const lines = content.split("\n");
258
+ for (let i = 0; i < lines.length; i++) {
259
+ const line = lines[i];
260
+ const match = line.match(/^- \[([ xX])\] (.+)$/);
261
+ if (match) {
262
+ let meta = "";
263
+ if (i > 0 && lines[i - 1].match(/^<!--.*-->$/)) {
264
+ meta = lines[i - 1];
265
+ }
266
+ items.push({
267
+ done: match[1].toLowerCase() === "x",
268
+ text: match[2],
269
+ meta,
270
+ });
271
+ }
272
+ }
273
+ return items;
274
+ }
275
+
276
+ export function serializeScratchpad(items: ScratchpadItem[]): string {
277
+ const lines: string[] = ["# Scratchpad", ""];
278
+ for (const item of items) {
279
+ if (item.meta) {
280
+ lines.push(item.meta);
281
+ }
282
+ const checkbox = item.done ? "[x]" : "[ ]";
283
+ lines.push(`- ${checkbox} ${item.text}`);
284
+ }
285
+ return lines.join("\n") + "\n";
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Context builder
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export function buildMemoryContext(searchResults?: string): string {
293
+ ensureDirs();
294
+ // Priority order: scratchpad > today's daily > search results > MEMORY.md > yesterday's daily
295
+ const sections: string[] = [];
296
+
297
+ const scratchpad = readFileSafe(SCRATCHPAD_FILE);
298
+ if (scratchpad?.trim()) {
299
+ const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
300
+ if (openItems.length > 0) {
301
+ const serialized = serializeScratchpad(openItems);
302
+ const section = formatContextSection(
303
+ "## SCRATCHPAD.md (working context)",
304
+ serialized,
305
+ "start",
306
+ CONTEXT_SCRATCHPAD_MAX_LINES,
307
+ CONTEXT_SCRATCHPAD_MAX_CHARS,
308
+ );
309
+ if (section) sections.push(section);
310
+ }
311
+ }
312
+
313
+ const today = todayStr();
314
+ const yesterday = yesterdayStr();
315
+
316
+ const todayContent = readFileSafe(dailyPath(today));
317
+ if (todayContent?.trim()) {
318
+ const section = formatContextSection(
319
+ `## Daily log: ${today} (today)`,
320
+ todayContent,
321
+ "end",
322
+ CONTEXT_DAILY_MAX_LINES,
323
+ CONTEXT_DAILY_MAX_CHARS,
324
+ );
325
+ if (section) sections.push(section);
326
+ }
327
+
328
+ if (searchResults?.trim()) {
329
+ const section = formatContextSection(
330
+ "## Relevant memories (auto-retrieved)",
331
+ searchResults,
332
+ "start",
333
+ CONTEXT_SEARCH_MAX_LINES,
334
+ CONTEXT_SEARCH_MAX_CHARS,
335
+ );
336
+ if (section) sections.push(section);
337
+ }
338
+
339
+ const longTerm = readFileSafe(MEMORY_FILE);
340
+ if (longTerm?.trim()) {
341
+ const section = formatContextSection(
342
+ "## MEMORY.md (long-term)",
343
+ longTerm,
344
+ "middle",
345
+ CONTEXT_LONG_TERM_MAX_LINES,
346
+ CONTEXT_LONG_TERM_MAX_CHARS,
347
+ );
348
+ if (section) sections.push(section);
349
+ }
350
+
351
+ const yesterdayContent = readFileSafe(dailyPath(yesterday));
352
+ if (yesterdayContent?.trim()) {
353
+ const section = formatContextSection(
354
+ `## Daily log: ${yesterday} (yesterday)`,
355
+ yesterdayContent,
356
+ "end",
357
+ CONTEXT_DAILY_MAX_LINES,
358
+ CONTEXT_DAILY_MAX_CHARS,
359
+ );
360
+ if (section) sections.push(section);
361
+ }
362
+
363
+ if (sections.length === 0) {
364
+ return "";
365
+ }
366
+
367
+ const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
368
+ if (context.length > CONTEXT_MAX_CHARS) {
369
+ const result = buildPreview(context, {
370
+ maxLines: Number.POSITIVE_INFINITY,
371
+ maxChars: CONTEXT_MAX_CHARS,
372
+ mode: "start",
373
+ });
374
+ const note = result.truncated
375
+ ? `\n\n[truncated overall context: showing ${result.previewChars}/${result.totalChars} chars]`
376
+ : "";
377
+ return `${result.preview}${note}`;
378
+ }
379
+
380
+ return context;
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // QMD integration
385
+ // ---------------------------------------------------------------------------
386
+
387
+ let qmdAvailable = false;
388
+ let updateTimer: ReturnType<typeof setTimeout> | null = null;
389
+
390
+ /** Set qmd availability flag (for testing). */
391
+ export function _setQmdAvailable(value: boolean) {
392
+ qmdAvailable = value;
393
+ }
394
+
395
+ /** Get current qmd availability flag (for testing). */
396
+ export function _getQmdAvailable(): boolean {
397
+ return qmdAvailable;
398
+ }
399
+
400
+ /** Get current update timer (for testing). */
401
+ export function _getUpdateTimer(): ReturnType<typeof setTimeout> | null {
402
+ return updateTimer;
403
+ }
404
+
405
+ /** Clear the update timer (for testing). */
406
+ export function _clearUpdateTimer() {
407
+ if (updateTimer) {
408
+ clearTimeout(updateTimer);
409
+ updateTimer = null;
410
+ }
411
+ }
412
+
413
+ const QMD_REPO_URL = "https://github.com/tobi/qmd";
414
+
415
+ export function qmdInstallInstructions(): string {
416
+ return [
417
+ "memory_search requires qmd.",
418
+ "",
419
+ "Install qmd (requires Bun):",
420
+ ` bun install -g ${QMD_REPO_URL}`,
421
+ " # ensure ~/.bun/bin is in your PATH",
422
+ "",
423
+ "Then set up the collection (one-time):",
424
+ ` qmd collection add ${MEMORY_DIR} --name pi-memory`,
425
+ " qmd embed",
426
+ ].join("\n");
427
+ }
428
+
429
+ /** Auto-create the pi-memory collection and path contexts in qmd. */
430
+ export async function setupQmdCollection(): Promise<boolean> {
431
+ try {
432
+ await new Promise<void>((resolve, reject) => {
433
+ execFile(
434
+ "qmd",
435
+ ["collection", "add", MEMORY_DIR, "--name", "pi-memory"],
436
+ { timeout: 10_000 },
437
+ (err) => (err ? reject(err) : resolve()),
438
+ );
439
+ });
440
+ } catch {
441
+ // Collection may already exist under a different name — not critical
442
+ return false;
443
+ }
444
+
445
+ // Add path contexts (best-effort, ignore errors)
446
+ const contexts: [string, string][] = [
447
+ ["/daily", "Daily append-only work logs organized by date"],
448
+ ["/", "Curated long-term memory: decisions, preferences, facts, lessons"],
449
+ ];
450
+ for (const [ctxPath, desc] of contexts) {
451
+ try {
452
+ await new Promise<void>((resolve, reject) => {
453
+ execFile(
454
+ "qmd",
455
+ ["context", "add", ctxPath, desc, "-c", "pi-memory"],
456
+ { timeout: 10_000 },
457
+ (err) => (err ? reject(err) : resolve()),
458
+ );
459
+ });
460
+ } catch {
461
+ // Ignore — context may already exist
462
+ }
463
+ }
464
+ return true;
465
+ }
466
+
467
+ export function detectQmd(): Promise<boolean> {
468
+ return new Promise((resolve) => {
469
+ // qmd doesn't reliably support --version; use a fast command that exits 0 when available.
470
+ execFile("qmd", ["status"], { timeout: 5_000 }, (err) => {
471
+ resolve(!err);
472
+ });
473
+ });
474
+ }
475
+
476
+ export function checkCollection(name: string): Promise<boolean> {
477
+ return new Promise((resolve) => {
478
+ execFile("qmd", ["collection", "list", "--json"], { timeout: 10_000 }, (err, stdout) => {
479
+ if (err) {
480
+ resolve(false);
481
+ return;
482
+ }
483
+ try {
484
+ const collections = JSON.parse(stdout);
485
+ if (Array.isArray(collections)) {
486
+ resolve(collections.some((c: any) => c.name === name || c === name));
487
+ } else {
488
+ // qmd may output an object with a collections array or similar
489
+ resolve(stdout.includes(name));
490
+ }
491
+ } catch {
492
+ // Fallback: just check if the name appears in the output
493
+ resolve(stdout.includes(name));
494
+ }
495
+ });
496
+ });
497
+ }
498
+
499
+ export function scheduleQmdUpdate() {
500
+ if (getQmdUpdateMode() !== "background") return;
501
+ if (!qmdAvailable) return;
502
+ if (updateTimer) clearTimeout(updateTimer);
503
+ updateTimer = setTimeout(() => {
504
+ updateTimer = null;
505
+ execFile("qmd", ["update"], { timeout: 30_000 }, () => {});
506
+ }, 500);
507
+ }
508
+
509
+ /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
510
+ export async function searchRelevantMemories(prompt: string): Promise<string> {
511
+ if (!qmdAvailable || !prompt.trim()) return "";
512
+
513
+ // Sanitize: strip control chars, limit to 200 chars for the search query
514
+ // eslint-disable-next-line no-control-regex
515
+ const sanitized = prompt.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 200);
516
+ if (!sanitized) return "";
517
+
518
+ try {
519
+ const hasCollection = await checkCollection("pi-memory");
520
+ if (!hasCollection) return "";
521
+
522
+ const results = await Promise.race([
523
+ runQmdSearch("keyword", sanitized, 3),
524
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 3_000)),
525
+ ]);
526
+
527
+ if (!results || results.length === 0) return "";
528
+
529
+ const snippets = results
530
+ .map((r) => {
531
+ const text = r.content ?? r.chunk ?? "";
532
+ if (!text.trim()) return null;
533
+ const filePart = r.path ? `_${r.path}_` : "";
534
+ return `${filePart}\n${text.trim()}`;
535
+ })
536
+ .filter(Boolean);
537
+
538
+ if (snippets.length === 0) return "";
539
+ return snippets.join("\n\n---\n\n");
540
+ } catch {
541
+ return "";
542
+ }
543
+ }
544
+
545
+ export interface QmdSearchResult {
546
+ path?: string;
547
+ score?: number;
548
+ content?: string;
549
+ chunk?: string;
550
+ title?: string;
551
+ [key: string]: unknown;
552
+ }
553
+
554
+ export function runQmdSearch(
555
+ mode: "keyword" | "semantic" | "deep",
556
+ query: string,
557
+ limit: number,
558
+ ): Promise<QmdSearchResult[]> {
559
+ const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
560
+ const args = [subcommand, "--json", "-c", "pi-memory", "-n", String(limit), query];
561
+
562
+ return new Promise((resolve, reject) => {
563
+ execFile("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
564
+ if (err) {
565
+ reject(new Error(stderr?.trim() || err.message));
566
+ return;
567
+ }
568
+ try {
569
+ const parsed = JSON.parse(stdout);
570
+ const results = Array.isArray(parsed) ? parsed : parsed.results ?? parsed.hits ?? [];
571
+ resolve(results);
572
+ } catch {
573
+ reject(new Error(`Failed to parse qmd output: ${stdout.slice(0, 200)}`));
574
+ }
575
+ });
576
+ });
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Extension entry point
581
+ // ---------------------------------------------------------------------------
582
+
583
+ export default function (pi: ExtensionAPI) {
584
+ // --- session_start: detect qmd, auto-setup collection ---
585
+ pi.on("session_start", async (_event, ctx) => {
586
+ qmdAvailable = await detectQmd();
587
+ if (!qmdAvailable) {
588
+ if (ctx.hasUI) {
589
+ ctx.ui.notify(qmdInstallInstructions(), "info");
590
+ }
591
+ return;
592
+ }
593
+
594
+ const hasCollection = await checkCollection("pi-memory");
595
+ if (!hasCollection) {
596
+ await setupQmdCollection();
597
+ }
598
+ });
599
+
600
+ // --- session_shutdown: clean up timer ---
601
+ pi.on("session_shutdown", async () => {
602
+ if (updateTimer) {
603
+ clearTimeout(updateTimer);
604
+ updateTimer = null;
605
+ }
606
+ });
607
+
608
+ // --- Inject memory context before every agent turn ---
609
+ pi.on("before_agent_start", async (event, _ctx) => {
610
+ const skipSearch = process.env.PI_MEMORY_NO_SEARCH === "1";
611
+ const searchResults = skipSearch ? "" : await searchRelevantMemories(event.prompt ?? "");
612
+ const memoryContext = buildMemoryContext(searchResults);
613
+ if (!memoryContext) return;
614
+
615
+ const memoryInstructions = [
616
+ "\n\n## Memory",
617
+ "The following memory files have been loaded. Use the memory_write tool to persist important information.",
618
+ "- Decisions, preferences, and durable facts \u2192 MEMORY.md",
619
+ "- Day-to-day notes and running context \u2192 daily/<YYYY-MM-DD>.md",
620
+ "- Things to fix later or keep in mind \u2192 scratchpad tool",
621
+ "- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).",
622
+ "- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.",
623
+ "- If someone says \"remember this,\" write it immediately.",
624
+ "",
625
+ memoryContext,
626
+ ].join("\n");
627
+
628
+ return {
629
+ systemPrompt: event.systemPrompt + memoryInstructions,
630
+ };
631
+ });
632
+
633
+ // --- Pre-compaction: auto-capture session handoff ---
634
+ pi.on("session_before_compact", async (_event, ctx) => {
635
+ ensureDirs();
636
+ const sid = shortSessionId(ctx.sessionManager.getSessionId());
637
+ const ts = nowTimestamp();
638
+ const parts: string[] = [];
639
+
640
+ // Capture open scratchpad items
641
+ const scratchpad = readFileSafe(SCRATCHPAD_FILE);
642
+ if (scratchpad?.trim()) {
643
+ const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
644
+ if (openItems.length > 0) {
645
+ parts.push("**Open scratchpad items:**");
646
+ for (const item of openItems) {
647
+ parts.push(`- [ ] ${item.text}`);
648
+ }
649
+ }
650
+ }
651
+
652
+ // Capture last few lines from today's daily log
653
+ const todayContent = readFileSafe(dailyPath(todayStr()));
654
+ if (todayContent?.trim()) {
655
+ const lines = todayContent.trim().split("\n");
656
+ const tail = lines.slice(-15).join("\n");
657
+ parts.push(`**Recent daily log context:**\n${tail}`);
658
+ }
659
+
660
+ if (parts.length === 0) return;
661
+
662
+ const handoff = [
663
+ `<!-- HANDOFF ${ts} [${sid}] -->`,
664
+ "## Session Handoff",
665
+ ...parts,
666
+ ].join("\n");
667
+
668
+ const filePath = dailyPath(todayStr());
669
+ const existing = readFileSafe(filePath) ?? "";
670
+ const separator = existing.trim() ? "\n\n" : "";
671
+ fs.writeFileSync(filePath, existing + separator + handoff, "utf-8");
672
+ await ensureQmdAvailableForUpdate();
673
+ scheduleQmdUpdate();
674
+ });
675
+
676
+ // --- memory_write tool ---
677
+ pi.registerTool({
678
+ name: "memory_write",
679
+ label: "Memory Write",
680
+ description: [
681
+ "Write to memory files. Two targets:",
682
+ "- 'long_term': Write to MEMORY.md (curated durable facts, decisions, preferences). Mode: 'append' or 'overwrite'.",
683
+ "- 'daily': Append to today's daily log (daily/<YYYY-MM-DD>.md). Always appends.",
684
+ "Use this when the user asks you to remember something, or when you learn important preferences/decisions.",
685
+ "Use #tags (e.g. #decision, #preference, #lesson, #bug) and [[links]] (e.g. [[auth-strategy]]) in content to improve searchability.",
686
+ ].join("\n"),
687
+ parameters: Type.Object({
688
+ target: StringEnum(["long_term", "daily"] as const, {
689
+ description: "Where to write: 'long_term' for MEMORY.md, 'daily' for today's daily log",
690
+ }),
691
+ content: Type.String({ description: "Content to write (Markdown)" }),
692
+ mode: Type.Optional(
693
+ StringEnum(["append", "overwrite"] as const, {
694
+ description: "Write mode for long_term target. Default: 'append'. Daily always appends.",
695
+ }),
696
+ ),
697
+ }),
698
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
699
+ ensureDirs();
700
+ const { target, content, mode } = params;
701
+ const sid = shortSessionId(ctx.sessionManager.getSessionId());
702
+ const ts = nowTimestamp();
703
+
704
+ if (target === "daily") {
705
+ const filePath = dailyPath(todayStr());
706
+ const existing = readFileSafe(filePath) ?? "";
707
+ const existingPreview = buildPreview(existing, {
708
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
709
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
710
+ mode: "end",
711
+ });
712
+ const existingSnippet = existingPreview.preview
713
+ ? `\n\n${formatPreviewBlock("Existing daily log preview", existing, "end")}`
714
+ : "\n\nDaily log was empty.";
715
+
716
+ const separator = existing.trim() ? "\n\n" : "";
717
+ const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
718
+ fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
719
+ await ensureQmdAvailableForUpdate();
720
+ scheduleQmdUpdate();
721
+ return {
722
+ content: [{ type: "text", text: `Appended to daily log: ${filePath}${existingSnippet}` }],
723
+ details: {
724
+ path: filePath,
725
+ target,
726
+ mode: "append",
727
+ sessionId: sid,
728
+ timestamp: ts,
729
+ qmdUpdateMode: getQmdUpdateMode(),
730
+ existingPreview,
731
+ },
732
+ };
733
+ }
734
+
735
+ // long_term
736
+ const existing = readFileSafe(MEMORY_FILE) ?? "";
737
+ const existingPreview = buildPreview(existing, {
738
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
739
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
740
+ mode: "middle",
741
+ });
742
+ const existingSnippet = existingPreview.preview
743
+ ? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", existing, "middle")}`
744
+ : "\n\nMEMORY.md was empty.";
745
+
746
+ if (mode === "overwrite") {
747
+ const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
748
+ fs.writeFileSync(MEMORY_FILE, stamped, "utf-8");
749
+ await ensureQmdAvailableForUpdate();
750
+ scheduleQmdUpdate();
751
+ return {
752
+ content: [{ type: "text", text: `Overwrote MEMORY.md${existingSnippet}` }],
753
+ details: {
754
+ path: MEMORY_FILE,
755
+ target,
756
+ mode: "overwrite",
757
+ sessionId: sid,
758
+ timestamp: ts,
759
+ qmdUpdateMode: getQmdUpdateMode(),
760
+ existingPreview,
761
+ },
762
+ };
763
+ }
764
+
765
+ // append (default)
766
+ const separator = existing.trim() ? "\n\n" : "";
767
+ const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
768
+ fs.writeFileSync(MEMORY_FILE, existing + separator + stamped, "utf-8");
769
+ await ensureQmdAvailableForUpdate();
770
+ scheduleQmdUpdate();
771
+ return {
772
+ content: [{ type: "text", text: `Appended to MEMORY.md${existingSnippet}` }],
773
+ details: {
774
+ path: MEMORY_FILE,
775
+ target,
776
+ mode: "append",
777
+ sessionId: sid,
778
+ timestamp: ts,
779
+ qmdUpdateMode: getQmdUpdateMode(),
780
+ existingPreview,
781
+ },
782
+ };
783
+ },
784
+ });
785
+
786
+ // --- scratchpad tool ---
787
+ pi.registerTool({
788
+ name: "scratchpad",
789
+ label: "Scratchpad",
790
+ description: [
791
+ "Manage a checklist of things to fix later or keep in mind. Actions:",
792
+ "- 'add': Add a new unchecked item (- [ ] text)",
793
+ "- 'done': Mark an item as done (- [x] text). Match by substring.",
794
+ "- 'undo': Uncheck a done item back to open. Match by substring.",
795
+ "- 'clear_done': Remove all checked items from the list.",
796
+ "- 'list': Show all items.",
797
+ ].join("\n"),
798
+ parameters: Type.Object({
799
+ action: StringEnum(["add", "done", "undo", "clear_done", "list"] as const, {
800
+ description: "What to do",
801
+ }),
802
+ text: Type.Optional(
803
+ Type.String({ description: "Item text for add, or substring to match for done/undo" }),
804
+ ),
805
+ }),
806
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
807
+ ensureDirs();
808
+ const { action, text } = params;
809
+ const sid = shortSessionId(ctx.sessionManager.getSessionId());
810
+ const ts = nowTimestamp();
811
+
812
+ const existing = readFileSafe(SCRATCHPAD_FILE) ?? "";
813
+ let items = parseScratchpad(existing);
814
+
815
+ if (action === "list") {
816
+ if (items.length === 0) {
817
+ return { content: [{ type: "text", text: "Scratchpad is empty." }], details: {} };
818
+ }
819
+ const serialized = serializeScratchpad(items);
820
+ const preview = buildPreview(serialized, {
821
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
822
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
823
+ mode: "start",
824
+ });
825
+ return {
826
+ content: [{ type: "text", text: formatPreviewBlock("Scratchpad preview", serialized, "start") }],
827
+ details: {
828
+ count: items.length,
829
+ open: items.filter((i) => !i.done).length,
830
+ preview,
831
+ },
832
+ };
833
+ }
834
+
835
+ if (action === "add") {
836
+ if (!text) {
837
+ return { content: [{ type: "text", text: "Error: 'text' is required for add." }], details: {} };
838
+ }
839
+ items.push({ done: false, text, meta: `<!-- ${ts} [${sid}] -->` });
840
+ const serialized = serializeScratchpad(items);
841
+ const preview = buildPreview(serialized, {
842
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
843
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
844
+ mode: "start",
845
+ });
846
+ fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
847
+ await ensureQmdAvailableForUpdate();
848
+ scheduleQmdUpdate();
849
+ return {
850
+ content: [
851
+ {
852
+ type: "text",
853
+ text: `Added: - [ ] ${text}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
854
+ },
855
+ ],
856
+ details: { action, sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), preview },
857
+ };
858
+ }
859
+
860
+ if (action === "done" || action === "undo") {
861
+ if (!text) {
862
+ return { content: [{ type: "text", text: `Error: 'text' is required for ${action}.` }], details: {} };
863
+ }
864
+ const needle = text.toLowerCase();
865
+ const targetDone = action === "done";
866
+ let matched = false;
867
+ for (const item of items) {
868
+ if (item.done !== targetDone && item.text.toLowerCase().includes(needle)) {
869
+ item.done = targetDone;
870
+ matched = true;
871
+ break;
872
+ }
873
+ }
874
+ if (!matched) {
875
+ return {
876
+ content: [{ type: "text", text: `No matching ${targetDone ? "open" : "done"} item found for: "${text}"` }],
877
+ details: {},
878
+ };
879
+ }
880
+ const serialized = serializeScratchpad(items);
881
+ const preview = buildPreview(serialized, {
882
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
883
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
884
+ mode: "start",
885
+ });
886
+ fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
887
+ await ensureQmdAvailableForUpdate();
888
+ scheduleQmdUpdate();
889
+ return {
890
+ content: [{ type: "text", text: `Updated.\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}` }],
891
+ details: { action, sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), preview },
892
+ };
893
+ }
894
+
895
+ if (action === "clear_done") {
896
+ const before = items.length;
897
+ items = items.filter((i) => !i.done);
898
+ const removed = before - items.length;
899
+ const serialized = serializeScratchpad(items);
900
+ const preview = buildPreview(serialized, {
901
+ maxLines: RESPONSE_PREVIEW_MAX_LINES,
902
+ maxChars: RESPONSE_PREVIEW_MAX_CHARS,
903
+ mode: "start",
904
+ });
905
+ fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
906
+ await ensureQmdAvailableForUpdate();
907
+ scheduleQmdUpdate();
908
+ return {
909
+ content: [
910
+ {
911
+ type: "text",
912
+ text: `Cleared ${removed} done item(s).\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
913
+ },
914
+ ],
915
+ details: { action, removed, qmdUpdateMode: getQmdUpdateMode(), preview },
916
+ };
917
+ }
918
+
919
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: {} };
920
+ },
921
+ });
922
+
923
+ // --- memory_read tool ---
924
+ pi.registerTool({
925
+ name: "memory_read",
926
+ label: "Memory Read",
927
+ description: [
928
+ "Read a memory file. Targets:",
929
+ "- 'long_term': Read MEMORY.md",
930
+ "- 'scratchpad': Read SCRATCHPAD.md",
931
+ "- 'daily': Read a specific day's log (default: today). Pass date as YYYY-MM-DD.",
932
+ "- 'list': List all daily log files.",
933
+ ].join("\n"),
934
+ parameters: Type.Object({
935
+ target: StringEnum(["long_term", "scratchpad", "daily", "list"] as const, {
936
+ description: "What to read",
937
+ }),
938
+ date: Type.Optional(
939
+ Type.String({ description: "Date for daily log (YYYY-MM-DD). Default: today." }),
940
+ ),
941
+ }),
942
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
943
+ ensureDirs();
944
+ const { target, date } = params;
945
+
946
+ if (target === "list") {
947
+ try {
948
+ const files = fs.readdirSync(DAILY_DIR).filter((f) => f.endsWith(".md")).sort().reverse();
949
+ if (files.length === 0) {
950
+ return { content: [{ type: "text", text: "No daily logs found." }], details: {} };
951
+ }
952
+ return {
953
+ content: [{ type: "text", text: `Daily logs:\n${files.map((f) => `- ${f}`).join("\n")}` }],
954
+ details: { files },
955
+ };
956
+ } catch {
957
+ return { content: [{ type: "text", text: "No daily logs directory." }], details: {} };
958
+ }
959
+ }
960
+
961
+ if (target === "daily") {
962
+ const d = date ?? todayStr();
963
+ const filePath = dailyPath(d);
964
+ const content = readFileSafe(filePath);
965
+ if (!content) {
966
+ return { content: [{ type: "text", text: `No daily log for ${d}.` }], details: {} };
967
+ }
968
+ return {
969
+ content: [{ type: "text", text: content }],
970
+ details: { path: filePath, date: d },
971
+ };
972
+ }
973
+
974
+ if (target === "scratchpad") {
975
+ const content = readFileSafe(SCRATCHPAD_FILE);
976
+ if (!content?.trim()) {
977
+ return { content: [{ type: "text", text: "SCRATCHPAD.md is empty or does not exist." }], details: {} };
978
+ }
979
+ return {
980
+ content: [{ type: "text", text: content }],
981
+ details: { path: SCRATCHPAD_FILE },
982
+ };
983
+ }
984
+
985
+ // long_term
986
+ const content = readFileSafe(MEMORY_FILE);
987
+ if (!content) {
988
+ return { content: [{ type: "text", text: "MEMORY.md is empty or does not exist." }], details: {} };
989
+ }
990
+ return {
991
+ content: [{ type: "text", text: content }],
992
+ details: { path: MEMORY_FILE },
993
+ };
994
+ },
995
+ });
996
+
997
+ // --- memory_search tool ---
998
+ pi.registerTool({
999
+ name: "memory_search",
1000
+ label: "Memory Search",
1001
+ description:
1002
+ "Search across all memory files (MEMORY.md, SCRATCHPAD.md, daily logs).\n" +
1003
+ "Modes:\n" +
1004
+ "- 'keyword' (default, ~30ms): Fast BM25 search. Best for specific terms, dates, names, #tags, [[links]].\n" +
1005
+ "- 'semantic' (~2s): Meaning-based search. Finds related concepts even with different wording.\n" +
1006
+ "- 'deep' (~10s): Hybrid search with reranking. Use when other modes don't find what you need.\n" +
1007
+ "If the first search doesn't find what you need, try rephrasing or switching modes. " +
1008
+ "Keyword mode is best for specific terms; semantic mode finds related concepts even with different wording.",
1009
+ parameters: Type.Object({
1010
+ query: Type.String({ description: "Search query" }),
1011
+ mode: Type.Optional(
1012
+ StringEnum(["keyword", "semantic", "deep"] as const, {
1013
+ description: "Search mode. Default: 'keyword'.",
1014
+ }),
1015
+ ),
1016
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
1017
+ }),
1018
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1019
+ if (!qmdAvailable) {
1020
+ // Re-check on demand in case qmd was installed after session start.
1021
+ qmdAvailable = await detectQmd();
1022
+ }
1023
+
1024
+ if (!qmdAvailable) {
1025
+ return {
1026
+ content: [
1027
+ {
1028
+ type: "text",
1029
+ text: qmdInstallInstructions(),
1030
+ },
1031
+ ],
1032
+ isError: true,
1033
+ details: {},
1034
+ };
1035
+ }
1036
+
1037
+ let hasCollection = await checkCollection("pi-memory");
1038
+ if (!hasCollection) {
1039
+ const created = await setupQmdCollection();
1040
+ if (created) {
1041
+ hasCollection = true;
1042
+ }
1043
+ }
1044
+ if (!hasCollection) {
1045
+ return {
1046
+ content: [
1047
+ {
1048
+ type: "text",
1049
+ text: "Could not set up qmd pi-memory collection. Check that qmd is working and the memory directory exists.",
1050
+ },
1051
+ ],
1052
+ isError: true,
1053
+ details: {},
1054
+ };
1055
+ }
1056
+
1057
+ const mode = params.mode ?? "keyword";
1058
+ const limit = params.limit ?? 5;
1059
+
1060
+ try {
1061
+ const results = await runQmdSearch(mode, params.query, limit);
1062
+
1063
+ if (results.length === 0) {
1064
+ return {
1065
+ content: [{ type: "text", text: `No results found for "${params.query}" (mode: ${mode}).` }],
1066
+ details: { mode, query: params.query, count: 0 },
1067
+ };
1068
+ }
1069
+
1070
+ const formatted = results
1071
+ .map((r, i) => {
1072
+ const parts: string[] = [`### Result ${i + 1}`];
1073
+ if (r.path) parts.push(`**File:** ${r.path}`);
1074
+ if (r.score != null) parts.push(`**Score:** ${r.score}`);
1075
+ const text = r.content ?? r.chunk ?? "";
1076
+ if (text) parts.push(`\n${text}`);
1077
+ return parts.join("\n");
1078
+ })
1079
+ .join("\n\n---\n\n");
1080
+
1081
+ return {
1082
+ content: [{ type: "text", text: formatted }],
1083
+ details: { mode, query: params.query, count: results.length },
1084
+ };
1085
+ } catch (err) {
1086
+ return {
1087
+ content: [
1088
+ {
1089
+ type: "text",
1090
+ text: `memory_search error: ${err instanceof Error ? err.message : String(err)}`,
1091
+ },
1092
+ ],
1093
+ isError: true,
1094
+ details: {},
1095
+ };
1096
+ }
1097
+ },
1098
+ });
1099
+ }