myagentmemory 0.4.17 → 0.5.1

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
@@ -61,6 +61,47 @@ export function getTopicsDir(): string {
61
61
  return TOPICS_DIR;
62
62
  }
63
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // Hook mode config (per-turn vs stable)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export type HookMode = "stable" | "per-turn";
69
+
70
+ const HOOK_CONFIG_FILENAME = "hook-config.json";
71
+ const HOOK_MODE_DEFAULT: HookMode = "per-turn";
72
+
73
+ function hookConfigPath(): string {
74
+ return path.join(MEMORY_DIR, HOOK_CONFIG_FILENAME);
75
+ }
76
+
77
+ /**
78
+ * Resolve the active hook mode.
79
+ * Precedence: `AGENT_MEMORY_HOOK_MODE` env var → `<memoryDir>/hook-config.json`
80
+ * → default `per-turn`. Invalid values fall through to the next source.
81
+ */
82
+ export function readHookMode(): HookMode {
83
+ const env = process.env.AGENT_MEMORY_HOOK_MODE;
84
+ if (env === "stable" || env === "per-turn") return env;
85
+ try {
86
+ const raw = fs.readFileSync(hookConfigPath(), "utf-8");
87
+ const parsed = JSON.parse(raw) as { mode?: unknown };
88
+ if (parsed.mode === "stable" || parsed.mode === "per-turn") return parsed.mode;
89
+ } catch {}
90
+ return HOOK_MODE_DEFAULT;
91
+ }
92
+
93
+ /**
94
+ * Atomically persist the chosen hook mode. Called by `install-hooks` after a
95
+ * successful install pass so `doctor` and later invocations can report it.
96
+ */
97
+ export function writeHookMode(mode: HookMode): void {
98
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
99
+ const target = hookConfigPath();
100
+ const temporary = `${target}.${process.pid}.tmp`;
101
+ fs.writeFileSync(temporary, `${JSON.stringify({ mode }, null, 2)}\n`, { mode: 0o600 });
102
+ fs.renameSync(temporary, target);
103
+ }
104
+
64
105
  // ---------------------------------------------------------------------------
65
106
  // Utilities
66
107
  // ---------------------------------------------------------------------------
@@ -427,97 +468,121 @@ export function serializeScratchpad(items: ScratchpadItem[]): string {
427
468
  // Context builder
428
469
  // ---------------------------------------------------------------------------
429
470
 
430
- export function buildMemoryContext(searchResults?: string): string {
431
- ensureDirs();
432
- // Priority order: scratchpad > topics > today's daily > search results > MEMORY.md > yesterday's daily
433
- const sections: string[] = [];
434
-
471
+ function scratchpadContextSection(): string | null {
435
472
  const scratchpad = readFileSafe(SCRATCHPAD_FILE);
436
- if (scratchpad?.trim()) {
437
- const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
438
- if (openItems.length > 0) {
439
- const serialized = filterMemoryForContext(serializeScratchpad(openItems));
440
- const section = formatContextSection(
441
- "## SCRATCHPAD.md (working context)",
442
- serialized,
443
- "start",
444
- CONTEXT_SCRATCHPAD_MAX_LINES,
445
- CONTEXT_SCRATCHPAD_MAX_CHARS,
446
- );
447
- if (section) sections.push(section);
448
- }
449
- }
450
-
451
- const topicsSection = buildTopicsContextSection();
452
- if (topicsSection) sections.push(topicsSection);
473
+ if (!scratchpad?.trim()) return null;
474
+ const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
475
+ if (openItems.length === 0) return null;
476
+ const serialized = filterMemoryForContext(serializeScratchpad(openItems));
477
+ return formatContextSection(
478
+ "## SCRATCHPAD.md (working context)",
479
+ serialized,
480
+ "start",
481
+ CONTEXT_SCRATCHPAD_MAX_LINES,
482
+ CONTEXT_SCRATCHPAD_MAX_CHARS,
483
+ );
484
+ }
453
485
 
486
+ function todayContextSection(): string | null {
454
487
  const today = todayStr();
455
- const yesterday = yesterdayStr();
488
+ const content = readFileSafe(dailyPath(today));
489
+ const safe = content ? filterMemoryForContext(content) : "";
490
+ if (!safe) return null;
491
+ return formatContextSection(
492
+ `## Daily log: ${today} (today)`,
493
+ safe,
494
+ "middle",
495
+ CONTEXT_DAILY_MAX_LINES,
496
+ CONTEXT_DAILY_MAX_CHARS,
497
+ );
498
+ }
456
499
 
457
- const todayContent = readFileSafe(dailyPath(today));
458
- const safeTodayContent = todayContent ? filterMemoryForContext(todayContent) : "";
459
- if (safeTodayContent) {
460
- const section = formatContextSection(
461
- `## Daily log: ${today} (today)`,
462
- safeTodayContent,
463
- "middle",
464
- CONTEXT_DAILY_MAX_LINES,
465
- CONTEXT_DAILY_MAX_CHARS,
466
- );
467
- if (section) sections.push(section);
468
- }
500
+ function yesterdayContextSection(): string | null {
501
+ const yesterday = yesterdayStr();
502
+ const content = readFileSafe(dailyPath(yesterday));
503
+ const safe = content ? filterMemoryForContext(content) : "";
504
+ if (!safe) return null;
505
+ return formatContextSection(
506
+ `## Daily log: ${yesterday} (yesterday)`,
507
+ safe,
508
+ "end",
509
+ CONTEXT_DAILY_MAX_LINES,
510
+ CONTEXT_DAILY_MAX_CHARS,
511
+ );
512
+ }
469
513
 
470
- const safeSearchResults = searchResults ? filterMemoryForContext(searchResults) : "";
471
- if (safeSearchResults) {
472
- const section = formatContextSection(
473
- "## Relevant memories (auto-retrieved)",
474
- safeSearchResults,
475
- "start",
476
- CONTEXT_SEARCH_MAX_LINES,
477
- CONTEXT_SEARCH_MAX_CHARS,
478
- );
479
- if (section) sections.push(section);
480
- }
514
+ function searchContextSection(searchResults?: string): string | null {
515
+ const safe = searchResults ? filterMemoryForContext(searchResults) : "";
516
+ if (!safe) return null;
517
+ return formatContextSection(
518
+ "## Relevant memories (auto-retrieved)",
519
+ safe,
520
+ "start",
521
+ CONTEXT_SEARCH_MAX_LINES,
522
+ CONTEXT_SEARCH_MAX_CHARS,
523
+ );
524
+ }
481
525
 
526
+ function longTermContextSection(): string | null {
482
527
  const longTerm = readFileSafe(MEMORY_FILE);
483
- const safeLongTerm = longTerm ? filterMemoryForContext(longTerm) : "";
484
- if (safeLongTerm) {
485
- const section = formatContextSection(
486
- "## MEMORY.md (long-term)",
487
- safeLongTerm,
488
- "middle",
489
- CONTEXT_LONG_TERM_MAX_LINES,
490
- CONTEXT_LONG_TERM_MAX_CHARS,
491
- );
492
- if (section) sections.push(section);
493
- }
494
-
495
- const yesterdayContent = readFileSafe(dailyPath(yesterday));
496
- const safeYesterdayContent = yesterdayContent ? filterMemoryForContext(yesterdayContent) : "";
497
- if (safeYesterdayContent) {
498
- const section = formatContextSection(
499
- `## Daily log: ${yesterday} (yesterday)`,
500
- safeYesterdayContent,
501
- "end",
502
- CONTEXT_DAILY_MAX_LINES,
503
- CONTEXT_DAILY_MAX_CHARS,
504
- );
505
- if (section) sections.push(section);
506
- }
507
-
508
- if (sections.length === 0) {
509
- return "";
510
- }
528
+ const safe = longTerm ? filterMemoryForContext(longTerm) : "";
529
+ if (!safe) return null;
530
+ return formatContextSection(
531
+ "## MEMORY.md (long-term)",
532
+ safe,
533
+ "middle",
534
+ CONTEXT_LONG_TERM_MAX_LINES,
535
+ CONTEXT_LONG_TERM_MAX_CHARS,
536
+ );
537
+ }
511
538
 
512
- const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
539
+ function assembleContext(sections: readonly (string | null)[]): string {
540
+ const kept = sections.filter((s): s is string => !!s);
541
+ if (kept.length === 0) return "";
542
+ const context = `# Memory\n\n${kept.join("\n\n---\n\n")}`;
513
543
  if (context.length > CONTEXT_MAX_CHARS) {
514
544
  const note = "\n\n[truncated overall context to 16000 chars]";
515
545
  return context.slice(0, CONTEXT_MAX_CHARS - note.length).trimEnd() + note;
516
546
  }
517
-
518
547
  return context;
519
548
  }
520
549
 
550
+ /**
551
+ * Full context: scratchpad + topics + today + search + MEMORY.md + yesterday.
552
+ * Used by `agent-memory context` and by SessionStart in stable mode.
553
+ */
554
+ export function buildMemoryContext(searchResults?: string): string {
555
+ ensureDirs();
556
+ return assembleContext([
557
+ scratchpadContextSection(),
558
+ buildTopicsContextSection(),
559
+ todayContextSection(),
560
+ searchContextSection(searchResults),
561
+ longTermContextSection(),
562
+ yesterdayContextSection(),
563
+ ]);
564
+ }
565
+
566
+ /**
567
+ * Stable subset: scratchpad + topics + MEMORY.md. No daily logs, no search.
568
+ * Emitted at SessionStart in per-turn mode — the durable facts that survive
569
+ * across sessions and are unlikely to be affected by the current prompt.
570
+ */
571
+ export function buildStableContext(): string {
572
+ ensureDirs();
573
+ return assembleContext([scratchpadContextSection(), buildTopicsContextSection(), longTermContextSection()]);
574
+ }
575
+
576
+ /**
577
+ * Dynamic subset: today's daily log + qmd search hits + yesterday's daily log.
578
+ * Emitted at UserPromptSubmit — turn-scoped context that can be scoped by the
579
+ * current query. Excludes MEMORY.md and scratchpad (already sent at SessionStart).
580
+ */
581
+ export function buildDynamicContext(searchResults?: string, _query?: string): string {
582
+ ensureDirs();
583
+ return assembleContext([todayContextSection(), searchContextSection(searchResults), yesterdayContextSection()]);
584
+ }
585
+
521
586
  function buildTopicsContextSection(): string | null {
522
587
  let topicFiles: string[];
523
588
  try {
@@ -685,14 +750,22 @@ function findSkillsRoot(): string | null {
685
750
  }
686
751
  };
687
752
 
753
+ const realDirOf = (p: string): string => {
754
+ try {
755
+ return path.dirname(fs.realpathSync(p));
756
+ } catch {
757
+ return path.resolve(path.dirname(p));
758
+ }
759
+ };
760
+
688
761
  const argvPath = process.argv[1];
689
762
  if (argvPath) {
690
- const found = scanUp(path.resolve(path.dirname(argvPath)));
763
+ const found = scanUp(realDirOf(argvPath));
691
764
  if (found) return found;
692
765
  }
693
766
 
694
- const execDir = path.dirname(process.execPath);
695
- const found = scanUp(path.resolve(execDir));
767
+ const execDir = realDirOf(process.execPath);
768
+ const found = scanUp(execDir);
696
769
  if (found) return found;
697
770
 
698
771
  return scanUp(path.resolve(process.cwd()));
@@ -773,44 +846,49 @@ export async function setupQmdCollection(): Promise<boolean> {
773
846
  return true;
774
847
  }
775
848
 
776
- export function detectQmd(): Promise<boolean> {
849
+ export function detectQmd(options: { signal?: AbortSignal } = {}): Promise<boolean> {
777
850
  return new Promise((resolve) => {
778
851
  // qmd doesn't reliably support --version; use a fast command that exits 0 when available.
779
- execFileFn("qmd", ["status"], { timeout: 5_000 }, (err) => {
852
+ execFileFn("qmd", ["status"], { timeout: 5_000, signal: options.signal }, (err) => {
780
853
  resolve(!err);
781
854
  });
782
855
  });
783
856
  }
784
857
 
785
- export function checkCollection(name?: string): Promise<boolean> {
858
+ export function checkCollection(name?: string, options: { signal?: AbortSignal } = {}): Promise<boolean> {
786
859
  const collName = name ?? QMD_COLLECTION_NAME;
787
860
  return new Promise((resolve) => {
788
- execFileFn("qmd", ["collection", "list", "--json"], { timeout: 10_000 }, (err, stdout) => {
789
- if (err) {
790
- resolve(false);
791
- return;
792
- }
793
- try {
794
- const collections = JSON.parse(stdout);
795
- if (Array.isArray(collections)) {
796
- resolve(
797
- collections.some((entry) => {
798
- if (typeof entry === "string") return entry === collName;
799
- if (entry && typeof entry === "object" && "name" in entry) {
800
- return (entry as { name?: string }).name === collName;
801
- }
802
- return false;
803
- }),
804
- );
805
- } else {
806
- // qmd may output an object with a collections array or similar
861
+ execFileFn(
862
+ "qmd",
863
+ ["collection", "list", "--json"],
864
+ { timeout: 10_000, signal: options.signal },
865
+ (err, stdout) => {
866
+ if (err) {
867
+ resolve(false);
868
+ return;
869
+ }
870
+ try {
871
+ const collections = JSON.parse(stdout);
872
+ if (Array.isArray(collections)) {
873
+ resolve(
874
+ collections.some((entry) => {
875
+ if (typeof entry === "string") return entry === collName;
876
+ if (entry && typeof entry === "object" && "name" in entry) {
877
+ return (entry as { name?: string }).name === collName;
878
+ }
879
+ return false;
880
+ }),
881
+ );
882
+ } else {
883
+ // qmd may output an object with a collections array or similar
884
+ resolve(stdout.includes(collName));
885
+ }
886
+ } catch {
887
+ // Fallback: just check if the name appears in the output
807
888
  resolve(stdout.includes(collName));
808
889
  }
809
- } catch {
810
- // Fallback: just check if the name appears in the output
811
- resolve(stdout.includes(collName));
812
- }
813
- });
890
+ },
891
+ );
814
892
  });
815
893
  }
816
894
 
@@ -890,9 +968,9 @@ export async function runQmdEmbedNow(): Promise<boolean> {
890
968
  });
891
969
  }
892
970
 
893
- export async function ensureQmdAvailableForSync(): Promise<boolean> {
971
+ export async function ensureQmdAvailableForSync(options: { signal?: AbortSignal } = {}): Promise<boolean> {
894
972
  if (qmdAvailable) return true;
895
- qmdAvailable = await detectQmd();
973
+ qmdAvailable = await detectQmd(options);
896
974
  return qmdAvailable;
897
975
  }
898
976
 
@@ -1205,18 +1283,42 @@ function qmdResultPassesSourcePolicy(filePath: string | undefined, snippet: stri
1205
1283
  if (!source) return false;
1206
1284
 
1207
1285
  const activeSource = filterMemoryForContext(source);
1286
+ // qmd truncates chunks with a trailing ellipsis, so requiring EVERY line to
1287
+ // substring-match the source is too strict (it fails on any truncated line).
1288
+ // We just need to verify the snippet came from THIS source and isn't stale.
1289
+ // Require at least one substantive line to match, and reject if none do.
1290
+ const stripTruncation = (line: string): string =>
1291
+ line
1292
+ .trim()
1293
+ .replace(/\s*\.\.\.\s*$/, "")
1294
+ .replace(/…\s*$/, "")
1295
+ .trim();
1208
1296
  const snippetLines = snippet
1209
1297
  .split("\n")
1210
- .map((line) => line.trim())
1298
+ .map(stripTruncation)
1211
1299
  .filter((line) => line.length >= 8);
1212
- return snippetLines.length > 0 && snippetLines.every((line) => activeSource.includes(line));
1300
+ if (snippetLines.length === 0) return false;
1301
+ return snippetLines.some((line) => activeSource.includes(line));
1302
+ }
1303
+
1304
+ const RECALL_TIMEOUT_MS = 8_000;
1305
+ const RECALL_LIMIT = 3;
1306
+ // Widen upstream so post-filtering (system/plugins/**) still leaves candidates.
1307
+ const RECALL_QMD_WIDEN = 15;
1308
+ const RECALL_EXCLUDE_PATH_FRAGMENTS = ["/system/plugins/", "system/plugins/"];
1309
+
1310
+ function qmdResultIsUserContent(r: QmdSearchResult): boolean {
1311
+ const p = getQmdResultPath(r);
1312
+ if (!p) return true;
1313
+ return !RECALL_EXCLUDE_PATH_FRAGMENTS.some((frag) => p.includes(frag));
1213
1314
  }
1214
1315
 
1215
1316
  /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
1216
- export async function searchRelevantMemories(prompt: string): Promise<string> {
1317
+ export async function searchRelevantMemories(prompt: string, options: { signal?: AbortSignal } = {}): Promise<string> {
1217
1318
  if (!qmdAvailable || !prompt.trim()) return "";
1218
1319
  let timer: ReturnType<typeof setTimeout> | undefined;
1219
1320
  const controller = new AbortController();
1321
+ const abortFromCaller = () => controller.abort();
1220
1322
 
1221
1323
  // Sanitize: strip control chars, limit to 200 chars for the search query
1222
1324
  const sanitized = prompt
@@ -1225,24 +1327,31 @@ export async function searchRelevantMemories(prompt: string): Promise<string> {
1225
1327
  .trim()
1226
1328
  .slice(0, 200);
1227
1329
  if (!sanitized) return "";
1330
+ if (options.signal?.aborted) controller.abort();
1331
+ else options.signal?.addEventListener("abort", abortFromCaller, { once: true });
1228
1332
 
1229
1333
  try {
1230
- const hasCollection = await checkCollection();
1334
+ const hasCollection = await checkCollection(undefined, { signal: controller.signal });
1231
1335
  if (!hasCollection) return "";
1232
1336
 
1233
- const results = await Promise.race([
1234
- runQmdSearch("keyword", sanitized, 3, { signal: controller.signal }),
1337
+ // Single `qmd query --no-rerank "lex: q\nvec: q"` invocation: qmd runs BM25 +
1338
+ // vector internally and fuses via RRF. ~1.5s vs 2.5s for two parallel calls.
1339
+ // No LLM query expansion, no LLM rerank — those add 2-6s and hurt named-entity
1340
+ // / temporal-reasoning recall (LongMemEval-S finding 2026-08-27).
1341
+ const deepResult = await Promise.race([
1342
+ runQmdSearch("deep", sanitized, RECALL_QMD_WIDEN, { signal: controller.signal }),
1235
1343
  new Promise<never>((_, reject) => {
1236
1344
  timer = setTimeout(() => {
1237
1345
  controller.abort();
1238
1346
  reject(new Error("timeout"));
1239
- }, 3_000);
1347
+ }, RECALL_TIMEOUT_MS);
1240
1348
  }),
1241
1349
  ]);
1242
1350
 
1243
- if (!results || results.results.length === 0) return "";
1351
+ const fused = deepResult.results.filter(qmdResultIsUserContent).slice(0, RECALL_LIMIT);
1352
+ if (fused.length === 0) return "";
1244
1353
 
1245
- const snippets = results.results
1354
+ const snippets = fused
1246
1355
  .map((r) => {
1247
1356
  const text = filterMemoryForContext(getQmdResultText(r));
1248
1357
  if (!text) return null;
@@ -1259,6 +1368,7 @@ export async function searchRelevantMemories(prompt: string): Promise<string> {
1259
1368
  return "";
1260
1369
  } finally {
1261
1370
  clearTimeout(timer);
1371
+ options.signal?.removeEventListener("abort", abortFromCaller);
1262
1372
  }
1263
1373
  }
1264
1374
 
@@ -1326,10 +1436,35 @@ export function runQmdSearch(
1326
1436
  mode: "keyword" | "semantic" | "deep",
1327
1437
  query: string,
1328
1438
  limit: number,
1329
- options: { signal?: AbortSignal } = {},
1439
+ options: { signal?: AbortSignal; collection?: string; index?: string } = {},
1330
1440
  ): Promise<{ results: QmdSearchResult[]; stderr: string }> {
1331
- const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
1332
- const args = [subcommand, "--json", "-c", QMD_COLLECTION_NAME, "-n", String(limit), query];
1441
+ // Route through qmd's typed-query interface (`qmd query --no-rerank "lex: q\nvec: q"`)
1442
+ // so mode="deep" runs BM25 + vector in ONE qmd invocation (~1.5s) with internal
1443
+ // RRF fusion — vs two parallel invocations (~2.5s wall). Keyword and semantic modes
1444
+ // use the typed form too so behavior is uniform: no LLM query expansion, no
1445
+ // LLM rerank. That was the source of 8-9s hybrid latency + the temporal-reasoning
1446
+ // regression on LongMemEval-S (grep 83% vs expanded-hybrid 62%).
1447
+ // qmd's `vec:` grammar treats a leading `-` on a token as negation, which
1448
+ // blows up natural-language questions like "e-commerce" or "friends-and-
1449
+ // family". lex tolerates negation intentionally, but for vec we normalize
1450
+ // hyphens to spaces (and collapse whitespace) before injection.
1451
+ const vecSafe = query.replace(/-/g, " ").replace(/\s+/g, " ").trim() || query;
1452
+ let typedBody: string;
1453
+ if (mode === "keyword") typedBody = `lex: ${query}`;
1454
+ else if (mode === "semantic") typedBody = `vec: ${vecSafe}`;
1455
+ else typedBody = `lex: ${query}\nvec: ${vecSafe}`;
1456
+ const args: string[] = [];
1457
+ if (options.index) args.push("--index", options.index);
1458
+ args.push(
1459
+ "query",
1460
+ "--json",
1461
+ "--no-rerank",
1462
+ "-c",
1463
+ options.collection ?? QMD_COLLECTION_NAME,
1464
+ "-n",
1465
+ String(limit),
1466
+ typedBody,
1467
+ );
1333
1468
 
1334
1469
  return new Promise((resolve, reject) => {
1335
1470
  execFileFn("qmd", args, { timeout: 60_000, signal: options.signal }, (err, stdout, stderr) => {
@@ -1395,6 +1530,49 @@ export interface ToolResult {
1395
1530
  isError?: boolean;
1396
1531
  }
1397
1532
 
1533
+ const LONG_TERM_SOFT_LINE_CAP = 50;
1534
+ const LONG_TERM_DUPLICATE_THRESHOLD = 0.6;
1535
+
1536
+ function significantWords(text: string): Set<string> {
1537
+ return new Set(
1538
+ text
1539
+ .toLowerCase()
1540
+ .replace(/<!--.*?-->/gs, " ")
1541
+ .replace(/[`*_#>[\]()]/g, " ")
1542
+ .split(/\s+/)
1543
+ .filter((word) => word.length > 2),
1544
+ );
1545
+ }
1546
+
1547
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
1548
+ if (a.size === 0 || b.size === 0) return 0;
1549
+ let intersection = 0;
1550
+ for (const word of a) {
1551
+ if (b.has(word)) intersection++;
1552
+ }
1553
+ return intersection / (a.size + b.size - intersection);
1554
+ }
1555
+
1556
+ /** Cheap near-duplicate check against existing long_term entries — advisory only, never blocks the write. */
1557
+ function findSimilarLongTermEntry(existingContent: string, newContent: string): string | null {
1558
+ const newWords = significantWords(newContent);
1559
+ if (newWords.size === 0) return null;
1560
+ const { entries } = splitLogicalMemoryEntries(existingContent);
1561
+ for (const entry of entries) {
1562
+ if (!entry.trim()) continue;
1563
+ if (jaccardSimilarity(newWords, significantWords(entry)) >= LONG_TERM_DUPLICATE_THRESHOLD) {
1564
+ return entry.trim();
1565
+ }
1566
+ }
1567
+ return null;
1568
+ }
1569
+
1570
+ function longTermLineCapWarning(finalContent: string): string | null {
1571
+ const lineCount = finalContent.split("\n").length;
1572
+ if (lineCount <= LONG_TERM_SOFT_LINE_CAP) return null;
1573
+ return `MEMORY.md is now ${lineCount} lines, over the recommended ~${LONG_TERM_SOFT_LINE_CAP}-line cap — consider \`agent-memory distil\` to curate it back down.`;
1574
+ }
1575
+
1398
1576
  export async function memoryWrite(params: {
1399
1577
  directory?: string;
1400
1578
  target?: "long_term" | "daily" | "topic";
@@ -1520,8 +1698,9 @@ export async function memoryWrite(params: {
1520
1698
  const stored = formatStoredEntry(content, `<!-- last updated: ${ts} [${sid}] -->`, params.sourceUri);
1521
1699
  fs.writeFileSync(memFile, stored.entry, "utf-8");
1522
1700
  await scheduleSearchRefresh();
1701
+ const warnings = [longTermLineCapWarning(stored.entry)].filter((w): w is string => w !== null);
1523
1702
  return {
1524
- text: `Overwrote MEMORY.md${existingSnippet}`,
1703
+ text: `Overwrote MEMORY.md${warnings.length ? `\n\n${warnings.join("\n\n")}` : ""}${existingSnippet}`,
1525
1704
  details: {
1526
1705
  path: memFile,
1527
1706
  target,
@@ -1532,17 +1711,26 @@ export async function memoryWrite(params: {
1532
1711
  redacted: stored.redacted,
1533
1712
  qmdUpdateMode: getQmdUpdateMode(),
1534
1713
  existingPreview,
1714
+ warnings,
1535
1715
  },
1536
1716
  };
1537
1717
  }
1538
1718
 
1539
1719
  // append (default)
1720
+ const similarEntry = findSimilarLongTermEntry(existing, content);
1540
1721
  const separator = existing.trim() ? "\n\n" : "";
1541
1722
  const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
1542
- fs.writeFileSync(memFile, existing + separator + stored.entry, "utf-8");
1723
+ const merged = existing + separator + stored.entry;
1724
+ fs.writeFileSync(memFile, merged, "utf-8");
1543
1725
  await scheduleSearchRefresh();
1726
+ const warnings = [
1727
+ similarEntry
1728
+ ? `Possible duplicate — an existing entry looks similar:\n${buildPreview(similarEntry, { maxLines: 4, maxChars: 300, mode: "start" }).preview}\nConsider \`--mode overwrite\` to curate instead of appending a near-duplicate.`
1729
+ : null,
1730
+ longTermLineCapWarning(merged),
1731
+ ].filter((w): w is string => w !== null);
1544
1732
  return {
1545
- text: `Appended to MEMORY.md${existingSnippet}`,
1733
+ text: `Appended to MEMORY.md${warnings.length ? `\n\n${warnings.join("\n\n")}` : ""}${existingSnippet}`,
1546
1734
  details: {
1547
1735
  path: memFile,
1548
1736
  target,
@@ -1553,6 +1741,7 @@ export async function memoryWrite(params: {
1553
1741
  redacted: stored.redacted,
1554
1742
  qmdUpdateMode: getQmdUpdateMode(),
1555
1743
  existingPreview,
1744
+ warnings,
1556
1745
  },
1557
1746
  };
1558
1747
  }