myagentmemory 0.4.12 → 0.4.14
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/README.md +118 -49
- package/dist/cli-spec.d.ts +25 -0
- package/dist/cli-spec.js +211 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +435 -71
- package/dist/completions.d.ts +13 -0
- package/dist/completions.js +429 -0
- package/dist/core.d.ts +22 -1
- package/dist/core.js +299 -62
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +444 -0
- package/dist/plugin-bootstrap.d.ts +190 -0
- package/dist/plugin-bootstrap.js +628 -0
- package/dist/plugin-host.d.ts +136 -0
- package/dist/plugin-host.js +98 -0
- package/dist/plugin-runtime.d.ts +21 -0
- package/dist/plugin-runtime.js +208 -0
- package/dist/plugin-service.d.ts +45 -0
- package/dist/plugin-service.js +395 -0
- package/docs/official-plugin-bootstrap.md +335 -0
- package/package.json +62 -11
- package/scripts/install-skills.sh +4 -1
- package/scripts/postinstall.cjs +23 -4
- package/src/cli-spec.ts +236 -0
- package/src/cli.ts +455 -82
- package/src/completions.ts +501 -0
- package/src/core.ts +314 -62
- package/src/hooks.ts +485 -0
- package/src/plugin-bootstrap.ts +931 -0
- package/src/plugin-host.ts +255 -0
- package/src/plugin-runtime.ts +296 -0
- package/src/plugin-service.ts +451 -0
- package/dist/agent-memory +0 -0
package/src/core.ts
CHANGED
|
@@ -152,6 +152,114 @@ function normalizeContent(content: string): string {
|
|
|
152
152
|
return content.trim();
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
156
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
157
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
158
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
159
|
+
/\b(?:api[_-]?key|access[_-]?token|secret)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{16,}["']?/gi,
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/** Redact common credential shapes before content reaches disk or agent context. */
|
|
163
|
+
export function redactSecrets(content: string): { content: string; redacted: boolean } {
|
|
164
|
+
let redactedContent = content;
|
|
165
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
166
|
+
redactedContent = redactedContent.replace(pattern, "[REDACTED_SECRET]");
|
|
167
|
+
}
|
|
168
|
+
return { content: redactedContent, redacted: redactedContent !== content };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isInactiveMemoryEntry(entry: string, now: Date): boolean {
|
|
172
|
+
const metadataHeader = entry.split(/\n\s*\n/, 1)[0];
|
|
173
|
+
if (/^\s*(?:[-*]\s*)?Trust:\s*untrusted\s*\.?\s*$/im.test(metadataHeader)) return true;
|
|
174
|
+
if (/^\s*(?:[-*]\s*)?Status:\s*(?:expired|superseded|revoked|retired)\s*\.?\s*$/im.test(metadataHeader)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const validUntil = metadataHeader.match(/^\s*(?:[-*]\s*)?Valid until:?\s+(\d{4}-\d{2}-\d{2})\s*\.?\s*$/im)?.[1];
|
|
179
|
+
if (validUntil && validUntil < now.toISOString().slice(0, 10)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function splitLogicalMemoryEntries(content: string): { entries: string[]; timestampDelimited: boolean } {
|
|
184
|
+
const normalized = content.replace(/^\uFEFF/, "");
|
|
185
|
+
const marker = /^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm;
|
|
186
|
+
const starts = [...normalized.matchAll(marker)].map((match) => match.index ?? 0);
|
|
187
|
+
if (starts.length === 0) {
|
|
188
|
+
return { entries: normalized.split(/\n\s*\n/), timestampDelimited: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const entries: string[] = [];
|
|
192
|
+
const preamble = normalized.slice(0, starts[0]).trim();
|
|
193
|
+
if (preamble) entries.push(preamble);
|
|
194
|
+
for (let i = 0; i < starts.length; i++) {
|
|
195
|
+
entries.push(normalized.slice(starts[i], starts[i + 1] ?? normalized.length).trim());
|
|
196
|
+
}
|
|
197
|
+
return { entries, timestampDelimited: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isUnmarkedInactiveHeader(entry: string, now: Date): boolean {
|
|
201
|
+
if (!isInactiveMemoryEntry(entry, now)) return false;
|
|
202
|
+
return entry.split("\n").every((line) => {
|
|
203
|
+
const trimmed = line.trim();
|
|
204
|
+
return (
|
|
205
|
+
!trimmed ||
|
|
206
|
+
/^#{1,6}\s+/.test(trimmed) ||
|
|
207
|
+
/^<!--.*-->$/.test(trimmed) ||
|
|
208
|
+
/^(?:[-*]\s*)?(?:Trust:|Status:|Valid until:?\s|Source:)/i.test(trimmed)
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Apply trust, lifecycle, and secret policy to complete logical write entries. */
|
|
214
|
+
export function filterMemoryForContext(content: string, now = new Date()): string {
|
|
215
|
+
const { entries, timestampDelimited } = splitLogicalMemoryEntries(content);
|
|
216
|
+
const activeEntries: string[] = [];
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const inactive = isInactiveMemoryEntry(entry, now);
|
|
219
|
+
if (!timestampDelimited && isUnmarkedInactiveHeader(entry, now)) {
|
|
220
|
+
// Without write markers there is no reliable boundary after a metadata-only
|
|
221
|
+
// header. Fail closed instead of treating the following body paragraphs as
|
|
222
|
+
// independent trusted entries.
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (!inactive) activeEntries.push(entry);
|
|
226
|
+
}
|
|
227
|
+
return redactSecrets(activeEntries.join("\n\n")).content.trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sanitizeSourceUri(sourceUri?: string): string | undefined {
|
|
231
|
+
if (!sourceUri?.trim()) return undefined;
|
|
232
|
+
const singleLine = [...sourceUri]
|
|
233
|
+
.map((char) => {
|
|
234
|
+
const code = char.charCodeAt(0);
|
|
235
|
+
return code <= 31 || code === 127 ? " " : char;
|
|
236
|
+
})
|
|
237
|
+
.join("")
|
|
238
|
+
.trim()
|
|
239
|
+
.slice(0, 2_048);
|
|
240
|
+
return redactSecrets(singleLine).content;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function escapeEntryMarkers(content: string): string {
|
|
244
|
+
return content.replace(
|
|
245
|
+
/^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm,
|
|
246
|
+
(line) => line.replace("<!--", "<!--"),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatStoredEntry(
|
|
251
|
+
content: string,
|
|
252
|
+
metadata: string,
|
|
253
|
+
sourceUri?: string,
|
|
254
|
+
): { entry: string; redacted: boolean } {
|
|
255
|
+
const safeContent = redactSecrets(escapeEntryMarkers(content));
|
|
256
|
+
const source = sanitizeSourceUri(sourceUri);
|
|
257
|
+
return {
|
|
258
|
+
entry: `${metadata}\n${safeContent.content}${source ? `\nSource: ${source}` : ""}`,
|
|
259
|
+
redacted: safeContent.redacted || (!!sourceUri && source !== sourceUri.trim()),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
155
263
|
export function truncateLines(lines: string[], maxLines: number, mode: TruncateMode) {
|
|
156
264
|
if (maxLines <= 0 || lines.length <= maxLines) {
|
|
157
265
|
return { lines, truncated: false };
|
|
@@ -328,7 +436,7 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
328
436
|
if (scratchpad?.trim()) {
|
|
329
437
|
const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
|
|
330
438
|
if (openItems.length > 0) {
|
|
331
|
-
const serialized = serializeScratchpad(openItems);
|
|
439
|
+
const serialized = filterMemoryForContext(serializeScratchpad(openItems));
|
|
332
440
|
const section = formatContextSection(
|
|
333
441
|
"## SCRATCHPAD.md (working context)",
|
|
334
442
|
serialized,
|
|
@@ -347,21 +455,23 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
347
455
|
const yesterday = yesterdayStr();
|
|
348
456
|
|
|
349
457
|
const todayContent = readFileSafe(dailyPath(today));
|
|
350
|
-
|
|
458
|
+
const safeTodayContent = todayContent ? filterMemoryForContext(todayContent) : "";
|
|
459
|
+
if (safeTodayContent) {
|
|
351
460
|
const section = formatContextSection(
|
|
352
461
|
`## Daily log: ${today} (today)`,
|
|
353
|
-
|
|
354
|
-
"
|
|
462
|
+
safeTodayContent,
|
|
463
|
+
"middle",
|
|
355
464
|
CONTEXT_DAILY_MAX_LINES,
|
|
356
465
|
CONTEXT_DAILY_MAX_CHARS,
|
|
357
466
|
);
|
|
358
467
|
if (section) sections.push(section);
|
|
359
468
|
}
|
|
360
469
|
|
|
361
|
-
|
|
470
|
+
const safeSearchResults = searchResults ? filterMemoryForContext(searchResults) : "";
|
|
471
|
+
if (safeSearchResults) {
|
|
362
472
|
const section = formatContextSection(
|
|
363
473
|
"## Relevant memories (auto-retrieved)",
|
|
364
|
-
|
|
474
|
+
safeSearchResults,
|
|
365
475
|
"start",
|
|
366
476
|
CONTEXT_SEARCH_MAX_LINES,
|
|
367
477
|
CONTEXT_SEARCH_MAX_CHARS,
|
|
@@ -370,10 +480,11 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
370
480
|
}
|
|
371
481
|
|
|
372
482
|
const longTerm = readFileSafe(MEMORY_FILE);
|
|
373
|
-
|
|
483
|
+
const safeLongTerm = longTerm ? filterMemoryForContext(longTerm) : "";
|
|
484
|
+
if (safeLongTerm) {
|
|
374
485
|
const section = formatContextSection(
|
|
375
486
|
"## MEMORY.md (long-term)",
|
|
376
|
-
|
|
487
|
+
safeLongTerm,
|
|
377
488
|
"middle",
|
|
378
489
|
CONTEXT_LONG_TERM_MAX_LINES,
|
|
379
490
|
CONTEXT_LONG_TERM_MAX_CHARS,
|
|
@@ -382,10 +493,11 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
382
493
|
}
|
|
383
494
|
|
|
384
495
|
const yesterdayContent = readFileSafe(dailyPath(yesterday));
|
|
385
|
-
|
|
496
|
+
const safeYesterdayContent = yesterdayContent ? filterMemoryForContext(yesterdayContent) : "";
|
|
497
|
+
if (safeYesterdayContent) {
|
|
386
498
|
const section = formatContextSection(
|
|
387
499
|
`## Daily log: ${yesterday} (yesterday)`,
|
|
388
|
-
|
|
500
|
+
safeYesterdayContent,
|
|
389
501
|
"end",
|
|
390
502
|
CONTEXT_DAILY_MAX_LINES,
|
|
391
503
|
CONTEXT_DAILY_MAX_CHARS,
|
|
@@ -399,15 +511,8 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
399
511
|
|
|
400
512
|
const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
|
|
401
513
|
if (context.length > CONTEXT_MAX_CHARS) {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
maxChars: CONTEXT_MAX_CHARS,
|
|
405
|
-
mode: "start",
|
|
406
|
-
});
|
|
407
|
-
const note = result.truncated
|
|
408
|
-
? `\n\n[truncated overall context: showing ${result.previewChars}/${result.totalChars} chars]`
|
|
409
|
-
: "";
|
|
410
|
-
return `${result.preview}${note}`;
|
|
514
|
+
const note = "\n\n[truncated overall context to 16000 chars]";
|
|
515
|
+
return context.slice(0, CONTEXT_MAX_CHARS - note.length).trimEnd() + note;
|
|
411
516
|
}
|
|
412
517
|
|
|
413
518
|
return context;
|
|
@@ -430,10 +535,11 @@ function buildTopicsContextSection(): string | null {
|
|
|
430
535
|
for (const file of topicFiles) {
|
|
431
536
|
const slug = file.replace(/\.md$/, "");
|
|
432
537
|
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
433
|
-
|
|
434
|
-
|
|
538
|
+
const safeContent = content ? filterMemoryForContext(content) : "";
|
|
539
|
+
if (!safeContent) continue;
|
|
540
|
+
const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
|
|
435
541
|
const title = titleMatch?.[1]?.trim() || slug;
|
|
436
|
-
entries.push(...parseTopicEntries(title, slug,
|
|
542
|
+
entries.push(...parseTopicEntries(title, slug, safeContent));
|
|
437
543
|
}
|
|
438
544
|
|
|
439
545
|
if (entries.length === 0) return null;
|
|
@@ -879,6 +985,12 @@ export function installSkills(): InstallSkillsReport {
|
|
|
879
985
|
destDir: path.join(homeDir, ".cursor", "skills", "agent-memory"),
|
|
880
986
|
homeMarker: path.join(homeDir, ".cursor"),
|
|
881
987
|
},
|
|
988
|
+
{
|
|
989
|
+
label: "Agent CLI skill",
|
|
990
|
+
srcDir: path.join(skillsDir, "agent"),
|
|
991
|
+
destDir: path.join(homeDir, ".agents", "skills", "agent-memory"),
|
|
992
|
+
homeMarker: path.join(homeDir, ".agents"),
|
|
993
|
+
},
|
|
882
994
|
];
|
|
883
995
|
|
|
884
996
|
const detected: Array<{ label: string; homeMarker: string }> = [];
|
|
@@ -949,6 +1061,7 @@ export function uninstallSkills(): UninstallSkillsReport {
|
|
|
949
1061
|
{ label: "Claude Code skill", destDir: path.join(homeDir, ".claude", "skills", "agent-memory") },
|
|
950
1062
|
{ label: "Codex skill", destDir: path.join(homeDir, ".codex", "skills", "agent-memory") },
|
|
951
1063
|
{ label: "Cursor skill", destDir: path.join(homeDir, ".cursor", "skills", "agent-memory") },
|
|
1064
|
+
{ label: "Agent CLI skill", destDir: path.join(homeDir, ".agents", "skills", "agent-memory") },
|
|
952
1065
|
];
|
|
953
1066
|
|
|
954
1067
|
const removed: Array<{ label: string; path: string }> = [];
|
|
@@ -1049,9 +1162,61 @@ export async function getQmdHealth(): Promise<QmdHealthInfo | null> {
|
|
|
1049
1162
|
});
|
|
1050
1163
|
}
|
|
1051
1164
|
|
|
1165
|
+
function resolveCaseInsensitivePath(root: string, relativePath: string): string | null {
|
|
1166
|
+
let current = root;
|
|
1167
|
+
for (const segment of relativePath.split(/[/\\]+/).filter(Boolean)) {
|
|
1168
|
+
if (segment === "." || segment === "..") return null;
|
|
1169
|
+
let children: string[];
|
|
1170
|
+
try {
|
|
1171
|
+
children = fs.readdirSync(current);
|
|
1172
|
+
} catch {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
const child =
|
|
1176
|
+
children.find((name) => name === segment) ??
|
|
1177
|
+
children.find((name) => name.toLowerCase() === segment.toLowerCase());
|
|
1178
|
+
if (!child) return null;
|
|
1179
|
+
current = path.join(current, child);
|
|
1180
|
+
}
|
|
1181
|
+
return current;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function resolveQmdSourcePath(filePath: string): string | null {
|
|
1185
|
+
const qmdUri = filePath.match(/^qmd:\/\/([^/]+)\/?(.*)$/i);
|
|
1186
|
+
let relativePath = qmdUri ? qmdUri[2] : filePath;
|
|
1187
|
+
if (relativePath.toLowerCase().startsWith(`${QMD_COLLECTION_NAME.toLowerCase()}/`)) {
|
|
1188
|
+
relativePath = relativePath.slice(QMD_COLLECTION_NAME.length + 1);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const root = path.resolve(MEMORY_DIR);
|
|
1192
|
+
if (path.isAbsolute(relativePath)) {
|
|
1193
|
+
const candidate = path.resolve(relativePath);
|
|
1194
|
+
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null;
|
|
1195
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
1196
|
+
}
|
|
1197
|
+
return resolveCaseInsensitivePath(root, relativePath.replace(/^\/+/, ""));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function qmdResultPassesSourcePolicy(filePath: string | undefined, snippet: string): boolean {
|
|
1201
|
+
if (!filePath) return false;
|
|
1202
|
+
const sourcePath = resolveQmdSourcePath(filePath);
|
|
1203
|
+
if (!sourcePath) return false;
|
|
1204
|
+
const source = readFileSafe(sourcePath);
|
|
1205
|
+
if (!source) return false;
|
|
1206
|
+
|
|
1207
|
+
const activeSource = filterMemoryForContext(source);
|
|
1208
|
+
const snippetLines = snippet
|
|
1209
|
+
.split("\n")
|
|
1210
|
+
.map((line) => line.trim())
|
|
1211
|
+
.filter((line) => line.length >= 8);
|
|
1212
|
+
return snippetLines.length > 0 && snippetLines.every((line) => activeSource.includes(line));
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1052
1215
|
/** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
|
|
1053
1216
|
export async function searchRelevantMemories(prompt: string): Promise<string> {
|
|
1054
1217
|
if (!qmdAvailable || !prompt.trim()) return "";
|
|
1218
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1219
|
+
const controller = new AbortController();
|
|
1055
1220
|
|
|
1056
1221
|
// Sanitize: strip control chars, limit to 200 chars for the search query
|
|
1057
1222
|
const sanitized = prompt
|
|
@@ -1066,19 +1231,25 @@ export async function searchRelevantMemories(prompt: string): Promise<string> {
|
|
|
1066
1231
|
if (!hasCollection) return "";
|
|
1067
1232
|
|
|
1068
1233
|
const results = await Promise.race([
|
|
1069
|
-
runQmdSearch("keyword", sanitized, 3),
|
|
1070
|
-
new Promise<never>((_, reject) =>
|
|
1234
|
+
runQmdSearch("keyword", sanitized, 3, { signal: controller.signal }),
|
|
1235
|
+
new Promise<never>((_, reject) => {
|
|
1236
|
+
timer = setTimeout(() => {
|
|
1237
|
+
controller.abort();
|
|
1238
|
+
reject(new Error("timeout"));
|
|
1239
|
+
}, 3_000);
|
|
1240
|
+
}),
|
|
1071
1241
|
]);
|
|
1072
1242
|
|
|
1073
1243
|
if (!results || results.results.length === 0) return "";
|
|
1074
1244
|
|
|
1075
1245
|
const snippets = results.results
|
|
1076
1246
|
.map((r) => {
|
|
1077
|
-
const text = getQmdResultText(r);
|
|
1078
|
-
if (!text
|
|
1247
|
+
const text = filterMemoryForContext(getQmdResultText(r));
|
|
1248
|
+
if (!text) return null;
|
|
1079
1249
|
const filePath = getQmdResultPath(r);
|
|
1250
|
+
if (!qmdResultPassesSourcePolicy(filePath, text)) return null;
|
|
1080
1251
|
const filePart = filePath ? `_${filePath}_` : "";
|
|
1081
|
-
return filePart ? `${filePart}\n${text
|
|
1252
|
+
return filePart ? `${filePart}\n${text}` : text;
|
|
1082
1253
|
})
|
|
1083
1254
|
.filter(Boolean);
|
|
1084
1255
|
|
|
@@ -1086,12 +1257,15 @@ export async function searchRelevantMemories(prompt: string): Promise<string> {
|
|
|
1086
1257
|
return snippets.join("\n\n---\n\n");
|
|
1087
1258
|
} catch {
|
|
1088
1259
|
return "";
|
|
1260
|
+
} finally {
|
|
1261
|
+
clearTimeout(timer);
|
|
1089
1262
|
}
|
|
1090
1263
|
}
|
|
1091
1264
|
|
|
1092
1265
|
export interface QmdSearchResult {
|
|
1093
1266
|
path?: string;
|
|
1094
1267
|
file?: string;
|
|
1268
|
+
context?: string;
|
|
1095
1269
|
score?: number;
|
|
1096
1270
|
content?: string;
|
|
1097
1271
|
chunk?: string;
|
|
@@ -1105,7 +1279,20 @@ export function getQmdResultPath(r: QmdSearchResult): string | undefined {
|
|
|
1105
1279
|
}
|
|
1106
1280
|
|
|
1107
1281
|
export function getQmdResultText(r: QmdSearchResult): string {
|
|
1108
|
-
|
|
1282
|
+
const text = r.content ?? r.chunk ?? r.snippet ?? "";
|
|
1283
|
+
const folderContext = r.context?.trim();
|
|
1284
|
+
let normalized = text.trimStart();
|
|
1285
|
+
if (folderContext) {
|
|
1286
|
+
const prefix = `Folder Context: ${folderContext}`;
|
|
1287
|
+
if (normalized.startsWith(prefix)) {
|
|
1288
|
+
const remainder = normalized.slice(prefix.length);
|
|
1289
|
+
if (/^(?:\r?\n){2}/.test(remainder)) {
|
|
1290
|
+
normalized = remainder.replace(/^(?:\r?\n){2}/, "");
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return normalized.replace(/^@@ -\d+(?:,\d+)?(?: \+\d+(?:,\d+)?)? @@ \(\d+ before, \d+ after\)(?:\r?\n){1,2}/, "");
|
|
1109
1296
|
}
|
|
1110
1297
|
|
|
1111
1298
|
function stripAnsi(text: string): string {
|
|
@@ -1139,12 +1326,13 @@ export function runQmdSearch(
|
|
|
1139
1326
|
mode: "keyword" | "semantic" | "deep",
|
|
1140
1327
|
query: string,
|
|
1141
1328
|
limit: number,
|
|
1329
|
+
options: { signal?: AbortSignal } = {},
|
|
1142
1330
|
): Promise<{ results: QmdSearchResult[]; stderr: string }> {
|
|
1143
1331
|
const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
|
|
1144
1332
|
const args = [subcommand, "--json", "-c", QMD_COLLECTION_NAME, "-n", String(limit), query];
|
|
1145
1333
|
|
|
1146
1334
|
return new Promise((resolve, reject) => {
|
|
1147
|
-
execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
|
|
1335
|
+
execFileFn("qmd", args, { timeout: 60_000, signal: options.signal }, (err, stdout, stderr) => {
|
|
1148
1336
|
if (err) {
|
|
1149
1337
|
reject(new Error(stderr?.trim() || err.message));
|
|
1150
1338
|
return;
|
|
@@ -1164,6 +1352,39 @@ export function runQmdSearch(
|
|
|
1164
1352
|
});
|
|
1165
1353
|
}
|
|
1166
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* Best-effort check of whether vector embeddings are actually usable for
|
|
1357
|
+
* semantic/deep search right now. Runs a tiny semantic probe and looks for
|
|
1358
|
+
* qmd's "need embeddings" warning. Bounded by a short timeout because the very
|
|
1359
|
+
* first semantic query can trigger a model download — returns "unknown" rather
|
|
1360
|
+
* than blocking on it. "ready" means the probe ran without the warning; it does
|
|
1361
|
+
* not prove the index has content.
|
|
1362
|
+
*/
|
|
1363
|
+
export async function probeEmbeddings(): Promise<"ready" | "missing" | "unknown"> {
|
|
1364
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1365
|
+
// Abort the underlying qmd child when the timeout fires so it does not keep
|
|
1366
|
+
// the event loop open until its own 60s timeout and hang the CLI.
|
|
1367
|
+
const controller = new AbortController();
|
|
1368
|
+
try {
|
|
1369
|
+
const { stderr } = await Promise.race([
|
|
1370
|
+
runQmdSearch("semantic", "memory", 1, { signal: controller.signal }),
|
|
1371
|
+
new Promise<never>((_, reject) => {
|
|
1372
|
+
timer = setTimeout(() => {
|
|
1373
|
+
controller.abort();
|
|
1374
|
+
reject(new Error("timeout"));
|
|
1375
|
+
}, 4_000);
|
|
1376
|
+
}),
|
|
1377
|
+
]);
|
|
1378
|
+
return /need embeddings/i.test(stderr ?? "") ? "missing" : "ready";
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1381
|
+
if (/need embeddings/i.test(msg)) return "missing";
|
|
1382
|
+
return "unknown";
|
|
1383
|
+
} finally {
|
|
1384
|
+
clearTimeout(timer);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1167
1388
|
// ---------------------------------------------------------------------------
|
|
1168
1389
|
// Standalone tool functions
|
|
1169
1390
|
// ---------------------------------------------------------------------------
|
|
@@ -1175,36 +1396,46 @@ export interface ToolResult {
|
|
|
1175
1396
|
}
|
|
1176
1397
|
|
|
1177
1398
|
export async function memoryWrite(params: {
|
|
1399
|
+
directory?: string;
|
|
1178
1400
|
target?: "long_term" | "daily" | "topic";
|
|
1179
1401
|
content: string;
|
|
1180
1402
|
mode?: "append" | "overwrite";
|
|
1181
1403
|
sessionId?: string;
|
|
1182
1404
|
topic?: string;
|
|
1183
1405
|
date?: string;
|
|
1406
|
+
sourceUri?: string;
|
|
1184
1407
|
}): Promise<ToolResult> {
|
|
1185
|
-
|
|
1408
|
+
const memoryDir = params.directory ? path.resolve(params.directory) : getMemoryDir();
|
|
1409
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
1410
|
+
fs.mkdirSync(path.join(memoryDir, "daily"), { recursive: true });
|
|
1411
|
+
fs.mkdirSync(path.join(memoryDir, "topics"), { recursive: true });
|
|
1412
|
+
const scheduleSearchRefresh = async () => {
|
|
1413
|
+
if (path.resolve(getMemoryDir()) !== memoryDir) return;
|
|
1414
|
+
await ensureQmdAvailableForUpdate();
|
|
1415
|
+
scheduleQmdUpdate();
|
|
1416
|
+
};
|
|
1186
1417
|
const target = params.target ?? "daily";
|
|
1187
1418
|
const { content, mode } = params;
|
|
1188
1419
|
const sid = shortSessionId(params.sessionId ?? "cli");
|
|
1189
1420
|
const ts = nowTimestamp();
|
|
1190
1421
|
|
|
1191
1422
|
if (target === "daily") {
|
|
1192
|
-
const filePath =
|
|
1423
|
+
const filePath = path.join(memoryDir, "daily", `${params.date?.trim() || todayStr()}.md`);
|
|
1193
1424
|
const existing = readFileSafe(filePath) ?? "";
|
|
1194
|
-
const
|
|
1425
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1426
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
1195
1427
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1196
1428
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1197
1429
|
mode: "end",
|
|
1198
1430
|
});
|
|
1199
1431
|
const existingSnippet = existingPreview.preview
|
|
1200
|
-
? `\n\n${formatPreviewBlock("Existing daily log preview",
|
|
1432
|
+
? `\n\n${formatPreviewBlock("Existing daily log preview", safeExisting, "end")}`
|
|
1201
1433
|
: "\n\nDaily log was empty.";
|
|
1202
1434
|
|
|
1203
1435
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1204
|
-
const
|
|
1205
|
-
fs.writeFileSync(filePath, existing + separator +
|
|
1206
|
-
await
|
|
1207
|
-
scheduleQmdUpdate();
|
|
1436
|
+
const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
|
|
1437
|
+
fs.writeFileSync(filePath, existing + separator + stored.entry, "utf-8");
|
|
1438
|
+
await scheduleSearchRefresh();
|
|
1208
1439
|
return {
|
|
1209
1440
|
text: `Appended to daily log: ${filePath}${existingSnippet}`,
|
|
1210
1441
|
details: {
|
|
@@ -1213,6 +1444,8 @@ export async function memoryWrite(params: {
|
|
|
1213
1444
|
mode: "append",
|
|
1214
1445
|
sessionId: sid,
|
|
1215
1446
|
timestamp: ts,
|
|
1447
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1448
|
+
redacted: stored.redacted,
|
|
1216
1449
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1217
1450
|
existingPreview,
|
|
1218
1451
|
},
|
|
@@ -1228,25 +1461,29 @@ export async function memoryWrite(params: {
|
|
|
1228
1461
|
if (!slug) {
|
|
1229
1462
|
return { text: "Error: 'topic' must include at least one letter or number.", details: {}, isError: true };
|
|
1230
1463
|
}
|
|
1231
|
-
const filePath =
|
|
1464
|
+
const filePath = path.join(memoryDir, "topics", `${slug}.md`);
|
|
1232
1465
|
const existing = readFileSafe(filePath) ?? "";
|
|
1233
|
-
const
|
|
1466
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1467
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
1234
1468
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1235
1469
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1236
1470
|
mode: "end",
|
|
1237
1471
|
});
|
|
1238
1472
|
const existingSnippet = existingPreview.preview
|
|
1239
|
-
? `\n\n${formatPreviewBlock("Existing topic preview",
|
|
1473
|
+
? `\n\n${formatPreviewBlock("Existing topic preview", safeExisting, "end")}`
|
|
1240
1474
|
: "\n\nTopic file was empty.";
|
|
1241
1475
|
|
|
1242
1476
|
const linkDate = params.date?.trim() || todayStr();
|
|
1243
1477
|
const header = `# Topic: ${topic}\n\n<!-- created: ${ts} [${sid}] -->\n`;
|
|
1244
1478
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1245
1479
|
const base = existing.trim() ? existing : header.trimEnd();
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1480
|
+
const stored = formatStoredEntry(
|
|
1481
|
+
`${content.trim()}\nDaily: [[${linkDate}]]`,
|
|
1482
|
+
`<!-- ${ts} [${sid}] -->`,
|
|
1483
|
+
params.sourceUri,
|
|
1484
|
+
);
|
|
1485
|
+
fs.writeFileSync(filePath, `${base}${separator}${stored.entry}`, "utf-8");
|
|
1486
|
+
await scheduleSearchRefresh();
|
|
1250
1487
|
return {
|
|
1251
1488
|
text: `Appended to topic: ${filePath}${existingSnippet}`,
|
|
1252
1489
|
details: {
|
|
@@ -1258,6 +1495,8 @@ export async function memoryWrite(params: {
|
|
|
1258
1495
|
topic,
|
|
1259
1496
|
slug,
|
|
1260
1497
|
date: linkDate,
|
|
1498
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1499
|
+
redacted: stored.redacted,
|
|
1261
1500
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1262
1501
|
existingPreview,
|
|
1263
1502
|
},
|
|
@@ -1265,22 +1504,22 @@ export async function memoryWrite(params: {
|
|
|
1265
1504
|
}
|
|
1266
1505
|
|
|
1267
1506
|
// long_term
|
|
1268
|
-
const memFile =
|
|
1507
|
+
const memFile = path.join(memoryDir, "MEMORY.md");
|
|
1269
1508
|
const existing = readFileSafe(memFile) ?? "";
|
|
1270
|
-
const
|
|
1509
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1510
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
1271
1511
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1272
1512
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1273
1513
|
mode: "middle",
|
|
1274
1514
|
});
|
|
1275
1515
|
const existingSnippet = existingPreview.preview
|
|
1276
|
-
? `\n\n${formatPreviewBlock("Existing MEMORY.md preview",
|
|
1516
|
+
? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", safeExisting, "middle")}`
|
|
1277
1517
|
: "\n\nMEMORY.md was empty.";
|
|
1278
1518
|
|
|
1279
1519
|
if (mode === "overwrite") {
|
|
1280
|
-
const
|
|
1281
|
-
fs.writeFileSync(memFile,
|
|
1282
|
-
await
|
|
1283
|
-
scheduleQmdUpdate();
|
|
1520
|
+
const stored = formatStoredEntry(content, `<!-- last updated: ${ts} [${sid}] -->`, params.sourceUri);
|
|
1521
|
+
fs.writeFileSync(memFile, stored.entry, "utf-8");
|
|
1522
|
+
await scheduleSearchRefresh();
|
|
1284
1523
|
return {
|
|
1285
1524
|
text: `Overwrote MEMORY.md${existingSnippet}`,
|
|
1286
1525
|
details: {
|
|
@@ -1289,6 +1528,8 @@ export async function memoryWrite(params: {
|
|
|
1289
1528
|
mode: "overwrite",
|
|
1290
1529
|
sessionId: sid,
|
|
1291
1530
|
timestamp: ts,
|
|
1531
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1532
|
+
redacted: stored.redacted,
|
|
1292
1533
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1293
1534
|
existingPreview,
|
|
1294
1535
|
},
|
|
@@ -1297,10 +1538,9 @@ export async function memoryWrite(params: {
|
|
|
1297
1538
|
|
|
1298
1539
|
// append (default)
|
|
1299
1540
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1300
|
-
const
|
|
1301
|
-
fs.writeFileSync(memFile, existing + separator +
|
|
1302
|
-
await
|
|
1303
|
-
scheduleQmdUpdate();
|
|
1541
|
+
const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
|
|
1542
|
+
fs.writeFileSync(memFile, existing + separator + stored.entry, "utf-8");
|
|
1543
|
+
await scheduleSearchRefresh();
|
|
1304
1544
|
return {
|
|
1305
1545
|
text: `Appended to MEMORY.md${existingSnippet}`,
|
|
1306
1546
|
details: {
|
|
@@ -1309,6 +1549,8 @@ export async function memoryWrite(params: {
|
|
|
1309
1549
|
mode: "append",
|
|
1310
1550
|
sessionId: sid,
|
|
1311
1551
|
timestamp: ts,
|
|
1552
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1553
|
+
redacted: stored.redacted,
|
|
1312
1554
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1313
1555
|
existingPreview,
|
|
1314
1556
|
},
|
|
@@ -1327,7 +1569,11 @@ export async function scratchpadAction(params: {
|
|
|
1327
1569
|
const spFile = getScratchpadFile();
|
|
1328
1570
|
|
|
1329
1571
|
const existing = readFileSafe(spFile) ?? "";
|
|
1330
|
-
let items = parseScratchpad(existing)
|
|
1572
|
+
let items = parseScratchpad(existing).map((item) => ({
|
|
1573
|
+
...item,
|
|
1574
|
+
text: redactSecrets(item.text).content,
|
|
1575
|
+
meta: redactSecrets(item.meta).content,
|
|
1576
|
+
}));
|
|
1331
1577
|
|
|
1332
1578
|
if (action === "list") {
|
|
1333
1579
|
if (items.length === 0) {
|
|
@@ -1353,7 +1599,8 @@ export async function scratchpadAction(params: {
|
|
|
1353
1599
|
if (!text) {
|
|
1354
1600
|
return { text: "Error: 'text' is required for add.", details: {} };
|
|
1355
1601
|
}
|
|
1356
|
-
|
|
1602
|
+
const safeText = redactSecrets(text).content;
|
|
1603
|
+
items.push({ done: false, text: safeText, meta: `<!-- ${ts} [${sid}] -->` });
|
|
1357
1604
|
const serialized = serializeScratchpad(items);
|
|
1358
1605
|
const preview = buildPreview(serialized, {
|
|
1359
1606
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
@@ -1364,7 +1611,7 @@ export async function scratchpadAction(params: {
|
|
|
1364
1611
|
await ensureQmdAvailableForUpdate();
|
|
1365
1612
|
scheduleQmdUpdate();
|
|
1366
1613
|
return {
|
|
1367
|
-
text: `Added: - [ ] ${
|
|
1614
|
+
text: `Added: - [ ] ${safeText}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
|
|
1368
1615
|
details: {
|
|
1369
1616
|
action,
|
|
1370
1617
|
sessionId: sid,
|
|
@@ -1864,7 +2111,9 @@ export async function distilMemories(params?: { dryRun?: boolean; sessionId?: st
|
|
|
1864
2111
|
const date = file.replace(/\.md$/, "");
|
|
1865
2112
|
const content = readFileSafe(path.join(DAILY_DIR, file));
|
|
1866
2113
|
if (!content?.trim()) continue;
|
|
1867
|
-
|
|
2114
|
+
const safeContent = filterMemoryForContext(content);
|
|
2115
|
+
if (!safeContent) continue;
|
|
2116
|
+
allEntries.push(...parseDailyEntries(date, safeContent));
|
|
1868
2117
|
}
|
|
1869
2118
|
const topicEntriesByTopic = new Map<string, TopicEntry[]>();
|
|
1870
2119
|
let totalTopicEntries = 0;
|
|
@@ -1872,9 +2121,11 @@ export async function distilMemories(params?: { dryRun?: boolean; sessionId?: st
|
|
|
1872
2121
|
const slug = file.replace(/\.md$/, "");
|
|
1873
2122
|
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
1874
2123
|
if (!content?.trim()) continue;
|
|
1875
|
-
const
|
|
2124
|
+
const safeContent = filterMemoryForContext(content);
|
|
2125
|
+
if (!safeContent) continue;
|
|
2126
|
+
const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
|
|
1876
2127
|
const title = titleMatch?.[1]?.trim() || slug;
|
|
1877
|
-
const entries = parseTopicEntries(title, slug,
|
|
2128
|
+
const entries = parseTopicEntries(title, slug, safeContent);
|
|
1878
2129
|
if (entries.length === 0) continue;
|
|
1879
2130
|
totalTopicEntries += entries.length;
|
|
1880
2131
|
allEntries.push(...entries);
|
|
@@ -1922,7 +2173,8 @@ export async function distilMemories(params?: { dryRun?: boolean; sessionId?: st
|
|
|
1922
2173
|
let pinnedSection = "";
|
|
1923
2174
|
const existingMemory = readFileSafe(MEMORY_FILE);
|
|
1924
2175
|
if (existingMemory) {
|
|
1925
|
-
const
|
|
2176
|
+
const safeExistingMemory = filterMemoryForContext(existingMemory);
|
|
2177
|
+
const pinnedMatch = safeExistingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
|
|
1926
2178
|
if (pinnedMatch) {
|
|
1927
2179
|
pinnedSection = pinnedMatch[1].trim();
|
|
1928
2180
|
}
|