@rohirik/openltm-core 2.9.1 → 2.11.0
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/package.json +9 -4
- package/src/__tests__/cli/memory.test.ts +186 -0
- package/src/__tests__/codeAnchors.test.ts +246 -0
- package/src/__tests__/mcp-server.test.ts +39 -0
- package/src/__tests__/migrations/023_memory_files.test.ts +83 -0
- package/src/anchors.ts +39 -0
- package/src/cli/bin.ts +22 -6
- package/src/cli/memory.ts +305 -0
- package/src/db.ts +123 -5
- package/src/index.ts +3 -2
- package/src/mcp/server.ts +449 -0
- package/src/schema.sql +18 -1
package/src/cli/bin.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { runInstallCli } from "./install.js";
|
|
16
16
|
import { runHook } from "./hook.js";
|
|
17
|
+
import { runMemoryCli } from "./memory.js";
|
|
17
18
|
|
|
18
19
|
function printHelp(): void {
|
|
19
20
|
process.stdout.write(
|
|
@@ -29,8 +30,10 @@ function printHelp(): void {
|
|
|
29
30
|
" --help, -h Show this help",
|
|
30
31
|
"",
|
|
31
32
|
" Sub-commands:",
|
|
33
|
+
" memory <cmd> Read/write memories from the shell (learn, recall,",
|
|
34
|
+
" forget, relate, context) — run 'memory --help'",
|
|
32
35
|
" hook --name <event> Lifecycle hook stub (for Claude Code hook wiring)",
|
|
33
|
-
" mcp-serve Start the LTM MCP server",
|
|
36
|
+
" mcp-serve Start the LTM MCP server (stdio)",
|
|
34
37
|
"",
|
|
35
38
|
" If no target flags are given, agents are auto-detected.",
|
|
36
39
|
"",
|
|
@@ -46,6 +49,12 @@ async function main(): Promise<void> {
|
|
|
46
49
|
process.exit(0);
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
// Sub-command: memory (learn | recall | forget | relate | context)
|
|
53
|
+
if (argv[0] === "memory") {
|
|
54
|
+
const exitCode = await runMemoryCli(argv.slice(1));
|
|
55
|
+
process.exit(exitCode);
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
// Sub-command: hook
|
|
50
59
|
if (argv[0] === "hook") {
|
|
51
60
|
const nameIdx = argv.indexOf("--name");
|
|
@@ -58,12 +67,19 @@ async function main(): Promise<void> {
|
|
|
58
67
|
return;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
// Sub-command: mcp-serve
|
|
70
|
+
// Sub-command: mcp-serve — run the LTM MCP server on stdio
|
|
62
71
|
if (argv[0] === "mcp-serve") {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
const { startMcpServer } = await import("../mcp/server.js");
|
|
73
|
+
await startMcpServer();
|
|
74
|
+
return; // keep the process alive — transport owns the event loop
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Unknown positional sub-command: print help + exit 1. Never fall through
|
|
78
|
+
// to the install wizard (e.g. `ltm memry learn ...` must not start installing).
|
|
79
|
+
if (argv[0] && !argv[0].startsWith("--")) {
|
|
80
|
+
process.stderr.write(` ltm: unknown sub-command '${argv[0]}'\n`);
|
|
81
|
+
printHelp();
|
|
82
|
+
process.exit(1);
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
// Parse installer flags
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/memory.ts — `ltm memory <learn|recall|forget|relate|context>` subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Gives headless/CLI agents (OpenCode CLI, scripts, cron jobs) a direct path
|
|
5
|
+
* to LTM without the agent TUI, slash commands, or a running MCP server:
|
|
6
|
+
*
|
|
7
|
+
* bunx @rohirik/openltm-core memory learn --text "..." --category gotcha
|
|
8
|
+
* bunx @rohirik/openltm-core memory recall --query "docker rate limit" --json
|
|
9
|
+
*
|
|
10
|
+
* DB path resolution follows paths.ts: LTM_DB_PATH > CLAUDE_PLUGIN_DATA > dev
|
|
11
|
+
* fallback. All writes route through db.ts learn() and therefore through
|
|
12
|
+
* scrubSecrets — the CLI is not a secret-leak bypass.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 success · 1 usage error · 2 runtime/DB error.
|
|
15
|
+
*/
|
|
16
|
+
import { learn, recall, forget, relate, getContextMerge, type MemoryCategory } from "../db.js";
|
|
17
|
+
import { waitForInit } from "../shared-db.js";
|
|
18
|
+
|
|
19
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type MemoryCommand = "learn" | "recall" | "forget" | "relate" | "context";
|
|
22
|
+
|
|
23
|
+
export interface ParsedMemoryArgs {
|
|
24
|
+
ok: true;
|
|
25
|
+
command: MemoryCommand;
|
|
26
|
+
options: Record<string, string | number | string[] | undefined>;
|
|
27
|
+
json: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MemoryArgsError {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MemoryCommandResult {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
output: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CATEGORIES: readonly string[] = ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"];
|
|
41
|
+
const RELATIONSHIP_TYPES: readonly string[] = ["supports", "contradicts", "refines", "depends_on", "related_to", "supersedes"];
|
|
42
|
+
|
|
43
|
+
// ── Parsing ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Scan argv for `--flag value` pairs and bare `--flag` booleans. */
|
|
46
|
+
function scanFlags(argv: string[]): Record<string, string | true> {
|
|
47
|
+
const flags: Record<string, string | true> = {};
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const arg = argv[i];
|
|
50
|
+
if (!arg?.startsWith("--")) continue;
|
|
51
|
+
const name = arg.slice(2);
|
|
52
|
+
const next = argv[i + 1];
|
|
53
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
54
|
+
flags[name] = next;
|
|
55
|
+
i++;
|
|
56
|
+
} else {
|
|
57
|
+
flags[name] = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return flags;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function asInt(value: string | true | undefined): number | undefined {
|
|
64
|
+
if (typeof value !== "string") return undefined;
|
|
65
|
+
const n = Number.parseInt(value, 10);
|
|
66
|
+
return Number.isNaN(n) ? undefined : n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asList(value: string | true | undefined): string[] | undefined {
|
|
70
|
+
if (typeof value !== "string") return undefined;
|
|
71
|
+
const items = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
72
|
+
return items.length > 0 ? items : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* parseMemoryArgs — pure parser for `memory` subcommand argv (without the
|
|
77
|
+
* leading "memory"). Returns a typed parse result; never exits the process.
|
|
78
|
+
*/
|
|
79
|
+
export function parseMemoryArgs(argv: string[]): ParsedMemoryArgs | MemoryArgsError {
|
|
80
|
+
const command = argv[0];
|
|
81
|
+
if (!command || command.startsWith("--")) {
|
|
82
|
+
return { ok: false, error: "missing subcommand — expected one of: learn, recall, forget, relate, context" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const flags = scanFlags(argv.slice(1));
|
|
86
|
+
const json = flags["json"] === true;
|
|
87
|
+
|
|
88
|
+
switch (command) {
|
|
89
|
+
case "learn": {
|
|
90
|
+
const text = typeof flags["text"] === "string" ? flags["text"] : undefined;
|
|
91
|
+
if (!text) return { ok: false, error: "learn: --text <content> is required" };
|
|
92
|
+
const category = typeof flags["category"] === "string" ? flags["category"] : undefined;
|
|
93
|
+
if (category && !CATEGORIES.includes(category)) {
|
|
94
|
+
return { ok: false, error: `learn: invalid category '${category}' — expected one of: ${CATEGORIES.join(", ")}` };
|
|
95
|
+
}
|
|
96
|
+
const importance = asInt(flags["importance"]);
|
|
97
|
+
if (flags["importance"] !== undefined && (importance === undefined || importance < 1 || importance > 5)) {
|
|
98
|
+
return { ok: false, error: "learn: --importance must be an integer 1-5" };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
command: "learn",
|
|
103
|
+
json,
|
|
104
|
+
options: {
|
|
105
|
+
text,
|
|
106
|
+
title: typeof flags["title"] === "string" ? flags["title"] : undefined,
|
|
107
|
+
category,
|
|
108
|
+
importance,
|
|
109
|
+
project: typeof flags["project"] === "string" ? flags["project"] : undefined,
|
|
110
|
+
tags: asList(flags["tags"]),
|
|
111
|
+
files: asList(flags["files"]),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "recall": {
|
|
117
|
+
const limit = asInt(flags["limit"]);
|
|
118
|
+
if (flags["limit"] !== undefined && (limit === undefined || limit < 1)) {
|
|
119
|
+
return { ok: false, error: "recall: --limit must be a positive integer" };
|
|
120
|
+
}
|
|
121
|
+
const category = typeof flags["category"] === "string" ? flags["category"] : undefined;
|
|
122
|
+
if (category && !CATEGORIES.includes(category)) {
|
|
123
|
+
return { ok: false, error: `recall: invalid category '${category}' — expected one of: ${CATEGORIES.join(", ")}` };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
command: "recall",
|
|
128
|
+
json,
|
|
129
|
+
options: {
|
|
130
|
+
query: typeof flags["query"] === "string" ? flags["query"] : undefined,
|
|
131
|
+
category,
|
|
132
|
+
project: typeof flags["project"] === "string" ? flags["project"] : undefined,
|
|
133
|
+
limit,
|
|
134
|
+
tags: asList(flags["tags"]),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "forget": {
|
|
140
|
+
const id = asInt(flags["id"]);
|
|
141
|
+
if (id === undefined) return { ok: false, error: "forget: --id <number> is required" };
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
command: "forget",
|
|
145
|
+
json,
|
|
146
|
+
options: { id, reason: typeof flags["reason"] === "string" ? flags["reason"] : undefined },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "relate": {
|
|
151
|
+
const from = asInt(flags["from"]);
|
|
152
|
+
const to = asInt(flags["to"]);
|
|
153
|
+
const type = typeof flags["type"] === "string" ? flags["type"] : undefined;
|
|
154
|
+
if (from === undefined || to === undefined) {
|
|
155
|
+
return { ok: false, error: "relate: --from <id> and --to <id> are required" };
|
|
156
|
+
}
|
|
157
|
+
if (!type || !RELATIONSHIP_TYPES.includes(type)) {
|
|
158
|
+
return { ok: false, error: `relate: --type must be one of: ${RELATIONSHIP_TYPES.join(", ")}` };
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, command: "relate", json, options: { from, to, type } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case "context": {
|
|
164
|
+
const project = typeof flags["project"] === "string" ? flags["project"] : undefined;
|
|
165
|
+
if (!project) return { ok: false, error: "context: --project <name> is required" };
|
|
166
|
+
return { ok: true, command: "context", json, options: { project } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
default:
|
|
170
|
+
return { ok: false, error: `unknown memory subcommand '${command}' — expected one of: learn, recall, forget, relate, context` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Execution ─────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* runMemoryCommand — execute a parsed memory command against the LTM DB.
|
|
178
|
+
* Returns output text + exit code; never writes to stdout/stderr or exits
|
|
179
|
+
* (the bin entrypoint handles I/O), so it is directly unit-testable.
|
|
180
|
+
*/
|
|
181
|
+
export async function runMemoryCommand(parsed: ParsedMemoryArgs): Promise<MemoryCommandResult> {
|
|
182
|
+
try {
|
|
183
|
+
await waitForInit();
|
|
184
|
+
const o = parsed.options;
|
|
185
|
+
|
|
186
|
+
switch (parsed.command) {
|
|
187
|
+
case "learn": {
|
|
188
|
+
const result = learn({
|
|
189
|
+
content: o["text"] as string,
|
|
190
|
+
title: o["title"] as string | undefined,
|
|
191
|
+
category: (o["category"] as MemoryCategory | undefined) ?? "pattern",
|
|
192
|
+
importance: o["importance"] as number | undefined,
|
|
193
|
+
project_scope: o["project"] as string | undefined,
|
|
194
|
+
tags: o["tags"] as string[] | undefined,
|
|
195
|
+
files: o["files"] as string[] | undefined,
|
|
196
|
+
actor: "cli:ltm_memory",
|
|
197
|
+
});
|
|
198
|
+
const payload = { ...result, category: (o["category"] as string | undefined) ?? "pattern" };
|
|
199
|
+
return {
|
|
200
|
+
exitCode: 0,
|
|
201
|
+
output: parsed.json
|
|
202
|
+
? JSON.stringify(payload)
|
|
203
|
+
: ` ${payload.action === "created" ? "Stored" : "Reinforced"} memory #${payload.id} (category: ${payload.category}, confirms: ${payload.confirm_count})`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "recall": {
|
|
208
|
+
const results = await recall({
|
|
209
|
+
query: o["query"] as string | undefined,
|
|
210
|
+
category: o["category"] as MemoryCategory | undefined,
|
|
211
|
+
project: o["project"] as string | undefined,
|
|
212
|
+
limit: o["limit"] as number | undefined,
|
|
213
|
+
tags: o["tags"] as string[] | undefined,
|
|
214
|
+
});
|
|
215
|
+
if (parsed.json) {
|
|
216
|
+
const compact = results.map((m) => ({
|
|
217
|
+
id: m.id, title: m.title, content: m.content, category: m.category,
|
|
218
|
+
importance: m.importance, project_scope: m.project_scope,
|
|
219
|
+
}));
|
|
220
|
+
return { exitCode: 0, output: JSON.stringify(compact) };
|
|
221
|
+
}
|
|
222
|
+
if (results.length === 0) return { exitCode: 0, output: " No memories found." };
|
|
223
|
+
const lines = results.map(
|
|
224
|
+
(m) => ` #${m.id} [${m.category}/${m.importance}]${m.project_scope ? ` (${m.project_scope})` : ""} ${m.title ?? m.content.slice(0, 60)}`,
|
|
225
|
+
);
|
|
226
|
+
return { exitCode: 0, output: lines.join("\n") };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case "forget": {
|
|
230
|
+
forget({ id: o["id"] as number, reason: o["reason"] as string | undefined, actor: "cli:ltm_memory" });
|
|
231
|
+
return {
|
|
232
|
+
exitCode: 0,
|
|
233
|
+
output: parsed.json ? JSON.stringify({ ok: true, id: o["id"] }) : ` Forgot memory #${o["id"]}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case "relate": {
|
|
238
|
+
relate({ source_id: o["from"] as number, target_id: o["to"] as number, relationship_type: o["type"] as string });
|
|
239
|
+
return {
|
|
240
|
+
exitCode: 0,
|
|
241
|
+
output: parsed.json ? JSON.stringify({ ok: true }) : ` Related #${o["from"]} -[${o["type"]}]-> #${o["to"]}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case "context": {
|
|
246
|
+
const result = getContextMerge(o["project"] as string);
|
|
247
|
+
if (parsed.json) return { exitCode: 0, output: JSON.stringify(result) };
|
|
248
|
+
const fmt = (m: { id: number; content: string }) => ` #${m.id} ${m.content.slice(0, 80)}`;
|
|
249
|
+
return {
|
|
250
|
+
exitCode: 0,
|
|
251
|
+
output: [
|
|
252
|
+
` Globals (${result.globals.length}):`,
|
|
253
|
+
...result.globals.map(fmt),
|
|
254
|
+
` Scoped to ${o["project"]} (${result.scoped.length}):`,
|
|
255
|
+
...result.scoped.map(fmt),
|
|
256
|
+
].join("\n"),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { exitCode: 2, output: ` ltm memory ${parsed.command}: ${String(err)}` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Entrypoint glue ───────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
export function printMemoryHelp(): string {
|
|
268
|
+
return [
|
|
269
|
+
"",
|
|
270
|
+
" ltm memory <command> [options]",
|
|
271
|
+
"",
|
|
272
|
+
" Commands:",
|
|
273
|
+
" learn --text <content> [--title <t>] [--category <c>] [--importance 1-5]",
|
|
274
|
+
" [--project <scope>] [--tags a,b] [--files f1,f2] [--json]",
|
|
275
|
+
" recall [--query <q>] [--category <c>] [--project <scope>] [--limit N]",
|
|
276
|
+
" [--tags a,b] [--json]",
|
|
277
|
+
" forget --id <n> [--reason <r>] [--json]",
|
|
278
|
+
" relate --from <id> --to <id> --type <rel> [--json]",
|
|
279
|
+
" context --project <name> [--json]",
|
|
280
|
+
"",
|
|
281
|
+
` categories: ${CATEGORIES.join(", ")}`,
|
|
282
|
+
` relations: ${RELATIONSHIP_TYPES.join(", ")}`,
|
|
283
|
+
"",
|
|
284
|
+
" DB path: LTM_DB_PATH env var overrides the default plugin data location.",
|
|
285
|
+
"",
|
|
286
|
+
].join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** runMemoryCli — top-level handler invoked by bin.ts. Handles I/O + exit code. */
|
|
290
|
+
export async function runMemoryCli(argv: string[]): Promise<number> {
|
|
291
|
+
if (argv[0] === "--help" || argv[0] === "-h" || argv.length === 0) {
|
|
292
|
+
process.stdout.write(printMemoryHelp());
|
|
293
|
+
return argv.length === 0 ? 1 : 0;
|
|
294
|
+
}
|
|
295
|
+
const parsed = parseMemoryArgs(argv);
|
|
296
|
+
if (!parsed.ok) {
|
|
297
|
+
process.stderr.write(` ltm memory: ${parsed.error}\n`);
|
|
298
|
+
process.stderr.write(printMemoryHelp());
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const result = await runMemoryCommand(parsed);
|
|
302
|
+
const stream = result.exitCode === 0 ? process.stdout : process.stderr;
|
|
303
|
+
stream.write(result.output + "\n");
|
|
304
|
+
return result.exitCode;
|
|
305
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Database } from "bun:sqlite";
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
import { normalizeKey } from "./dedup.js";
|
|
9
|
+
import { normalizeAnchorPaths } from "./anchors.js";
|
|
9
10
|
import { getDb, DB_PATH, configure as configureDb } from "./shared-db.js";
|
|
10
11
|
import { enqueueEmbedding } from "./queue/index.js";
|
|
11
12
|
import { notifyLtm, notifyMemoryAdded } from "./events/index.js";
|
|
@@ -51,6 +52,8 @@ export interface Memory {
|
|
|
51
52
|
workspace_id?: string;
|
|
52
53
|
agent_id?: string;
|
|
53
54
|
decay_score?: number;
|
|
55
|
+
stale_flagged_at?: string | null;
|
|
56
|
+
stale_reason?: string | null;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface MemoryRelation {
|
|
@@ -68,6 +71,8 @@ export interface MemoryWithRelations extends Memory {
|
|
|
68
71
|
provenance?: import("./dao/types.js").ProvenanceRow[];
|
|
69
72
|
/** Score breakdown + temperature — always populated by recall(). */
|
|
70
73
|
explainer?: import("./recall/explainer.js").RecallExplainer;
|
|
74
|
+
/** True when a commit touched an anchored file and the memory hasn't been re-confirmed. */
|
|
75
|
+
stale?: boolean;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export interface LearnInput {
|
|
@@ -83,6 +88,8 @@ export interface LearnInput {
|
|
|
83
88
|
agent_id?: string;
|
|
84
89
|
tags?: string[];
|
|
85
90
|
relate_to?: Array<{ id: number; relationship_type: RelationshipType }>;
|
|
91
|
+
/** Repo-relative file paths this memory references — anchors for code-change invalidation. */
|
|
92
|
+
files?: string[];
|
|
86
93
|
/** Skip regenerating docs/memory-long-term.md (use during bulk imports) */
|
|
87
94
|
skipExport?: boolean;
|
|
88
95
|
/** Audit/provenance — all optional; safe to omit from existing callers. */
|
|
@@ -145,6 +152,16 @@ function attachTags(db: Database, memoryId: number, tags: string[]): void {
|
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
|
|
155
|
+
/** Anchor a memory to the repo files it references (merge-safe). */
|
|
156
|
+
function attachFiles(db: Database, memoryId: number, files: string[], projectScope: string | null): void {
|
|
157
|
+
for (const path of normalizeAnchorPaths(files)) {
|
|
158
|
+
db.run(
|
|
159
|
+
`INSERT OR IGNORE INTO memory_files (memory_id, path, project_scope) VALUES (?, ?, ?)`,
|
|
160
|
+
[memoryId, path, projectScope],
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
148
165
|
/** Fetch tags for a single memory — used in recall() results. */
|
|
149
166
|
function getTagsForMemory(db: Database, memoryId: number): string[] {
|
|
150
167
|
return db.query<{ name: string }, [number]>(
|
|
@@ -192,7 +209,7 @@ function getRelationsForMemory(db: Database, memoryId: number): MemoryWithRelati
|
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
function enrichMemory(db: Database, mem: Memory): MemoryWithRelations {
|
|
195
|
-
return { ...mem, tags: getTagsForMemory(db, mem.id), relations: getRelationsForMemory(db, mem.id) };
|
|
212
|
+
return { ...mem, stale: !!mem.stale_flagged_at, tags: getTagsForMemory(db, mem.id), relations: getRelationsForMemory(db, mem.id) };
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
// --- Decay / relevance scoring ---
|
|
@@ -255,8 +272,14 @@ export function decayMemories(): DecayResult {
|
|
|
255
272
|
).all();
|
|
256
273
|
|
|
257
274
|
const toDeprecate = rows
|
|
258
|
-
.filter(mem => mem.importance !== 5
|
|
259
|
-
.filter(mem =>
|
|
275
|
+
.filter(mem => mem.importance !== 5)
|
|
276
|
+
.filter(mem =>
|
|
277
|
+
// Code-invalidated memories are decay-eligible regardless of recall
|
|
278
|
+
// frequency — this is the "high-traffic but stale" case decay can't
|
|
279
|
+
// otherwise see. Otherwise fall back to the recency/confirm guard.
|
|
280
|
+
mem.stale_flagged_at != null ||
|
|
281
|
+
(mem.confirm_count < 5 && computeDecayScore(mem) < DEPRECATION_THRESHOLD)
|
|
282
|
+
)
|
|
260
283
|
.map(mem => mem.id);
|
|
261
284
|
|
|
262
285
|
if (toDeprecate.length > 0) {
|
|
@@ -270,6 +293,92 @@ export function decayMemories(): DecayResult {
|
|
|
270
293
|
return { deprecated: toDeprecate.length, scored: rows.length };
|
|
271
294
|
}
|
|
272
295
|
|
|
296
|
+
export interface FlagStaleResult {
|
|
297
|
+
flagged: number;
|
|
298
|
+
ids: number[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Flag active memories anchored to any of `paths` as stale — the code they
|
|
303
|
+
* reference changed. Never deletes (audit trail preserved) and never touches
|
|
304
|
+
* importance=5 (permanent). Matches anchors in the same project scope or global
|
|
305
|
+
* (NULL-scoped) anchors. Idempotent: re-flagging refreshes stale_flagged_at.
|
|
306
|
+
*/
|
|
307
|
+
export function flagStaleByPaths(
|
|
308
|
+
paths: string[],
|
|
309
|
+
opts: { project_scope?: string | null; reason?: string; actor?: string; sessionId?: string } = {},
|
|
310
|
+
): FlagStaleResult {
|
|
311
|
+
const db = getDb();
|
|
312
|
+
const norm = normalizeAnchorPaths(paths);
|
|
313
|
+
if (norm.length === 0) return { flagged: 0, ids: [] };
|
|
314
|
+
|
|
315
|
+
const scope = opts.project_scope ?? null;
|
|
316
|
+
const placeholders = norm.map(() => "?").join(",");
|
|
317
|
+
const candidates = db
|
|
318
|
+
.query<{ id: number }, (string | null)[]>(
|
|
319
|
+
`SELECT DISTINCT m.id
|
|
320
|
+
FROM memories m
|
|
321
|
+
JOIN memory_files mf ON mf.memory_id = m.id
|
|
322
|
+
WHERE m.status = 'active'
|
|
323
|
+
AND m.importance <> 5
|
|
324
|
+
AND mf.path IN (${placeholders})
|
|
325
|
+
AND (mf.project_scope IS ? OR mf.project_scope IS NULL)`,
|
|
326
|
+
)
|
|
327
|
+
.all(...norm, scope)
|
|
328
|
+
.map((r) => r.id);
|
|
329
|
+
|
|
330
|
+
const reason = opts.reason ?? "code change";
|
|
331
|
+
const actor = opts.actor ?? "git-commit";
|
|
332
|
+
|
|
333
|
+
for (const id of candidates) {
|
|
334
|
+
const beforeSnap = snapshotMemory(db, id);
|
|
335
|
+
db.run(
|
|
336
|
+
`UPDATE memories SET stale_flagged_at = datetime('now'), stale_reason = ? WHERE id = ?`,
|
|
337
|
+
[reason, id],
|
|
338
|
+
);
|
|
339
|
+
tryAudit(() => {
|
|
340
|
+
const afterSnap = snapshotMemory(db, id);
|
|
341
|
+
insertAudit(db, {
|
|
342
|
+
memory_id: id,
|
|
343
|
+
op: "update",
|
|
344
|
+
actor,
|
|
345
|
+
session_id: opts.sessionId,
|
|
346
|
+
before_json: beforeSnap ? JSON.stringify(beforeSnap) : null,
|
|
347
|
+
after_json: afterSnap ? JSON.stringify(afterSnap) : null,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { flagged: candidates.length, ids: candidates };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Clear a stale flag — the memory was reviewed and is still valid. Use forget()
|
|
357
|
+
* instead when the memory is actually wrong. No-op if the memory wasn't flagged.
|
|
358
|
+
*/
|
|
359
|
+
export function revalidate(id: number): { revalidated: boolean } {
|
|
360
|
+
const db = getDb();
|
|
361
|
+
const before = snapshotMemory(db, id);
|
|
362
|
+
const res = db.run(
|
|
363
|
+
`UPDATE memories SET stale_flagged_at = NULL, stale_reason = NULL
|
|
364
|
+
WHERE id = ? AND stale_flagged_at IS NOT NULL`,
|
|
365
|
+
[id],
|
|
366
|
+
);
|
|
367
|
+
const revalidated = Number(res.changes ?? 0) > 0;
|
|
368
|
+
if (revalidated) {
|
|
369
|
+
tryAudit(() => {
|
|
370
|
+
insertAudit(db, {
|
|
371
|
+
memory_id: id,
|
|
372
|
+
op: "update",
|
|
373
|
+
actor: "revalidate",
|
|
374
|
+
before_json: before ? JSON.stringify(before) : null,
|
|
375
|
+
after_json: JSON.stringify(snapshotMemory(db, id)),
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return { revalidated };
|
|
380
|
+
}
|
|
381
|
+
|
|
273
382
|
// Auto-relation detection — called fire-and-forget from learn()
|
|
274
383
|
async function autoDetectRelations(
|
|
275
384
|
newId: number,
|
|
@@ -322,10 +431,12 @@ export function learn(input: LearnInput): LearnResult {
|
|
|
322
431
|
const beforeSnap = snapshotMemory(db, existing.id);
|
|
323
432
|
db.run(
|
|
324
433
|
`UPDATE memories SET confirm_count=confirm_count+1, last_confirmed_at=datetime('now'),
|
|
325
|
-
confidence=MIN(1.0, confidence+0.05)
|
|
434
|
+
confidence=MIN(1.0, confidence+0.05),
|
|
435
|
+
stale_flagged_at=NULL, stale_reason=NULL WHERE id=?`,
|
|
326
436
|
[existing.id]
|
|
327
437
|
);
|
|
328
438
|
if (input.tags) attachTags(db, existing.id, input.tags);
|
|
439
|
+
if (input.files) attachFiles(db, existing.id, input.files, existing.project_scope ?? input.project_scope ?? null);
|
|
329
440
|
if (input.relate_to) {
|
|
330
441
|
for (const rel of input.relate_to) {
|
|
331
442
|
relate({ source_id: existing.id, target_id: rel.id, relationship_type: rel.relationship_type });
|
|
@@ -368,6 +479,7 @@ export function learn(input: LearnInput): LearnResult {
|
|
|
368
479
|
const newId = Number(result.lastInsertRowid);
|
|
369
480
|
|
|
370
481
|
if (input.tags) attachTags(db, newId, input.tags);
|
|
482
|
+
if (input.files) attachFiles(db, newId, input.files, input.project_scope ?? null);
|
|
371
483
|
if (input.relate_to) {
|
|
372
484
|
for (const rel of input.relate_to) {
|
|
373
485
|
relate({ source_id: newId, target_id: rel.id, relationship_type: rel.relationship_type });
|
|
@@ -528,7 +640,7 @@ export async function recall(input: RecallInput = {}): Promise<MemoryWithRelatio
|
|
|
528
640
|
`SELECT id, content, category, importance, confidence, source, project_scope, dedup_key,
|
|
529
641
|
created_at, last_confirmed_at, last_used_at, confirm_count, status,
|
|
530
642
|
first_recalled_at, last_recalled_at, recall_count, superseded_by, superseded_at,
|
|
531
|
-
workspace_id, agent_id, decay_score
|
|
643
|
+
workspace_id, agent_id, decay_score, stale_flagged_at, stale_reason
|
|
532
644
|
FROM memories ${where} ${orderBy} LIMIT ${limit}`
|
|
533
645
|
).all(...params);
|
|
534
646
|
|
|
@@ -549,6 +661,12 @@ export async function recall(input: RecallInput = {}): Promise<MemoryWithRelatio
|
|
|
549
661
|
.sort((a, b) => b.score - a.score)
|
|
550
662
|
.map(({ m }) => m);
|
|
551
663
|
}
|
|
664
|
+
// Downrank stale (code-invalidated) memories: stable partition pushes them
|
|
665
|
+
// after fresh ones at equal relevance — still returned, just demoted.
|
|
666
|
+
sorted = [
|
|
667
|
+
...sorted.filter(m => !m.stale_flagged_at),
|
|
668
|
+
...sorted.filter(m => m.stale_flagged_at),
|
|
669
|
+
];
|
|
552
670
|
if (sorted.length > 0) {
|
|
553
671
|
const placeholders = sorted.map(() => "?").join(",");
|
|
554
672
|
db.run(
|
package/src/index.ts
CHANGED
|
@@ -10,11 +10,11 @@ export { configureCore, configureDocs } from "./db.js";
|
|
|
10
10
|
export {
|
|
11
11
|
learn, recall, forget, relate, getSimilarMemories,
|
|
12
12
|
getContextMerge, getContextMergeWithGraph, computeDecayScore,
|
|
13
|
-
exportMarkdown, exportGraphJson,
|
|
13
|
+
exportMarkdown, exportGraphJson, flagStaleByPaths, revalidate,
|
|
14
14
|
} from "./db.js";
|
|
15
15
|
export type {
|
|
16
16
|
Memory, MemoryWithRelations, MemoryCategory, RelationshipType, MemoryRelation,
|
|
17
|
-
LearnInput, LearnResult, RecallInput, DecayResult,
|
|
17
|
+
LearnInput, LearnResult, RecallInput, DecayResult, FlagStaleResult,
|
|
18
18
|
} from "./db.js";
|
|
19
19
|
|
|
20
20
|
// Context items
|
|
@@ -35,6 +35,7 @@ export { listByProject, upsertGoal, appendProgress, addDecision, addGotcha } fro
|
|
|
35
35
|
// Utilities
|
|
36
36
|
export { scrubSecrets } from "./secretsScrubber.js";
|
|
37
37
|
export { normalizeKey } from "./dedup.js";
|
|
38
|
+
export { normalizeAnchorPath, normalizeAnchorPaths } from "./anchors.js";
|
|
38
39
|
export { embedText, getLlmConfig, callLlm } from "./embeddings.js";
|
|
39
40
|
|
|
40
41
|
// Recall utilities
|