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/README.md +68 -71
- package/dist/cli-spec.d.ts +7 -1
- package/dist/cli-spec.js +214 -12
- package/dist/cli.js +2049 -156
- 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 +4 -4
- package/dist/plugin-host.d.ts +33 -0
- package/dist/plugin-runtime.d.ts +13 -1
- package/dist/plugin-runtime.js +44 -2
- package/dist/plugin-service.d.ts +10 -4
- package/dist/plugin-service.js +52 -8
- package/dist/upgrade.d.ts +80 -0
- package/dist/upgrade.js +243 -0
- package/docs/official-plugin-bootstrap.md +3 -3
- 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 +218 -12
- package/src/completions.ts +26 -18
- package/src/core.ts +312 -123
- package/src/hooks.ts +395 -85
- package/src/plugin-bootstrap.ts +4 -4
- package/src/plugin-host.ts +33 -0
- package/src/cli.ts +0 -1332
- package/src/plugin-runtime.ts +0 -390
- package/src/plugin-service.ts +0 -627
package/dist/completions.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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) =>
|
|
145
|
-
plugin_commands=(${PLUGIN_COMMANDS.map((command) =>
|
|
146
|
-
worker_actions=(${WORKER_ACTIONS.map((action) =>
|
|
147
|
-
scratchpad_actions=(${SCRATCHPAD_ACTIONS.map((action) =>
|
|
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]) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
215
|
-
...Object.entries(SHELL_DESCRIPTIONS).map(([shell, description]) => `complete -c agent-memory -n '__fish_seen_subcommand_from completion' -a '${shell}' -d
|
|
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}' =
|
|
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}' =
|
|
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}' =
|
|
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}' =
|
|
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}' =
|
|
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}' =
|
|
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(
|
|
108
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
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(
|
|
605
|
+
const found = scanUp(realDirOf(argvPath));
|
|
537
606
|
if (found)
|
|
538
607
|
return found;
|
|
539
608
|
}
|
|
540
|
-
const execDir =
|
|
541
|
-
const found = scanUp(
|
|
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(
|
|
1071
|
+
.map(stripTruncation)
|
|
994
1072
|
.filter((line) => line.length >= 8);
|
|
995
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
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
|
-
},
|
|
1121
|
+
}, RECALL_TIMEOUT_MS);
|
|
1022
1122
|
}),
|
|
1023
1123
|
]);
|
|
1024
|
-
|
|
1124
|
+
const fused = deepResult.results.filter(qmdResultIsUserContent).slice(0, RECALL_LIMIT);
|
|
1125
|
+
if (fused.length === 0)
|
|
1025
1126
|
return "";
|
|
1026
|
-
const snippets =
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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
|
}
|