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/LICENSE +21 -0
- package/README.md +213 -0
- package/index.ts +1099 -0
- package/package.json +53 -0
- package/scripts/postinstall.cjs +44 -0
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
|
+
}
|