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/dist/core.js CHANGED
@@ -115,6 +115,100 @@ const CONTEXT_MAX_CHARS = 16_000;
115
115
  function normalizeContent(content) {
116
116
  return content.trim();
117
117
  }
118
+ const SECRET_PATTERNS = [
119
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g,
120
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
121
+ /\bAKIA[0-9A-Z]{16}\b/g,
122
+ /\b(?:api[_-]?key|access[_-]?token|secret)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{16,}["']?/gi,
123
+ ];
124
+ /** Redact common credential shapes before content reaches disk or agent context. */
125
+ export function redactSecrets(content) {
126
+ let redactedContent = content;
127
+ for (const pattern of SECRET_PATTERNS) {
128
+ redactedContent = redactedContent.replace(pattern, "[REDACTED_SECRET]");
129
+ }
130
+ return { content: redactedContent, redacted: redactedContent !== content };
131
+ }
132
+ function isInactiveMemoryEntry(entry, now) {
133
+ const metadataHeader = entry.split(/\n\s*\n/, 1)[0];
134
+ if (/^\s*(?:[-*]\s*)?Trust:\s*untrusted\s*\.?\s*$/im.test(metadataHeader))
135
+ return true;
136
+ if (/^\s*(?:[-*]\s*)?Status:\s*(?:expired|superseded|revoked|retired)\s*\.?\s*$/im.test(metadataHeader)) {
137
+ return true;
138
+ }
139
+ const validUntil = metadataHeader.match(/^\s*(?:[-*]\s*)?Valid until:?\s+(\d{4}-\d{2}-\d{2})\s*\.?\s*$/im)?.[1];
140
+ if (validUntil && validUntil < now.toISOString().slice(0, 10))
141
+ return true;
142
+ return false;
143
+ }
144
+ function splitLogicalMemoryEntries(content) {
145
+ const normalized = content.replace(/^\uFEFF/, "");
146
+ const marker = /^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm;
147
+ const starts = [...normalized.matchAll(marker)].map((match) => match.index ?? 0);
148
+ if (starts.length === 0) {
149
+ return { entries: normalized.split(/\n\s*\n/), timestampDelimited: false };
150
+ }
151
+ const entries = [];
152
+ const preamble = normalized.slice(0, starts[0]).trim();
153
+ if (preamble)
154
+ entries.push(preamble);
155
+ for (let i = 0; i < starts.length; i++) {
156
+ entries.push(normalized.slice(starts[i], starts[i + 1] ?? normalized.length).trim());
157
+ }
158
+ return { entries, timestampDelimited: true };
159
+ }
160
+ function isUnmarkedInactiveHeader(entry, now) {
161
+ if (!isInactiveMemoryEntry(entry, now))
162
+ return false;
163
+ return entry.split("\n").every((line) => {
164
+ const trimmed = line.trim();
165
+ return (!trimmed ||
166
+ /^#{1,6}\s+/.test(trimmed) ||
167
+ /^<!--.*-->$/.test(trimmed) ||
168
+ /^(?:[-*]\s*)?(?:Trust:|Status:|Valid until:?\s|Source:)/i.test(trimmed));
169
+ });
170
+ }
171
+ /** Apply trust, lifecycle, and secret policy to complete logical write entries. */
172
+ export function filterMemoryForContext(content, now = new Date()) {
173
+ const { entries, timestampDelimited } = splitLogicalMemoryEntries(content);
174
+ const activeEntries = [];
175
+ for (const entry of entries) {
176
+ const inactive = isInactiveMemoryEntry(entry, now);
177
+ if (!timestampDelimited && isUnmarkedInactiveHeader(entry, now)) {
178
+ // Without write markers there is no reliable boundary after a metadata-only
179
+ // header. Fail closed instead of treating the following body paragraphs as
180
+ // independent trusted entries.
181
+ break;
182
+ }
183
+ if (!inactive)
184
+ activeEntries.push(entry);
185
+ }
186
+ return redactSecrets(activeEntries.join("\n\n")).content.trim();
187
+ }
188
+ function sanitizeSourceUri(sourceUri) {
189
+ if (!sourceUri?.trim())
190
+ return undefined;
191
+ const singleLine = [...sourceUri]
192
+ .map((char) => {
193
+ const code = char.charCodeAt(0);
194
+ return code <= 31 || code === 127 ? " " : char;
195
+ })
196
+ .join("")
197
+ .trim()
198
+ .slice(0, 2_048);
199
+ return redactSecrets(singleLine).content;
200
+ }
201
+ function escapeEntryMarkers(content) {
202
+ return content.replace(/^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm, (line) => line.replace("<!--", "&lt;!--"));
203
+ }
204
+ function formatStoredEntry(content, metadata, sourceUri) {
205
+ const safeContent = redactSecrets(escapeEntryMarkers(content));
206
+ const source = sanitizeSourceUri(sourceUri);
207
+ return {
208
+ entry: `${metadata}\n${safeContent.content}${source ? `\nSource: ${source}` : ""}`,
209
+ redacted: safeContent.redacted || (!!sourceUri && source !== sourceUri.trim()),
210
+ };
211
+ }
118
212
  export function truncateLines(lines, maxLines, mode) {
119
213
  if (maxLines <= 0 || lines.length <= maxLines) {
120
214
  return { lines, truncated: false };
@@ -251,7 +345,7 @@ export function buildMemoryContext(searchResults) {
251
345
  if (scratchpad?.trim()) {
252
346
  const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
253
347
  if (openItems.length > 0) {
254
- const serialized = serializeScratchpad(openItems);
348
+ const serialized = filterMemoryForContext(serializeScratchpad(openItems));
255
349
  const section = formatContextSection("## SCRATCHPAD.md (working context)", serialized, "start", CONTEXT_SCRATCHPAD_MAX_LINES, CONTEXT_SCRATCHPAD_MAX_CHARS);
256
350
  if (section)
257
351
  sections.push(section);
@@ -263,25 +357,29 @@ export function buildMemoryContext(searchResults) {
263
357
  const today = todayStr();
264
358
  const yesterday = yesterdayStr();
265
359
  const todayContent = readFileSafe(dailyPath(today));
266
- if (todayContent?.trim()) {
267
- const section = formatContextSection(`## Daily log: ${today} (today)`, todayContent, "end", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
360
+ const safeTodayContent = todayContent ? filterMemoryForContext(todayContent) : "";
361
+ if (safeTodayContent) {
362
+ const section = formatContextSection(`## Daily log: ${today} (today)`, safeTodayContent, "middle", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
268
363
  if (section)
269
364
  sections.push(section);
270
365
  }
271
- if (searchResults?.trim()) {
272
- const section = formatContextSection("## Relevant memories (auto-retrieved)", searchResults, "start", CONTEXT_SEARCH_MAX_LINES, CONTEXT_SEARCH_MAX_CHARS);
366
+ const safeSearchResults = searchResults ? filterMemoryForContext(searchResults) : "";
367
+ if (safeSearchResults) {
368
+ const section = formatContextSection("## Relevant memories (auto-retrieved)", safeSearchResults, "start", CONTEXT_SEARCH_MAX_LINES, CONTEXT_SEARCH_MAX_CHARS);
273
369
  if (section)
274
370
  sections.push(section);
275
371
  }
276
372
  const longTerm = readFileSafe(MEMORY_FILE);
277
- if (longTerm?.trim()) {
278
- const section = formatContextSection("## MEMORY.md (long-term)", longTerm, "middle", CONTEXT_LONG_TERM_MAX_LINES, CONTEXT_LONG_TERM_MAX_CHARS);
373
+ const safeLongTerm = longTerm ? filterMemoryForContext(longTerm) : "";
374
+ if (safeLongTerm) {
375
+ const section = formatContextSection("## MEMORY.md (long-term)", safeLongTerm, "middle", CONTEXT_LONG_TERM_MAX_LINES, CONTEXT_LONG_TERM_MAX_CHARS);
279
376
  if (section)
280
377
  sections.push(section);
281
378
  }
282
379
  const yesterdayContent = readFileSafe(dailyPath(yesterday));
283
- if (yesterdayContent?.trim()) {
284
- const section = formatContextSection(`## Daily log: ${yesterday} (yesterday)`, yesterdayContent, "end", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
380
+ const safeYesterdayContent = yesterdayContent ? filterMemoryForContext(yesterdayContent) : "";
381
+ if (safeYesterdayContent) {
382
+ const section = formatContextSection(`## Daily log: ${yesterday} (yesterday)`, safeYesterdayContent, "end", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
285
383
  if (section)
286
384
  sections.push(section);
287
385
  }
@@ -290,15 +388,8 @@ export function buildMemoryContext(searchResults) {
290
388
  }
291
389
  const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
292
390
  if (context.length > CONTEXT_MAX_CHARS) {
293
- const result = buildPreview(context, {
294
- maxLines: Number.POSITIVE_INFINITY,
295
- maxChars: CONTEXT_MAX_CHARS,
296
- mode: "start",
297
- });
298
- const note = result.truncated
299
- ? `\n\n[truncated overall context: showing ${result.previewChars}/${result.totalChars} chars]`
300
- : "";
301
- return `${result.preview}${note}`;
391
+ const note = "\n\n[truncated overall context to 16000 chars]";
392
+ return context.slice(0, CONTEXT_MAX_CHARS - note.length).trimEnd() + note;
302
393
  }
303
394
  return context;
304
395
  }
@@ -319,11 +410,12 @@ function buildTopicsContextSection() {
319
410
  for (const file of topicFiles) {
320
411
  const slug = file.replace(/\.md$/, "");
321
412
  const content = readFileSafe(path.join(TOPICS_DIR, file));
322
- if (!content?.trim())
413
+ const safeContent = content ? filterMemoryForContext(content) : "";
414
+ if (!safeContent)
323
415
  continue;
324
- const titleMatch = content.match(/^# Topic:\s*(.+)$/m);
416
+ const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
325
417
  const title = titleMatch?.[1]?.trim() || slug;
326
- entries.push(...parseTopicEntries(title, slug, content));
418
+ entries.push(...parseTopicEntries(title, slug, safeContent));
327
419
  }
328
420
  if (entries.length === 0)
329
421
  return null;
@@ -711,6 +803,12 @@ export function installSkills() {
711
803
  destDir: path.join(homeDir, ".cursor", "skills", "agent-memory"),
712
804
  homeMarker: path.join(homeDir, ".cursor"),
713
805
  },
806
+ {
807
+ label: "Agent CLI skill",
808
+ srcDir: path.join(skillsDir, "agent"),
809
+ destDir: path.join(homeDir, ".agents", "skills", "agent-memory"),
810
+ homeMarker: path.join(homeDir, ".agents"),
811
+ },
714
812
  ];
715
813
  const detected = [];
716
814
  const installed = [];
@@ -765,6 +863,7 @@ export function uninstallSkills() {
765
863
  { label: "Claude Code skill", destDir: path.join(homeDir, ".claude", "skills", "agent-memory") },
766
864
  { label: "Codex skill", destDir: path.join(homeDir, ".codex", "skills", "agent-memory") },
767
865
  { label: "Cursor skill", destDir: path.join(homeDir, ".cursor", "skills", "agent-memory") },
866
+ { label: "Agent CLI skill", destDir: path.join(homeDir, ".agents", "skills", "agent-memory") },
768
867
  ];
769
868
  const removed = [];
770
869
  const skipped = [];
@@ -844,10 +943,63 @@ export async function getQmdHealth() {
844
943
  });
845
944
  });
846
945
  }
946
+ function resolveCaseInsensitivePath(root, relativePath) {
947
+ let current = root;
948
+ for (const segment of relativePath.split(/[/\\]+/).filter(Boolean)) {
949
+ if (segment === "." || segment === "..")
950
+ return null;
951
+ let children;
952
+ try {
953
+ children = fs.readdirSync(current);
954
+ }
955
+ catch {
956
+ return null;
957
+ }
958
+ const child = children.find((name) => name === segment) ??
959
+ children.find((name) => name.toLowerCase() === segment.toLowerCase());
960
+ if (!child)
961
+ return null;
962
+ current = path.join(current, child);
963
+ }
964
+ return current;
965
+ }
966
+ function resolveQmdSourcePath(filePath) {
967
+ const qmdUri = filePath.match(/^qmd:\/\/([^/]+)\/?(.*)$/i);
968
+ let relativePath = qmdUri ? qmdUri[2] : filePath;
969
+ if (relativePath.toLowerCase().startsWith(`${QMD_COLLECTION_NAME.toLowerCase()}/`)) {
970
+ relativePath = relativePath.slice(QMD_COLLECTION_NAME.length + 1);
971
+ }
972
+ const root = path.resolve(MEMORY_DIR);
973
+ if (path.isAbsolute(relativePath)) {
974
+ const candidate = path.resolve(relativePath);
975
+ if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`))
976
+ return null;
977
+ return fs.existsSync(candidate) ? candidate : null;
978
+ }
979
+ return resolveCaseInsensitivePath(root, relativePath.replace(/^\/+/, ""));
980
+ }
981
+ function qmdResultPassesSourcePolicy(filePath, snippet) {
982
+ if (!filePath)
983
+ return false;
984
+ const sourcePath = resolveQmdSourcePath(filePath);
985
+ if (!sourcePath)
986
+ return false;
987
+ const source = readFileSafe(sourcePath);
988
+ if (!source)
989
+ return false;
990
+ const activeSource = filterMemoryForContext(source);
991
+ const snippetLines = snippet
992
+ .split("\n")
993
+ .map((line) => line.trim())
994
+ .filter((line) => line.length >= 8);
995
+ return snippetLines.length > 0 && snippetLines.every((line) => activeSource.includes(line));
996
+ }
847
997
  /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
848
998
  export async function searchRelevantMemories(prompt) {
849
999
  if (!qmdAvailable || !prompt.trim())
850
1000
  return "";
1001
+ let timer;
1002
+ const controller = new AbortController();
851
1003
  // Sanitize: strip control chars, limit to 200 chars for the search query
852
1004
  const sanitized = prompt
853
1005
  // biome-ignore lint/suspicious/noControlCharactersInRegex: we intentionally strip control chars.
@@ -861,19 +1013,26 @@ export async function searchRelevantMemories(prompt) {
861
1013
  if (!hasCollection)
862
1014
  return "";
863
1015
  const results = await Promise.race([
864
- runQmdSearch("keyword", sanitized, 3),
865
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3_000)),
1016
+ runQmdSearch("keyword", sanitized, 3, { signal: controller.signal }),
1017
+ new Promise((_, reject) => {
1018
+ timer = setTimeout(() => {
1019
+ controller.abort();
1020
+ reject(new Error("timeout"));
1021
+ }, 3_000);
1022
+ }),
866
1023
  ]);
867
1024
  if (!results || results.results.length === 0)
868
1025
  return "";
869
1026
  const snippets = results.results
870
1027
  .map((r) => {
871
- const text = getQmdResultText(r);
872
- if (!text.trim())
1028
+ const text = filterMemoryForContext(getQmdResultText(r));
1029
+ if (!text)
873
1030
  return null;
874
1031
  const filePath = getQmdResultPath(r);
1032
+ if (!qmdResultPassesSourcePolicy(filePath, text))
1033
+ return null;
875
1034
  const filePart = filePath ? `_${filePath}_` : "";
876
- return filePart ? `${filePart}\n${text.trim()}` : text.trim();
1035
+ return filePart ? `${filePart}\n${text}` : text;
877
1036
  })
878
1037
  .filter(Boolean);
879
1038
  if (snippets.length === 0)
@@ -883,12 +1042,27 @@ export async function searchRelevantMemories(prompt) {
883
1042
  catch {
884
1043
  return "";
885
1044
  }
1045
+ finally {
1046
+ clearTimeout(timer);
1047
+ }
886
1048
  }
887
1049
  export function getQmdResultPath(r) {
888
1050
  return r.path ?? r.file;
889
1051
  }
890
1052
  export function getQmdResultText(r) {
891
- return r.content ?? r.chunk ?? r.snippet ?? "";
1053
+ const text = r.content ?? r.chunk ?? r.snippet ?? "";
1054
+ const folderContext = r.context?.trim();
1055
+ let normalized = text.trimStart();
1056
+ if (folderContext) {
1057
+ const prefix = `Folder Context: ${folderContext}`;
1058
+ if (normalized.startsWith(prefix)) {
1059
+ const remainder = normalized.slice(prefix.length);
1060
+ if (/^(?:\r?\n){2}/.test(remainder)) {
1061
+ normalized = remainder.replace(/^(?:\r?\n){2}/, "");
1062
+ }
1063
+ }
1064
+ }
1065
+ return normalized.replace(/^@@ -\d+(?:,\d+)?(?: \+\d+(?:,\d+)?)? @@ \(\d+ before, \d+ after\)(?:\r?\n){1,2}/, "");
892
1066
  }
893
1067
  function stripAnsi(text) {
894
1068
  // qmd may emit spinners/progress bars even with --json, especially on first model download.
@@ -916,11 +1090,11 @@ function parseQmdJson(stdout) {
916
1090
  return [];
917
1091
  return JSON.parse(jsonText);
918
1092
  }
919
- export function runQmdSearch(mode, query, limit) {
1093
+ export function runQmdSearch(mode, query, limit, options = {}) {
920
1094
  const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
921
1095
  const args = [subcommand, "--json", "-c", QMD_COLLECTION_NAME, "-n", String(limit), query];
922
1096
  return new Promise((resolve, reject) => {
923
- execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
1097
+ execFileFn("qmd", args, { timeout: 60_000, signal: options.signal }, (err, stdout, stderr) => {
924
1098
  if (err) {
925
1099
  reject(new Error(stderr?.trim() || err.message));
926
1100
  return;
@@ -940,28 +1114,72 @@ export function runQmdSearch(mode, query, limit) {
940
1114
  });
941
1115
  });
942
1116
  }
1117
+ /**
1118
+ * Best-effort check of whether vector embeddings are actually usable for
1119
+ * semantic/deep search right now. Runs a tiny semantic probe and looks for
1120
+ * qmd's "need embeddings" warning. Bounded by a short timeout because the very
1121
+ * first semantic query can trigger a model download — returns "unknown" rather
1122
+ * than blocking on it. "ready" means the probe ran without the warning; it does
1123
+ * not prove the index has content.
1124
+ */
1125
+ export async function probeEmbeddings() {
1126
+ let timer;
1127
+ // Abort the underlying qmd child when the timeout fires so it does not keep
1128
+ // the event loop open until its own 60s timeout and hang the CLI.
1129
+ const controller = new AbortController();
1130
+ try {
1131
+ const { stderr } = await Promise.race([
1132
+ runQmdSearch("semantic", "memory", 1, { signal: controller.signal }),
1133
+ new Promise((_, reject) => {
1134
+ timer = setTimeout(() => {
1135
+ controller.abort();
1136
+ reject(new Error("timeout"));
1137
+ }, 4_000);
1138
+ }),
1139
+ ]);
1140
+ return /need embeddings/i.test(stderr ?? "") ? "missing" : "ready";
1141
+ }
1142
+ catch (err) {
1143
+ const msg = err instanceof Error ? err.message : String(err);
1144
+ if (/need embeddings/i.test(msg))
1145
+ return "missing";
1146
+ return "unknown";
1147
+ }
1148
+ finally {
1149
+ clearTimeout(timer);
1150
+ }
1151
+ }
943
1152
  export async function memoryWrite(params) {
944
- ensureDirs();
1153
+ const memoryDir = params.directory ? path.resolve(params.directory) : getMemoryDir();
1154
+ fs.mkdirSync(memoryDir, { recursive: true });
1155
+ fs.mkdirSync(path.join(memoryDir, "daily"), { recursive: true });
1156
+ fs.mkdirSync(path.join(memoryDir, "topics"), { recursive: true });
1157
+ const scheduleSearchRefresh = async () => {
1158
+ if (path.resolve(getMemoryDir()) !== memoryDir)
1159
+ return;
1160
+ await ensureQmdAvailableForUpdate();
1161
+ scheduleQmdUpdate();
1162
+ };
945
1163
  const target = params.target ?? "daily";
946
1164
  const { content, mode } = params;
947
1165
  const sid = shortSessionId(params.sessionId ?? "cli");
948
1166
  const ts = nowTimestamp();
949
1167
  if (target === "daily") {
950
- const filePath = dailyPath(todayStr());
1168
+ const filePath = path.join(memoryDir, "daily", `${params.date?.trim() || todayStr()}.md`);
951
1169
  const existing = readFileSafe(filePath) ?? "";
952
- const existingPreview = buildPreview(existing, {
1170
+ const safeExisting = redactSecrets(existing).content;
1171
+ const existingPreview = buildPreview(safeExisting, {
953
1172
  maxLines: RESPONSE_PREVIEW_MAX_LINES,
954
1173
  maxChars: RESPONSE_PREVIEW_MAX_CHARS,
955
1174
  mode: "end",
956
1175
  });
957
1176
  const existingSnippet = existingPreview.preview
958
- ? `\n\n${formatPreviewBlock("Existing daily log preview", existing, "end")}`
1177
+ ? `\n\n${formatPreviewBlock("Existing daily log preview", safeExisting, "end")}`
959
1178
  : "\n\nDaily log was empty.";
960
1179
  const separator = existing.trim() ? "\n\n" : "";
961
- const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
962
- fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
963
- await ensureQmdAvailableForUpdate();
964
- scheduleQmdUpdate();
1180
+ const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
1181
+ fs.writeFileSync(filePath, existing + separator + stored.entry, "utf-8");
1182
+ await scheduleSearchRefresh();
965
1183
  return {
966
1184
  text: `Appended to daily log: ${filePath}${existingSnippet}`,
967
1185
  details: {
@@ -970,6 +1188,8 @@ export async function memoryWrite(params) {
970
1188
  mode: "append",
971
1189
  sessionId: sid,
972
1190
  timestamp: ts,
1191
+ sourceUri: sanitizeSourceUri(params.sourceUri),
1192
+ redacted: stored.redacted,
973
1193
  qmdUpdateMode: getQmdUpdateMode(),
974
1194
  existingPreview,
975
1195
  },
@@ -984,24 +1204,24 @@ export async function memoryWrite(params) {
984
1204
  if (!slug) {
985
1205
  return { text: "Error: 'topic' must include at least one letter or number.", details: {}, isError: true };
986
1206
  }
987
- const filePath = topicPath(slug);
1207
+ const filePath = path.join(memoryDir, "topics", `${slug}.md`);
988
1208
  const existing = readFileSafe(filePath) ?? "";
989
- const existingPreview = buildPreview(existing, {
1209
+ const safeExisting = redactSecrets(existing).content;
1210
+ const existingPreview = buildPreview(safeExisting, {
990
1211
  maxLines: RESPONSE_PREVIEW_MAX_LINES,
991
1212
  maxChars: RESPONSE_PREVIEW_MAX_CHARS,
992
1213
  mode: "end",
993
1214
  });
994
1215
  const existingSnippet = existingPreview.preview
995
- ? `\n\n${formatPreviewBlock("Existing topic preview", existing, "end")}`
1216
+ ? `\n\n${formatPreviewBlock("Existing topic preview", safeExisting, "end")}`
996
1217
  : "\n\nTopic file was empty.";
997
1218
  const linkDate = params.date?.trim() || todayStr();
998
1219
  const header = `# Topic: ${topic}\n\n<!-- created: ${ts} [${sid}] -->\n`;
999
1220
  const separator = existing.trim() ? "\n\n" : "";
1000
1221
  const base = existing.trim() ? existing : header.trimEnd();
1001
- const stamped = `<!-- ${ts} [${sid}] -->\n${content.trim()}\nDaily: [[${linkDate}]]`;
1002
- fs.writeFileSync(filePath, `${base}${separator}${stamped}`, "utf-8");
1003
- await ensureQmdAvailableForUpdate();
1004
- scheduleQmdUpdate();
1222
+ const stored = formatStoredEntry(`${content.trim()}\nDaily: [[${linkDate}]]`, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
1223
+ fs.writeFileSync(filePath, `${base}${separator}${stored.entry}`, "utf-8");
1224
+ await scheduleSearchRefresh();
1005
1225
  return {
1006
1226
  text: `Appended to topic: ${filePath}${existingSnippet}`,
1007
1227
  details: {
@@ -1013,27 +1233,29 @@ export async function memoryWrite(params) {
1013
1233
  topic,
1014
1234
  slug,
1015
1235
  date: linkDate,
1236
+ sourceUri: sanitizeSourceUri(params.sourceUri),
1237
+ redacted: stored.redacted,
1016
1238
  qmdUpdateMode: getQmdUpdateMode(),
1017
1239
  existingPreview,
1018
1240
  },
1019
1241
  };
1020
1242
  }
1021
1243
  // long_term
1022
- const memFile = getMemoryFile();
1244
+ const memFile = path.join(memoryDir, "MEMORY.md");
1023
1245
  const existing = readFileSafe(memFile) ?? "";
1024
- const existingPreview = buildPreview(existing, {
1246
+ const safeExisting = redactSecrets(existing).content;
1247
+ const existingPreview = buildPreview(safeExisting, {
1025
1248
  maxLines: RESPONSE_PREVIEW_MAX_LINES,
1026
1249
  maxChars: RESPONSE_PREVIEW_MAX_CHARS,
1027
1250
  mode: "middle",
1028
1251
  });
1029
1252
  const existingSnippet = existingPreview.preview
1030
- ? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", existing, "middle")}`
1253
+ ? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", safeExisting, "middle")}`
1031
1254
  : "\n\nMEMORY.md was empty.";
1032
1255
  if (mode === "overwrite") {
1033
- const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
1034
- fs.writeFileSync(memFile, stamped, "utf-8");
1035
- await ensureQmdAvailableForUpdate();
1036
- scheduleQmdUpdate();
1256
+ const stored = formatStoredEntry(content, `<!-- last updated: ${ts} [${sid}] -->`, params.sourceUri);
1257
+ fs.writeFileSync(memFile, stored.entry, "utf-8");
1258
+ await scheduleSearchRefresh();
1037
1259
  return {
1038
1260
  text: `Overwrote MEMORY.md${existingSnippet}`,
1039
1261
  details: {
@@ -1042,6 +1264,8 @@ export async function memoryWrite(params) {
1042
1264
  mode: "overwrite",
1043
1265
  sessionId: sid,
1044
1266
  timestamp: ts,
1267
+ sourceUri: sanitizeSourceUri(params.sourceUri),
1268
+ redacted: stored.redacted,
1045
1269
  qmdUpdateMode: getQmdUpdateMode(),
1046
1270
  existingPreview,
1047
1271
  },
@@ -1049,10 +1273,9 @@ export async function memoryWrite(params) {
1049
1273
  }
1050
1274
  // append (default)
1051
1275
  const separator = existing.trim() ? "\n\n" : "";
1052
- const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
1053
- fs.writeFileSync(memFile, existing + separator + stamped, "utf-8");
1054
- await ensureQmdAvailableForUpdate();
1055
- scheduleQmdUpdate();
1276
+ const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
1277
+ fs.writeFileSync(memFile, existing + separator + stored.entry, "utf-8");
1278
+ await scheduleSearchRefresh();
1056
1279
  return {
1057
1280
  text: `Appended to MEMORY.md${existingSnippet}`,
1058
1281
  details: {
@@ -1061,6 +1284,8 @@ export async function memoryWrite(params) {
1061
1284
  mode: "append",
1062
1285
  sessionId: sid,
1063
1286
  timestamp: ts,
1287
+ sourceUri: sanitizeSourceUri(params.sourceUri),
1288
+ redacted: stored.redacted,
1064
1289
  qmdUpdateMode: getQmdUpdateMode(),
1065
1290
  existingPreview,
1066
1291
  },
@@ -1073,7 +1298,11 @@ export async function scratchpadAction(params) {
1073
1298
  const ts = nowTimestamp();
1074
1299
  const spFile = getScratchpadFile();
1075
1300
  const existing = readFileSafe(spFile) ?? "";
1076
- let items = parseScratchpad(existing);
1301
+ let items = parseScratchpad(existing).map((item) => ({
1302
+ ...item,
1303
+ text: redactSecrets(item.text).content,
1304
+ meta: redactSecrets(item.meta).content,
1305
+ }));
1077
1306
  if (action === "list") {
1078
1307
  if (items.length === 0) {
1079
1308
  return { text: "Scratchpad is empty.", details: {} };
@@ -1097,7 +1326,8 @@ export async function scratchpadAction(params) {
1097
1326
  if (!text) {
1098
1327
  return { text: "Error: 'text' is required for add.", details: {} };
1099
1328
  }
1100
- items.push({ done: false, text, meta: `<!-- ${ts} [${sid}] -->` });
1329
+ const safeText = redactSecrets(text).content;
1330
+ items.push({ done: false, text: safeText, meta: `<!-- ${ts} [${sid}] -->` });
1101
1331
  const serialized = serializeScratchpad(items);
1102
1332
  const preview = buildPreview(serialized, {
1103
1333
  maxLines: RESPONSE_PREVIEW_MAX_LINES,
@@ -1108,7 +1338,7 @@ export async function scratchpadAction(params) {
1108
1338
  await ensureQmdAvailableForUpdate();
1109
1339
  scheduleQmdUpdate();
1110
1340
  return {
1111
- text: `Added: - [ ] ${text}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
1341
+ text: `Added: - [ ] ${safeText}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
1112
1342
  details: {
1113
1343
  action,
1114
1344
  sessionId: sid,
@@ -1526,7 +1756,10 @@ export async function distilMemories(params) {
1526
1756
  const content = readFileSafe(path.join(DAILY_DIR, file));
1527
1757
  if (!content?.trim())
1528
1758
  continue;
1529
- allEntries.push(...parseDailyEntries(date, content));
1759
+ const safeContent = filterMemoryForContext(content);
1760
+ if (!safeContent)
1761
+ continue;
1762
+ allEntries.push(...parseDailyEntries(date, safeContent));
1530
1763
  }
1531
1764
  const topicEntriesByTopic = new Map();
1532
1765
  let totalTopicEntries = 0;
@@ -1535,9 +1768,12 @@ export async function distilMemories(params) {
1535
1768
  const content = readFileSafe(path.join(TOPICS_DIR, file));
1536
1769
  if (!content?.trim())
1537
1770
  continue;
1538
- const titleMatch = content.match(/^# Topic:\s*(.+)$/m);
1771
+ const safeContent = filterMemoryForContext(content);
1772
+ if (!safeContent)
1773
+ continue;
1774
+ const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
1539
1775
  const title = titleMatch?.[1]?.trim() || slug;
1540
- const entries = parseTopicEntries(title, slug, content);
1776
+ const entries = parseTopicEntries(title, slug, safeContent);
1541
1777
  if (entries.length === 0)
1542
1778
  continue;
1543
1779
  totalTopicEntries += entries.length;
@@ -1584,7 +1820,8 @@ export async function distilMemories(params) {
1584
1820
  let pinnedSection = "";
1585
1821
  const existingMemory = readFileSafe(MEMORY_FILE);
1586
1822
  if (existingMemory) {
1587
- const pinnedMatch = existingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
1823
+ const safeExistingMemory = filterMemoryForContext(existingMemory);
1824
+ const pinnedMatch = safeExistingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
1588
1825
  if (pinnedMatch) {
1589
1826
  pinnedSection = pinnedMatch[1].trim();
1590
1827
  }
@@ -0,0 +1,42 @@
1
+ /** Override the detected home directory in deterministic tests. */
2
+ export declare function _setHookHomeDirForTest(directory: string | null): void;
3
+ export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
4
+ export interface HookTargetInfo {
5
+ key: HookAgentKey;
6
+ label: string;
7
+ homeMarker: string;
8
+ detectFiles: string[];
9
+ detectCommand?: string;
10
+ supported: boolean;
11
+ unsupportedReason?: string;
12
+ }
13
+ export interface DetectedHookTarget extends HookTargetInfo {
14
+ detected: boolean;
15
+ detectReason?: string;
16
+ }
17
+ export declare function detectHookAgents(): {
18
+ homeDir: string | null;
19
+ targets: DetectedHookTarget[];
20
+ };
21
+ export interface HookInstallResult {
22
+ key: HookAgentKey;
23
+ label: string;
24
+ installed: boolean;
25
+ path?: string;
26
+ backup?: string;
27
+ reason?: string;
28
+ }
29
+ export interface InstallHooksReport {
30
+ ok: boolean;
31
+ homeDir?: string;
32
+ results: HookInstallResult[];
33
+ error?: string;
34
+ }
35
+ export declare function installHooks(agents: Set<HookAgentKey>): InstallHooksReport;
36
+ export interface UninstallHooksReport {
37
+ ok: boolean;
38
+ homeDir?: string;
39
+ results: HookInstallResult[];
40
+ error?: string;
41
+ }
42
+ export declare function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport;