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/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("<!--", "&lt;!--"),
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
- if (todayContent?.trim()) {
458
+ const safeTodayContent = todayContent ? filterMemoryForContext(todayContent) : "";
459
+ if (safeTodayContent) {
351
460
  const section = formatContextSection(
352
461
  `## Daily log: ${today} (today)`,
353
- todayContent,
354
- "end",
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
- if (searchResults?.trim()) {
470
+ const safeSearchResults = searchResults ? filterMemoryForContext(searchResults) : "";
471
+ if (safeSearchResults) {
362
472
  const section = formatContextSection(
363
473
  "## Relevant memories (auto-retrieved)",
364
- searchResults,
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
- if (longTerm?.trim()) {
483
+ const safeLongTerm = longTerm ? filterMemoryForContext(longTerm) : "";
484
+ if (safeLongTerm) {
374
485
  const section = formatContextSection(
375
486
  "## MEMORY.md (long-term)",
376
- longTerm,
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
- if (yesterdayContent?.trim()) {
496
+ const safeYesterdayContent = yesterdayContent ? filterMemoryForContext(yesterdayContent) : "";
497
+ if (safeYesterdayContent) {
386
498
  const section = formatContextSection(
387
499
  `## Daily log: ${yesterday} (yesterday)`,
388
- yesterdayContent,
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 result = buildPreview(context, {
403
- maxLines: Number.POSITIVE_INFINITY,
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
- if (!content?.trim()) continue;
434
- const titleMatch = content.match(/^# Topic:\s*(.+)$/m);
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, content));
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) => setTimeout(() => reject(new Error("timeout")), 3_000)),
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.trim()) return null;
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.trim()}` : text.trim();
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
- return r.content ?? r.chunk ?? r.snippet ?? "";
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
- ensureDirs();
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 = dailyPath(todayStr());
1423
+ const filePath = path.join(memoryDir, "daily", `${params.date?.trim() || todayStr()}.md`);
1193
1424
  const existing = readFileSafe(filePath) ?? "";
1194
- const existingPreview = buildPreview(existing, {
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", existing, "end")}`
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 stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
1205
- fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
1206
- await ensureQmdAvailableForUpdate();
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 = topicPath(slug);
1464
+ const filePath = path.join(memoryDir, "topics", `${slug}.md`);
1232
1465
  const existing = readFileSafe(filePath) ?? "";
1233
- const existingPreview = buildPreview(existing, {
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", existing, "end")}`
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 stamped = `<!-- ${ts} [${sid}] -->\n${content.trim()}\nDaily: [[${linkDate}]]`;
1247
- fs.writeFileSync(filePath, `${base}${separator}${stamped}`, "utf-8");
1248
- await ensureQmdAvailableForUpdate();
1249
- scheduleQmdUpdate();
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 = getMemoryFile();
1507
+ const memFile = path.join(memoryDir, "MEMORY.md");
1269
1508
  const existing = readFileSafe(memFile) ?? "";
1270
- const existingPreview = buildPreview(existing, {
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", existing, "middle")}`
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 stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
1281
- fs.writeFileSync(memFile, stamped, "utf-8");
1282
- await ensureQmdAvailableForUpdate();
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 stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
1301
- fs.writeFileSync(memFile, existing + separator + stamped, "utf-8");
1302
- await ensureQmdAvailableForUpdate();
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
- items.push({ done: false, text, meta: `<!-- ${ts} [${sid}] -->` });
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: - [ ] ${text}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
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
- allEntries.push(...parseDailyEntries(date, content));
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 titleMatch = content.match(/^# Topic:\s*(.+)$/m);
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, content);
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 pinnedMatch = existingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
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
  }