myagentmemory 0.4.17 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,12 @@ import { COMMAND_DESCRIPTIONS, COMMAND_OPTIONS, COMMANDS, GLOBAL_OPTIONS, OPTION
5
5
  function words(values) {
6
6
  return values.join(" ");
7
7
  }
8
+ function shellSingleQuote(value) {
9
+ return `'${value.replace(/'/g, "'\\''")}'`;
10
+ }
11
+ function powerShellSingleQuote(value) {
12
+ return `'${value.replace(/'/g, "''")}'`;
13
+ }
8
14
  function zshOptionValue(command, option) {
9
15
  if (option === "--target") {
10
16
  return command === "read"
@@ -30,7 +36,7 @@ function zshOptionValue(command, option) {
30
36
  }
31
37
  function zshOptionSpecs(command, options = COMMAND_OPTIONS[command] ?? []) {
32
38
  return options
33
- .map((option) => `'${option}[${optionDescription(option)}]${zshOptionValue(command, option)}'`)
39
+ .map((option) => shellSingleQuote(`${option}[${optionDescription(option)}]${zshOptionValue(command, option)}`))
34
40
  .join(" ");
35
41
  }
36
42
  function fishOption(command, condition, option) {
@@ -52,7 +58,7 @@ function fishOption(command, condition, option) {
52
58
  suggestions = " -a '(__fish_complete_directories)'";
53
59
  else if (spec?.value?.kind === "file")
54
60
  suggestions = " -F";
55
- return `complete -c agent-memory -n '${condition}' -l ${option.slice(2)}${value}${suggestions} -d '${optionDescription(option)}'`;
61
+ return `complete -c agent-memory -n ${shellSingleQuote(condition)} -l ${option.slice(2)}${value}${suggestions} -d ${shellSingleQuote(optionDescription(option))}`;
56
62
  }
57
63
  function bashCompletion() {
58
64
  return `# agent-memory completion for Bash
@@ -141,12 +147,12 @@ _agent-memory() {
141
147
  local subcommand="$words[3]"
142
148
  local action="$words[4]"
143
149
  local command_position=$CURRENT
144
- commands=(${COMMANDS.map((command) => `'${command}:${COMMAND_DESCRIPTIONS[command] ?? command}'`).join(" ")})
145
- plugin_commands=(${PLUGIN_COMMANDS.map((command) => `'${command}:${PLUGIN_COMMAND_DESCRIPTIONS[command]}'`).join(" ")})
146
- worker_actions=(${WORKER_ACTIONS.map((action) => `'${action}:${WORKER_ACTION_DESCRIPTIONS[action]}'`).join(" ")})
147
- scratchpad_actions=(${SCRATCHPAD_ACTIONS.map((action) => `'${action}:${SCRATCHPAD_ACTION_DESCRIPTIONS[action]}'`).join(" ")})
150
+ commands=(${COMMANDS.map((command) => shellSingleQuote(`${command}:${COMMAND_DESCRIPTIONS[command] ?? command}`)).join(" ")})
151
+ plugin_commands=(${PLUGIN_COMMANDS.map((command) => shellSingleQuote(`${command}:${PLUGIN_COMMAND_DESCRIPTIONS[command]}`)).join(" ")})
152
+ worker_actions=(${WORKER_ACTIONS.map((action) => shellSingleQuote(`${action}:${WORKER_ACTION_DESCRIPTIONS[action]}`)).join(" ")})
153
+ scratchpad_actions=(${SCRATCHPAD_ACTIONS.map((action) => shellSingleQuote(`${action}:${SCRATCHPAD_ACTION_DESCRIPTIONS[action]}`)).join(" ")})
148
154
  shells=(${Object.entries(SHELL_DESCRIPTIONS)
149
- .map(([shell, description]) => `'${shell}:${description}'`)
155
+ .map(([shell, description]) => shellSingleQuote(`${shell}:${description}`))
150
156
  .join(" ")})
151
157
 
152
158
  _arguments -C \\
@@ -208,11 +214,11 @@ function fishCompletion() {
208
214
  "# Installed automatically by: agent-memory completion fish",
209
215
  "# Print this script instead with: agent-memory completion fish --stdout",
210
216
  "complete -c agent-memory -f",
211
- ...COMMANDS.map((command) => `complete -c agent-memory -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command] ?? command}'`),
212
- ...PLUGIN_COMMANDS.map((command) => `complete -c agent-memory -n '__fish_seen_subcommand_from plugin; and not __fish_seen_subcommand_from ${words(PLUGIN_COMMANDS)}' -a '${command}' -d '${PLUGIN_COMMAND_DESCRIPTIONS[command]}'`),
213
- ...WORKER_ACTIONS.map((action) => `complete -c agent-memory -n '__fish_seen_subcommand_from plugin; and __fish_seen_subcommand_from worker; and not __fish_seen_subcommand_from ${words(WORKER_ACTIONS)}' -a '${action}' -d '${WORKER_ACTION_DESCRIPTIONS[action]}'`),
214
- ...SCRATCHPAD_ACTIONS.map((action) => `complete -c agent-memory -n '__fish_seen_subcommand_from scratchpad; and not __fish_seen_subcommand_from ${words(SCRATCHPAD_ACTIONS)}' -a '${action}' -d '${SCRATCHPAD_ACTION_DESCRIPTIONS[action]}'`),
215
- ...Object.entries(SHELL_DESCRIPTIONS).map(([shell, description]) => `complete -c agent-memory -n '__fish_seen_subcommand_from completion' -a '${shell}' -d '${description}'`),
217
+ ...COMMANDS.map((command) => `complete -c agent-memory -n '__fish_use_subcommand' -a '${command}' -d ${shellSingleQuote(COMMAND_DESCRIPTIONS[command] ?? command)}`),
218
+ ...PLUGIN_COMMANDS.map((command) => `complete -c agent-memory -n '__fish_seen_subcommand_from plugin; and not __fish_seen_subcommand_from ${words(PLUGIN_COMMANDS)}' -a '${command}' -d ${shellSingleQuote(PLUGIN_COMMAND_DESCRIPTIONS[command])}`),
219
+ ...WORKER_ACTIONS.map((action) => `complete -c agent-memory -n '__fish_seen_subcommand_from plugin; and __fish_seen_subcommand_from worker; and not __fish_seen_subcommand_from ${words(WORKER_ACTIONS)}' -a '${action}' -d ${shellSingleQuote(WORKER_ACTION_DESCRIPTIONS[action])}`),
220
+ ...SCRATCHPAD_ACTIONS.map((action) => `complete -c agent-memory -n '__fish_seen_subcommand_from scratchpad; and not __fish_seen_subcommand_from ${words(SCRATCHPAD_ACTIONS)}' -a '${action}' -d ${shellSingleQuote(SCRATCHPAD_ACTION_DESCRIPTIONS[action])}`),
221
+ ...Object.entries(SHELL_DESCRIPTIONS).map(([shell, description]) => `complete -c agent-memory -n '__fish_seen_subcommand_from completion' -a '${shell}' -d ${shellSingleQuote(description)}`),
216
222
  "complete -c agent-memory -l dir -r -a '(__fish_complete_directories)' -d 'override the active memory directory'",
217
223
  "complete -c agent-memory -l json -d 'emit command-specific structured JSON'",
218
224
  "complete -c agent-memory -s h -l help -d 'show help for the selected command'",
@@ -271,32 +277,32 @@ Register-ArgumentCompleter -Native -CommandName agent-memory -ScriptBlock {
271
277
  $shells = @('bash','zsh','fish','powershell')
272
278
  $commandDescriptions = @{
273
279
  ${Object.entries(COMMAND_DESCRIPTIONS)
274
- .map(([command, description]) => ` '${command}' = '${description}'`)
280
+ .map(([command, description]) => ` '${command}' = ${powerShellSingleQuote(description)}`)
275
281
  .join("\n")}
276
282
  }
277
283
  $pluginCommandDescriptions = @{
278
284
  ${Object.entries(PLUGIN_COMMAND_DESCRIPTIONS)
279
- .map(([command, description]) => ` '${command}' = '${description}'`)
285
+ .map(([command, description]) => ` '${command}' = ${powerShellSingleQuote(description)}`)
280
286
  .join("\n")}
281
287
  }
282
288
  $workerActionDescriptions = @{
283
289
  ${Object.entries(WORKER_ACTION_DESCRIPTIONS)
284
- .map(([action, description]) => ` '${action}' = '${description}'`)
290
+ .map(([action, description]) => ` '${action}' = ${powerShellSingleQuote(String(description))}`)
285
291
  .join("\n")}
286
292
  }
287
293
  $scratchpadActionDescriptions = @{
288
294
  ${Object.entries(SCRATCHPAD_ACTION_DESCRIPTIONS)
289
- .map(([action, description]) => ` '${action}' = '${description}'`)
295
+ .map(([action, description]) => ` '${action}' = ${powerShellSingleQuote(description)}`)
290
296
  .join("\n")}
291
297
  }
292
298
  $shellDescriptions = @{
293
299
  ${Object.entries(SHELL_DESCRIPTIONS)
294
- .map(([shell, description]) => ` '${shell}' = '${description}'`)
300
+ .map(([shell, description]) => ` '${shell}' = ${powerShellSingleQuote(description)}`)
295
301
  .join("\n")}
296
302
  }
297
303
  $optionDescriptions = @{
298
304
  ${Object.keys(OPTION_SPECS)
299
- .map((option) => ` '${option}' = '${optionDescription(option)}'`)
305
+ .map((option) => ` '${option}' = ${powerShellSingleQuote(optionDescription(option))}`)
300
306
  .join("\n")}
301
307
  }
302
308
  $globalOptions = @('${GLOBAL_OPTIONS.join("','")}')
package/dist/core.d.ts CHANGED
@@ -20,6 +20,18 @@ export declare function getScratchpadFile(): string;
20
20
  export declare function getDailyDir(): string;
21
21
  /** Get the current topics directory path. */
22
22
  export declare function getTopicsDir(): string;
23
+ export type HookMode = "stable" | "per-turn";
24
+ /**
25
+ * Resolve the active hook mode.
26
+ * Precedence: `AGENT_MEMORY_HOOK_MODE` env var → `<memoryDir>/hook-config.json`
27
+ * → default `per-turn`. Invalid values fall through to the next source.
28
+ */
29
+ export declare function readHookMode(): HookMode;
30
+ /**
31
+ * Atomically persist the chosen hook mode. Called by `install-hooks` after a
32
+ * successful install pass so `doctor` and later invocations can report it.
33
+ */
34
+ export declare function writeHookMode(mode: HookMode): void;
23
35
  export declare function ensureDirs(): void;
24
36
  export declare function todayStr(): string;
25
37
  export declare function yesterdayStr(): string;
@@ -69,7 +81,23 @@ export interface ScratchpadItem {
69
81
  }
70
82
  export declare function parseScratchpad(content: string): ScratchpadItem[];
71
83
  export declare function serializeScratchpad(items: ScratchpadItem[]): string;
84
+ /**
85
+ * Full context: scratchpad + topics + today + search + MEMORY.md + yesterday.
86
+ * Used by `agent-memory context` and by SessionStart in stable mode.
87
+ */
72
88
  export declare function buildMemoryContext(searchResults?: string): string;
89
+ /**
90
+ * Stable subset: scratchpad + topics + MEMORY.md. No daily logs, no search.
91
+ * Emitted at SessionStart in per-turn mode — the durable facts that survive
92
+ * across sessions and are unlikely to be affected by the current prompt.
93
+ */
94
+ export declare function buildStableContext(): string;
95
+ /**
96
+ * Dynamic subset: today's daily log + qmd search hits + yesterday's daily log.
97
+ * Emitted at UserPromptSubmit — turn-scoped context that can be scoped by the
98
+ * current query. Excludes MEMORY.md and scratchpad (already sent at SessionStart).
99
+ */
100
+ export declare function buildDynamicContext(searchResults?: string, _query?: string): string;
73
101
  type ExecFileFn = typeof execFile;
74
102
  type SpawnFn = typeof spawn;
75
103
  /** Override execFile implementation (for testing). */
@@ -104,8 +132,12 @@ export declare function qmdInstallInstructions(): string;
104
132
  export declare function qmdCollectionInstructions(): string;
105
133
  /** Auto-create the qmd collection and path contexts. */
106
134
  export declare function setupQmdCollection(): Promise<boolean>;
107
- export declare function detectQmd(): Promise<boolean>;
108
- export declare function checkCollection(name?: string): Promise<boolean>;
135
+ export declare function detectQmd(options?: {
136
+ signal?: AbortSignal;
137
+ }): Promise<boolean>;
138
+ export declare function checkCollection(name?: string, options?: {
139
+ signal?: AbortSignal;
140
+ }): Promise<boolean>;
109
141
  export declare function getQmdUpdateMode(): "background" | "manual" | "off";
110
142
  export declare function ensureQmdAvailableForUpdate(): Promise<boolean>;
111
143
  export declare function getQmdEmbedMode(): "background" | "manual" | "off";
@@ -114,7 +146,9 @@ export declare function scheduleQmdEmbed(): void;
114
146
  export declare function scheduleQmdUpdate(): void;
115
147
  export declare function runQmdUpdateNow(): Promise<void>;
116
148
  export declare function runQmdEmbedNow(): Promise<boolean>;
117
- export declare function ensureQmdAvailableForSync(): Promise<boolean>;
149
+ export declare function ensureQmdAvailableForSync(options?: {
150
+ signal?: AbortSignal;
151
+ }): Promise<boolean>;
118
152
  export declare function runQmdSync(): Promise<{
119
153
  updateOk: boolean;
120
154
  embedOk: boolean;
@@ -169,7 +203,9 @@ export interface QmdHealthInfo {
169
203
  export declare function parseQmdStatus(stdout: string, collectionName: string): QmdHealthInfo;
170
204
  export declare function getQmdHealth(): Promise<QmdHealthInfo | null>;
171
205
  /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
172
- export declare function searchRelevantMemories(prompt: string): Promise<string>;
206
+ export declare function searchRelevantMemories(prompt: string, options?: {
207
+ signal?: AbortSignal;
208
+ }): Promise<string>;
173
209
  export interface QmdSearchResult {
174
210
  path?: string;
175
211
  file?: string;
@@ -185,6 +221,8 @@ export declare function getQmdResultPath(r: QmdSearchResult): string | undefined
185
221
  export declare function getQmdResultText(r: QmdSearchResult): string;
186
222
  export declare function runQmdSearch(mode: "keyword" | "semantic" | "deep", query: string, limit: number, options?: {
187
223
  signal?: AbortSignal;
224
+ collection?: string;
225
+ index?: string;
188
226
  }): Promise<{
189
227
  results: QmdSearchResult[];
190
228
  stderr: string;
package/dist/core.js CHANGED
@@ -48,6 +48,40 @@ export function getDailyDir() {
48
48
  export function getTopicsDir() {
49
49
  return TOPICS_DIR;
50
50
  }
51
+ const HOOK_CONFIG_FILENAME = "hook-config.json";
52
+ const HOOK_MODE_DEFAULT = "per-turn";
53
+ function hookConfigPath() {
54
+ return path.join(MEMORY_DIR, HOOK_CONFIG_FILENAME);
55
+ }
56
+ /**
57
+ * Resolve the active hook mode.
58
+ * Precedence: `AGENT_MEMORY_HOOK_MODE` env var → `<memoryDir>/hook-config.json`
59
+ * → default `per-turn`. Invalid values fall through to the next source.
60
+ */
61
+ export function readHookMode() {
62
+ const env = process.env.AGENT_MEMORY_HOOK_MODE;
63
+ if (env === "stable" || env === "per-turn")
64
+ return env;
65
+ try {
66
+ const raw = fs.readFileSync(hookConfigPath(), "utf-8");
67
+ const parsed = JSON.parse(raw);
68
+ if (parsed.mode === "stable" || parsed.mode === "per-turn")
69
+ return parsed.mode;
70
+ }
71
+ catch { }
72
+ return HOOK_MODE_DEFAULT;
73
+ }
74
+ /**
75
+ * Atomically persist the chosen hook mode. Called by `install-hooks` after a
76
+ * successful install pass so `doctor` and later invocations can report it.
77
+ */
78
+ export function writeHookMode(mode) {
79
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
80
+ const target = hookConfigPath();
81
+ const temporary = `${target}.${process.pid}.tmp`;
82
+ fs.writeFileSync(temporary, `${JSON.stringify({ mode }, null, 2)}\n`, { mode: 0o600 });
83
+ fs.renameSync(temporary, target);
84
+ }
51
85
  // ---------------------------------------------------------------------------
52
86
  // Utilities
53
87
  // ---------------------------------------------------------------------------
@@ -337,62 +371,89 @@ export function serializeScratchpad(items) {
337
371
  // ---------------------------------------------------------------------------
338
372
  // Context builder
339
373
  // ---------------------------------------------------------------------------
340
- export function buildMemoryContext(searchResults) {
341
- ensureDirs();
342
- // Priority order: scratchpad > topics > today's daily > search results > MEMORY.md > yesterday's daily
343
- const sections = [];
374
+ function scratchpadContextSection() {
344
375
  const scratchpad = readFileSafe(SCRATCHPAD_FILE);
345
- if (scratchpad?.trim()) {
346
- const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
347
- if (openItems.length > 0) {
348
- const serialized = filterMemoryForContext(serializeScratchpad(openItems));
349
- const section = formatContextSection("## SCRATCHPAD.md (working context)", serialized, "start", CONTEXT_SCRATCHPAD_MAX_LINES, CONTEXT_SCRATCHPAD_MAX_CHARS);
350
- if (section)
351
- sections.push(section);
352
- }
353
- }
354
- const topicsSection = buildTopicsContextSection();
355
- if (topicsSection)
356
- sections.push(topicsSection);
376
+ if (!scratchpad?.trim())
377
+ return null;
378
+ const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
379
+ if (openItems.length === 0)
380
+ return null;
381
+ const serialized = filterMemoryForContext(serializeScratchpad(openItems));
382
+ return formatContextSection("## SCRATCHPAD.md (working context)", serialized, "start", CONTEXT_SCRATCHPAD_MAX_LINES, CONTEXT_SCRATCHPAD_MAX_CHARS);
383
+ }
384
+ function todayContextSection() {
357
385
  const today = todayStr();
386
+ const content = readFileSafe(dailyPath(today));
387
+ const safe = content ? filterMemoryForContext(content) : "";
388
+ if (!safe)
389
+ return null;
390
+ return formatContextSection(`## Daily log: ${today} (today)`, safe, "middle", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
391
+ }
392
+ function yesterdayContextSection() {
358
393
  const yesterday = yesterdayStr();
359
- const todayContent = readFileSafe(dailyPath(today));
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);
363
- if (section)
364
- sections.push(section);
365
- }
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);
369
- if (section)
370
- sections.push(section);
371
- }
394
+ const content = readFileSafe(dailyPath(yesterday));
395
+ const safe = content ? filterMemoryForContext(content) : "";
396
+ if (!safe)
397
+ return null;
398
+ return formatContextSection(`## Daily log: ${yesterday} (yesterday)`, safe, "end", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
399
+ }
400
+ function searchContextSection(searchResults) {
401
+ const safe = searchResults ? filterMemoryForContext(searchResults) : "";
402
+ if (!safe)
403
+ return null;
404
+ return formatContextSection("## Relevant memories (auto-retrieved)", safe, "start", CONTEXT_SEARCH_MAX_LINES, CONTEXT_SEARCH_MAX_CHARS);
405
+ }
406
+ function longTermContextSection() {
372
407
  const longTerm = readFileSafe(MEMORY_FILE);
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);
376
- if (section)
377
- sections.push(section);
378
- }
379
- const yesterdayContent = readFileSafe(dailyPath(yesterday));
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);
383
- if (section)
384
- sections.push(section);
385
- }
386
- if (sections.length === 0) {
408
+ const safe = longTerm ? filterMemoryForContext(longTerm) : "";
409
+ if (!safe)
410
+ return null;
411
+ return formatContextSection("## MEMORY.md (long-term)", safe, "middle", CONTEXT_LONG_TERM_MAX_LINES, CONTEXT_LONG_TERM_MAX_CHARS);
412
+ }
413
+ function assembleContext(sections) {
414
+ const kept = sections.filter((s) => !!s);
415
+ if (kept.length === 0)
387
416
  return "";
388
- }
389
- const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
417
+ const context = `# Memory\n\n${kept.join("\n\n---\n\n")}`;
390
418
  if (context.length > CONTEXT_MAX_CHARS) {
391
419
  const note = "\n\n[truncated overall context to 16000 chars]";
392
420
  return context.slice(0, CONTEXT_MAX_CHARS - note.length).trimEnd() + note;
393
421
  }
394
422
  return context;
395
423
  }
424
+ /**
425
+ * Full context: scratchpad + topics + today + search + MEMORY.md + yesterday.
426
+ * Used by `agent-memory context` and by SessionStart in stable mode.
427
+ */
428
+ export function buildMemoryContext(searchResults) {
429
+ ensureDirs();
430
+ return assembleContext([
431
+ scratchpadContextSection(),
432
+ buildTopicsContextSection(),
433
+ todayContextSection(),
434
+ searchContextSection(searchResults),
435
+ longTermContextSection(),
436
+ yesterdayContextSection(),
437
+ ]);
438
+ }
439
+ /**
440
+ * Stable subset: scratchpad + topics + MEMORY.md. No daily logs, no search.
441
+ * Emitted at SessionStart in per-turn mode — the durable facts that survive
442
+ * across sessions and are unlikely to be affected by the current prompt.
443
+ */
444
+ export function buildStableContext() {
445
+ ensureDirs();
446
+ return assembleContext([scratchpadContextSection(), buildTopicsContextSection(), longTermContextSection()]);
447
+ }
448
+ /**
449
+ * Dynamic subset: today's daily log + qmd search hits + yesterday's daily log.
450
+ * Emitted at UserPromptSubmit — turn-scoped context that can be scoped by the
451
+ * current query. Excludes MEMORY.md and scratchpad (already sent at SessionStart).
452
+ */
453
+ export function buildDynamicContext(searchResults, _query) {
454
+ ensureDirs();
455
+ return assembleContext([todayContextSection(), searchContextSection(searchResults), yesterdayContextSection()]);
456
+ }
396
457
  function buildTopicsContextSection() {
397
458
  let topicFiles;
398
459
  try {
@@ -531,14 +592,22 @@ function findSkillsRoot() {
531
592
  dir = parent;
532
593
  }
533
594
  };
595
+ const realDirOf = (p) => {
596
+ try {
597
+ return path.dirname(fs.realpathSync(p));
598
+ }
599
+ catch {
600
+ return path.resolve(path.dirname(p));
601
+ }
602
+ };
534
603
  const argvPath = process.argv[1];
535
604
  if (argvPath) {
536
- const found = scanUp(path.resolve(path.dirname(argvPath)));
605
+ const found = scanUp(realDirOf(argvPath));
537
606
  if (found)
538
607
  return found;
539
608
  }
540
- const execDir = path.dirname(process.execPath);
541
- const found = scanUp(path.resolve(execDir));
609
+ const execDir = realDirOf(process.execPath);
610
+ const found = scanUp(execDir);
542
611
  if (found)
543
612
  return found;
544
613
  return scanUp(path.resolve(process.cwd()));
@@ -603,18 +672,18 @@ export async function setupQmdCollection() {
603
672
  }
604
673
  return true;
605
674
  }
606
- export function detectQmd() {
675
+ export function detectQmd(options = {}) {
607
676
  return new Promise((resolve) => {
608
677
  // qmd doesn't reliably support --version; use a fast command that exits 0 when available.
609
- execFileFn("qmd", ["status"], { timeout: 5_000 }, (err) => {
678
+ execFileFn("qmd", ["status"], { timeout: 5_000, signal: options.signal }, (err) => {
610
679
  resolve(!err);
611
680
  });
612
681
  });
613
682
  }
614
- export function checkCollection(name) {
683
+ export function checkCollection(name, options = {}) {
615
684
  const collName = name ?? QMD_COLLECTION_NAME;
616
685
  return new Promise((resolve) => {
617
- execFileFn("qmd", ["collection", "list", "--json"], { timeout: 10_000 }, (err, stdout) => {
686
+ execFileFn("qmd", ["collection", "list", "--json"], { timeout: 10_000, signal: options.signal }, (err, stdout) => {
618
687
  if (err) {
619
688
  resolve(false);
620
689
  return;
@@ -723,10 +792,10 @@ export async function runQmdEmbedNow() {
723
792
  });
724
793
  });
725
794
  }
726
- export async function ensureQmdAvailableForSync() {
795
+ export async function ensureQmdAvailableForSync(options = {}) {
727
796
  if (qmdAvailable)
728
797
  return true;
729
- qmdAvailable = await detectQmd();
798
+ qmdAvailable = await detectQmd(options);
730
799
  return qmdAvailable;
731
800
  }
732
801
  export async function runQmdSync() {
@@ -988,18 +1057,41 @@ function qmdResultPassesSourcePolicy(filePath, snippet) {
988
1057
  if (!source)
989
1058
  return false;
990
1059
  const activeSource = filterMemoryForContext(source);
1060
+ // qmd truncates chunks with a trailing ellipsis, so requiring EVERY line to
1061
+ // substring-match the source is too strict (it fails on any truncated line).
1062
+ // We just need to verify the snippet came from THIS source and isn't stale.
1063
+ // Require at least one substantive line to match, and reject if none do.
1064
+ const stripTruncation = (line) => line
1065
+ .trim()
1066
+ .replace(/\s*\.\.\.\s*$/, "")
1067
+ .replace(/…\s*$/, "")
1068
+ .trim();
991
1069
  const snippetLines = snippet
992
1070
  .split("\n")
993
- .map((line) => line.trim())
1071
+ .map(stripTruncation)
994
1072
  .filter((line) => line.length >= 8);
995
- return snippetLines.length > 0 && snippetLines.every((line) => activeSource.includes(line));
1073
+ if (snippetLines.length === 0)
1074
+ return false;
1075
+ return snippetLines.some((line) => activeSource.includes(line));
1076
+ }
1077
+ const RECALL_TIMEOUT_MS = 8_000;
1078
+ const RECALL_LIMIT = 3;
1079
+ // Widen upstream so post-filtering (system/plugins/**) still leaves candidates.
1080
+ const RECALL_QMD_WIDEN = 15;
1081
+ const RECALL_EXCLUDE_PATH_FRAGMENTS = ["/system/plugins/", "system/plugins/"];
1082
+ function qmdResultIsUserContent(r) {
1083
+ const p = getQmdResultPath(r);
1084
+ if (!p)
1085
+ return true;
1086
+ return !RECALL_EXCLUDE_PATH_FRAGMENTS.some((frag) => p.includes(frag));
996
1087
  }
997
1088
  /** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
998
- export async function searchRelevantMemories(prompt) {
1089
+ export async function searchRelevantMemories(prompt, options = {}) {
999
1090
  if (!qmdAvailable || !prompt.trim())
1000
1091
  return "";
1001
1092
  let timer;
1002
1093
  const controller = new AbortController();
1094
+ const abortFromCaller = () => controller.abort();
1003
1095
  // Sanitize: strip control chars, limit to 200 chars for the search query
1004
1096
  const sanitized = prompt
1005
1097
  // biome-ignore lint/suspicious/noControlCharactersInRegex: we intentionally strip control chars.
@@ -1008,22 +1100,31 @@ export async function searchRelevantMemories(prompt) {
1008
1100
  .slice(0, 200);
1009
1101
  if (!sanitized)
1010
1102
  return "";
1103
+ if (options.signal?.aborted)
1104
+ controller.abort();
1105
+ else
1106
+ options.signal?.addEventListener("abort", abortFromCaller, { once: true });
1011
1107
  try {
1012
- const hasCollection = await checkCollection();
1108
+ const hasCollection = await checkCollection(undefined, { signal: controller.signal });
1013
1109
  if (!hasCollection)
1014
1110
  return "";
1015
- const results = await Promise.race([
1016
- runQmdSearch("keyword", sanitized, 3, { signal: controller.signal }),
1111
+ // Single `qmd query --no-rerank "lex: q\nvec: q"` invocation: qmd runs BM25 +
1112
+ // vector internally and fuses via RRF. ~1.5s vs 2.5s for two parallel calls.
1113
+ // No LLM query expansion, no LLM rerank — those add 2-6s and hurt named-entity
1114
+ // / temporal-reasoning recall (LongMemEval-S finding 2026-08-27).
1115
+ const deepResult = await Promise.race([
1116
+ runQmdSearch("deep", sanitized, RECALL_QMD_WIDEN, { signal: controller.signal }),
1017
1117
  new Promise((_, reject) => {
1018
1118
  timer = setTimeout(() => {
1019
1119
  controller.abort();
1020
1120
  reject(new Error("timeout"));
1021
- }, 3_000);
1121
+ }, RECALL_TIMEOUT_MS);
1022
1122
  }),
1023
1123
  ]);
1024
- if (!results || results.results.length === 0)
1124
+ const fused = deepResult.results.filter(qmdResultIsUserContent).slice(0, RECALL_LIMIT);
1125
+ if (fused.length === 0)
1025
1126
  return "";
1026
- const snippets = results.results
1127
+ const snippets = fused
1027
1128
  .map((r) => {
1028
1129
  const text = filterMemoryForContext(getQmdResultText(r));
1029
1130
  if (!text)
@@ -1044,6 +1145,7 @@ export async function searchRelevantMemories(prompt) {
1044
1145
  }
1045
1146
  finally {
1046
1147
  clearTimeout(timer);
1148
+ options.signal?.removeEventListener("abort", abortFromCaller);
1047
1149
  }
1048
1150
  }
1049
1151
  export function getQmdResultPath(r) {
@@ -1091,8 +1193,28 @@ function parseQmdJson(stdout) {
1091
1193
  return JSON.parse(jsonText);
1092
1194
  }
1093
1195
  export function runQmdSearch(mode, query, limit, options = {}) {
1094
- const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
1095
- const args = [subcommand, "--json", "-c", QMD_COLLECTION_NAME, "-n", String(limit), query];
1196
+ // Route through qmd's typed-query interface (`qmd query --no-rerank "lex: q\nvec: q"`)
1197
+ // so mode="deep" runs BM25 + vector in ONE qmd invocation (~1.5s) with internal
1198
+ // RRF fusion — vs two parallel invocations (~2.5s wall). Keyword and semantic modes
1199
+ // use the typed form too so behavior is uniform: no LLM query expansion, no
1200
+ // LLM rerank. That was the source of 8-9s hybrid latency + the temporal-reasoning
1201
+ // regression on LongMemEval-S (grep 83% vs expanded-hybrid 62%).
1202
+ // qmd's `vec:` grammar treats a leading `-` on a token as negation, which
1203
+ // blows up natural-language questions like "e-commerce" or "friends-and-
1204
+ // family". lex tolerates negation intentionally, but for vec we normalize
1205
+ // hyphens to spaces (and collapse whitespace) before injection.
1206
+ const vecSafe = query.replace(/-/g, " ").replace(/\s+/g, " ").trim() || query;
1207
+ let typedBody;
1208
+ if (mode === "keyword")
1209
+ typedBody = `lex: ${query}`;
1210
+ else if (mode === "semantic")
1211
+ typedBody = `vec: ${vecSafe}`;
1212
+ else
1213
+ typedBody = `lex: ${query}\nvec: ${vecSafe}`;
1214
+ const args = [];
1215
+ if (options.index)
1216
+ args.push("--index", options.index);
1217
+ args.push("query", "--json", "--no-rerank", "-c", options.collection ?? QMD_COLLECTION_NAME, "-n", String(limit), typedBody);
1096
1218
  return new Promise((resolve, reject) => {
1097
1219
  execFileFn("qmd", args, { timeout: 60_000, signal: options.signal }, (err, stdout, stderr) => {
1098
1220
  if (err) {
@@ -1149,6 +1271,47 @@ export async function probeEmbeddings() {
1149
1271
  clearTimeout(timer);
1150
1272
  }
1151
1273
  }
1274
+ const LONG_TERM_SOFT_LINE_CAP = 50;
1275
+ const LONG_TERM_DUPLICATE_THRESHOLD = 0.6;
1276
+ function significantWords(text) {
1277
+ return new Set(text
1278
+ .toLowerCase()
1279
+ .replace(/<!--.*?-->/gs, " ")
1280
+ .replace(/[`*_#>[\]()]/g, " ")
1281
+ .split(/\s+/)
1282
+ .filter((word) => word.length > 2));
1283
+ }
1284
+ function jaccardSimilarity(a, b) {
1285
+ if (a.size === 0 || b.size === 0)
1286
+ return 0;
1287
+ let intersection = 0;
1288
+ for (const word of a) {
1289
+ if (b.has(word))
1290
+ intersection++;
1291
+ }
1292
+ return intersection / (a.size + b.size - intersection);
1293
+ }
1294
+ /** Cheap near-duplicate check against existing long_term entries — advisory only, never blocks the write. */
1295
+ function findSimilarLongTermEntry(existingContent, newContent) {
1296
+ const newWords = significantWords(newContent);
1297
+ if (newWords.size === 0)
1298
+ return null;
1299
+ const { entries } = splitLogicalMemoryEntries(existingContent);
1300
+ for (const entry of entries) {
1301
+ if (!entry.trim())
1302
+ continue;
1303
+ if (jaccardSimilarity(newWords, significantWords(entry)) >= LONG_TERM_DUPLICATE_THRESHOLD) {
1304
+ return entry.trim();
1305
+ }
1306
+ }
1307
+ return null;
1308
+ }
1309
+ function longTermLineCapWarning(finalContent) {
1310
+ const lineCount = finalContent.split("\n").length;
1311
+ if (lineCount <= LONG_TERM_SOFT_LINE_CAP)
1312
+ return null;
1313
+ 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.`;
1314
+ }
1152
1315
  export async function memoryWrite(params) {
1153
1316
  const memoryDir = params.directory ? path.resolve(params.directory) : getMemoryDir();
1154
1317
  fs.mkdirSync(memoryDir, { recursive: true });
@@ -1256,8 +1419,9 @@ export async function memoryWrite(params) {
1256
1419
  const stored = formatStoredEntry(content, `<!-- last updated: ${ts} [${sid}] -->`, params.sourceUri);
1257
1420
  fs.writeFileSync(memFile, stored.entry, "utf-8");
1258
1421
  await scheduleSearchRefresh();
1422
+ const warnings = [longTermLineCapWarning(stored.entry)].filter((w) => w !== null);
1259
1423
  return {
1260
- text: `Overwrote MEMORY.md${existingSnippet}`,
1424
+ text: `Overwrote MEMORY.md${warnings.length ? `\n\n${warnings.join("\n\n")}` : ""}${existingSnippet}`,
1261
1425
  details: {
1262
1426
  path: memFile,
1263
1427
  target,
@@ -1268,16 +1432,25 @@ export async function memoryWrite(params) {
1268
1432
  redacted: stored.redacted,
1269
1433
  qmdUpdateMode: getQmdUpdateMode(),
1270
1434
  existingPreview,
1435
+ warnings,
1271
1436
  },
1272
1437
  };
1273
1438
  }
1274
1439
  // append (default)
1440
+ const similarEntry = findSimilarLongTermEntry(existing, content);
1275
1441
  const separator = existing.trim() ? "\n\n" : "";
1276
1442
  const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
1277
- fs.writeFileSync(memFile, existing + separator + stored.entry, "utf-8");
1443
+ const merged = existing + separator + stored.entry;
1444
+ fs.writeFileSync(memFile, merged, "utf-8");
1278
1445
  await scheduleSearchRefresh();
1446
+ const warnings = [
1447
+ similarEntry
1448
+ ? `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.`
1449
+ : null,
1450
+ longTermLineCapWarning(merged),
1451
+ ].filter((w) => w !== null);
1279
1452
  return {
1280
- text: `Appended to MEMORY.md${existingSnippet}`,
1453
+ text: `Appended to MEMORY.md${warnings.length ? `\n\n${warnings.join("\n\n")}` : ""}${existingSnippet}`,
1281
1454
  details: {
1282
1455
  path: memFile,
1283
1456
  target,
@@ -1288,6 +1461,7 @@ export async function memoryWrite(params) {
1288
1461
  redacted: stored.redacted,
1289
1462
  qmdUpdateMode: getQmdUpdateMode(),
1290
1463
  existingPreview,
1464
+ warnings,
1291
1465
  },
1292
1466
  };
1293
1467
  }