myagentmemory 0.4.16 → 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/LICENSE +1 -0
- package/README.md +78 -68
- package/dist/cli-spec.d.ts +7 -1
- package/dist/cli-spec.js +226 -12
- package/dist/cli.js +2140 -182
- package/dist/completions.js +24 -18
- package/dist/core.d.ts +42 -4
- package/dist/core.js +242 -68
- package/dist/hooks.d.ts +21 -1
- package/dist/hooks.js +382 -87
- package/dist/mcp-server.d.ts +27 -0
- package/dist/mcp-server.js +106 -0
- package/dist/plugin-bootstrap.js +6 -6
- package/dist/plugin-host.d.ts +51 -0
- package/dist/plugin-runtime.d.ts +20 -1
- package/dist/plugin-runtime.js +83 -2
- package/dist/plugin-service.d.ts +10 -4
- package/dist/plugin-service.js +83 -42
- package/dist/upgrade.d.ts +80 -0
- package/dist/upgrade.js +243 -0
- package/docs/official-plugin-bootstrap.md +30 -22
- package/package.json +24 -4
- package/scripts/install-skills.sh +1 -1
- package/skills/agent/SKILL.md +12 -2
- package/skills/claude-code/SKILL.md +17 -2
- package/skills/codex/SKILL.md +14 -2
- package/skills/cursor/SKILL.md +14 -2
- package/src/cli-spec.ts +230 -12
- package/src/completions.ts +26 -18
- package/src/core.ts +312 -123
- package/src/hooks.ts +395 -85
- package/src/plugin-bootstrap.ts +6 -6
- package/src/plugin-host.ts +53 -0
- package/src/cli.ts +0 -1271
- package/src/plugin-runtime.ts +0 -326
- package/src/plugin-service.ts +0 -628
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
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
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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(
|
|
763
|
+
const found = scanUp(realDirOf(argvPath));
|
|
691
764
|
if (found) return found;
|
|
692
765
|
}
|
|
693
766
|
|
|
694
|
-
const execDir =
|
|
695
|
-
const found = scanUp(
|
|
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(
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
}
|
|
810
|
-
|
|
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(
|
|
1298
|
+
.map(stripTruncation)
|
|
1211
1299
|
.filter((line) => line.length >= 8);
|
|
1212
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
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
|
-
},
|
|
1347
|
+
}, RECALL_TIMEOUT_MS);
|
|
1240
1348
|
}),
|
|
1241
1349
|
]);
|
|
1242
1350
|
|
|
1243
|
-
|
|
1351
|
+
const fused = deepResult.results.filter(qmdResultIsUserContent).slice(0, RECALL_LIMIT);
|
|
1352
|
+
if (fused.length === 0) return "";
|
|
1244
1353
|
|
|
1245
|
-
const snippets =
|
|
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
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
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
|
}
|