@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/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 (stub — full implementation deferred)
70
+ // Sub-command: mcp-serve — run the LTM MCP server on stdio
62
71
  if (argv[0] === "mcp-serve") {
63
- process.stderr.write(
64
- " ltm mcp-serve: not yet implemented — install the Claude Code plugin for MCP server support\n",
65
- );
66
- process.exit(0);
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 && mem.confirm_count < 5)
259
- .filter(mem => computeDecayScore(mem) < DEPRECATION_THRESHOLD)
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) WHERE id=?`,
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