@rohirik/openltm-core 2.9.0 → 2.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohirik/openltm-core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Shared LTM storage engine — path-agnostic SQLite core used by Claude Code, OpenCode, and Pi adapters",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -9,7 +9,9 @@
9
9
  "./cli": "./src/cli/index.ts"
10
10
  },
11
11
  "bin": {
12
- "ltm": "src/cli/bin.ts"
12
+ "ltm": "src/cli/bin.ts",
13
+ "openltm": "src/cli/bin.ts",
14
+ "openltm-core": "src/cli/bin.ts"
13
15
  },
14
16
  "repository": {
15
17
  "type": "git",
@@ -0,0 +1,246 @@
1
+ /**
2
+ * codeAnchors.test.ts — Phase 2: path normaliser + anchor-on-learn.
3
+ */
4
+ import { describe, it, expect, beforeEach } from "bun:test";
5
+ import { Database } from "bun:sqlite";
6
+ import { readFileSync } from "fs";
7
+ import { join } from "path";
8
+ import { normalizeAnchorPath, normalizeAnchorPaths } from "../anchors.js";
9
+ import { _setDbForTesting } from "../shared-db.js";
10
+ import { learn, flagStaleByPaths, revalidate, recall, decayMemories } from "../db.js";
11
+ import { getMigrationFiles, parseMigration } from "../migrations.js";
12
+
13
+ // Full schema = schema.sql baseline + every migration `up` (later columns like
14
+ // created_by come from migrations). Apply ups directly to skip runPendingMigrations'
15
+ // real-DB backup/retention side-effects; tolerate dup-column on already-in-schema.sql.
16
+ async function freshDb(): Promise<Database> {
17
+ const db = new Database(":memory:");
18
+ db.exec("PRAGMA foreign_keys=ON;");
19
+ db.exec(readFileSync(join(import.meta.dir, "..", "schema.sql"), "utf8"));
20
+ for (const f of await getMigrationFiles()) {
21
+ const { up } = parseMigration(f.content);
22
+ if (!up) continue;
23
+ try {
24
+ db.exec(up);
25
+ } catch (e) {
26
+ if (!/duplicate column|already exists/i.test(String(e))) throw e;
27
+ }
28
+ }
29
+ return db;
30
+ }
31
+
32
+ function anchorPaths(db: Database, memoryId: number): string[] {
33
+ return db
34
+ .query<{ path: string }, [number]>(
35
+ "SELECT path FROM memory_files WHERE memory_id=? ORDER BY path",
36
+ )
37
+ .all(memoryId)
38
+ .map((r) => r.path);
39
+ }
40
+
41
+ describe("normalizeAnchorPath (AC7)", () => {
42
+ it("strips ./ and leading slashes, normalises backslashes", () => {
43
+ expect(normalizeAnchorPath("./src/a.ts")).toBe("src/a.ts");
44
+ expect(normalizeAnchorPath("src\\b.ts")).toBe("src/b.ts");
45
+ expect(normalizeAnchorPath("/src/c.ts")).toBe("src/c.ts");
46
+ expect(normalizeAnchorPath(" src/d.ts ")).toBe("src/d.ts");
47
+ });
48
+
49
+ it("makes absolute paths under repoRoot relative", () => {
50
+ expect(normalizeAnchorPath("/repo/root/src/e.ts", "/repo/root")).toBe("src/e.ts");
51
+ expect(normalizeAnchorPath("/repo/root/", "/repo/root")).toBe("");
52
+ });
53
+
54
+ it("de-duplicates and drops empties", () => {
55
+ expect(normalizeAnchorPaths(["./a.ts", "a.ts", "", " ", "b.ts"])).toEqual(["a.ts", "b.ts"]);
56
+ });
57
+ });
58
+
59
+ describe("anchor-on-learn", () => {
60
+ let db: Database;
61
+ beforeEach(async () => {
62
+ db = await freshDb();
63
+ _setDbForTesting(db);
64
+ });
65
+
66
+ it("stores normalised anchors scoped to the project (AC5, AC7)", () => {
67
+ const r = learn({
68
+ content: "auth uses JWT verified in middleware layer",
69
+ category: "architecture",
70
+ project_scope: "proj",
71
+ files: ["./src/auth.ts", "src\\auth.ts", "src/jwt.ts"],
72
+ skipExport: true,
73
+ });
74
+ expect(anchorPaths(db, r.id)).toEqual(["src/auth.ts", "src/jwt.ts"]);
75
+ const scope = db
76
+ .query<{ project_scope: string }, [number]>(
77
+ "SELECT project_scope FROM memory_files WHERE memory_id=? LIMIT 1",
78
+ )
79
+ .get(r.id);
80
+ expect(scope?.project_scope).toBe("proj");
81
+ });
82
+
83
+ it("omitting files writes no anchors (AC6)", () => {
84
+ const r = learn({ content: "a memory with no file anchors at all", category: "pattern", skipExport: true });
85
+ expect(anchorPaths(db, r.id)).toEqual([]);
86
+ });
87
+
88
+ it("reinforce merges new anchors without duplicates (AC8)", () => {
89
+ const a = learn({
90
+ content: "shared memory body for reinforcement",
91
+ category: "gotcha",
92
+ project_scope: "p",
93
+ files: ["src/a.ts"],
94
+ skipExport: true,
95
+ });
96
+ const b = learn({
97
+ content: "shared memory body for reinforcement",
98
+ category: "gotcha",
99
+ project_scope: "p",
100
+ files: ["src/a.ts", "src/b.ts"],
101
+ skipExport: true,
102
+ });
103
+ expect(b.action).toBe("reinforced");
104
+ expect(b.id).toBe(a.id);
105
+ expect(anchorPaths(db, a.id)).toEqual(["src/a.ts", "src/b.ts"]);
106
+ });
107
+ });
108
+
109
+ describe("flagStaleByPaths (invalidate-on-commit)", () => {
110
+ let db: Database;
111
+ beforeEach(async () => {
112
+ db = await freshDb();
113
+ _setDbForTesting(db);
114
+ });
115
+
116
+ function staleAt(id: number): string | null {
117
+ return (
118
+ db
119
+ .query<{ stale_flagged_at: string | null }, [number]>(
120
+ "SELECT stale_flagged_at FROM memories WHERE id=?",
121
+ )
122
+ .get(id)?.stale_flagged_at ?? null
123
+ );
124
+ }
125
+
126
+ it("flags a memory anchored to a changed path + records reason and audit (AC10)", () => {
127
+ const m = learn({
128
+ content: "auth verified via jwt in the middleware layer",
129
+ category: "architecture",
130
+ project_scope: "p",
131
+ files: ["src/auth.ts"],
132
+ skipExport: true,
133
+ });
134
+ const res = flagStaleByPaths(["src/auth.ts"], { project_scope: "p", reason: "commit abc" });
135
+ expect(res.flagged).toBe(1);
136
+ expect(res.ids).toContain(m.id);
137
+ expect(staleAt(m.id)).not.toBeNull();
138
+ const row = db
139
+ .query<{ stale_reason: string }, [number]>("SELECT stale_reason FROM memories WHERE id=?")
140
+ .get(m.id);
141
+ expect(row?.stale_reason).toBe("commit abc");
142
+ const audit = db
143
+ .query<{ n: number }, [number]>(
144
+ "SELECT count(*) n FROM memory_audit WHERE memory_id=? AND op='update'",
145
+ )
146
+ .get(m.id);
147
+ expect(audit?.n ?? 0).toBeGreaterThanOrEqual(1);
148
+ });
149
+
150
+ it("never flags importance=5 memories (AC12)", () => {
151
+ const m = learn({
152
+ content: "permanent architectural rule never decays",
153
+ category: "architecture",
154
+ importance: 5,
155
+ project_scope: "p",
156
+ files: ["src/auth.ts"],
157
+ skipExport: true,
158
+ });
159
+ const res = flagStaleByPaths(["src/auth.ts"], { project_scope: "p" });
160
+ expect(res.flagged).toBe(0);
161
+ expect(staleAt(m.id)).toBeNull();
162
+ });
163
+
164
+ it("returns flagged:0 when no memory is anchored to the path (AC14)", () => {
165
+ learn({ content: "anchored to a different file entirely", category: "pattern", project_scope: "p", files: ["src/other.ts"], skipExport: true });
166
+ expect(flagStaleByPaths(["src/unrelated.ts"], { project_scope: "p" }).flagged).toBe(0);
167
+ });
168
+
169
+ it("normalises commit paths (./ , backslash) before matching", () => {
170
+ const m = learn({ content: "path normalisation parity check body", category: "gotcha", project_scope: "p", files: ["src/n.ts"], skipExport: true });
171
+ expect(flagStaleByPaths(["./src/n.ts"], { project_scope: "p" }).ids).toContain(m.id);
172
+ });
173
+
174
+ it("flags global (null-scope) anchors regardless of commit scope", () => {
175
+ const m = learn({ content: "global anchor not scoped to a project", category: "pattern", files: ["src/g.ts"], skipExport: true });
176
+ expect(flagStaleByPaths(["src/g.ts"], { project_scope: "someproj" }).ids).toContain(m.id);
177
+ });
178
+ });
179
+
180
+ describe("stale-aware recall + decay + revalidate", () => {
181
+ let db: Database;
182
+ beforeEach(async () => {
183
+ db = await freshDb();
184
+ _setDbForTesting(db);
185
+ });
186
+
187
+ function staleAt(id: number): string | null {
188
+ return (
189
+ db
190
+ .query<{ stale_flagged_at: string | null }, [number]>(
191
+ "SELECT stale_flagged_at FROM memories WHERE id=?",
192
+ )
193
+ .get(id)?.stale_flagged_at ?? null
194
+ );
195
+ }
196
+ function statusOf(id: number): string {
197
+ return (
198
+ db.query<{ status: string }, [number]>("SELECT status FROM memories WHERE id=?").get(id)
199
+ ?.status ?? ""
200
+ );
201
+ }
202
+
203
+ it("recall marks stale + downranks but still returns it (AC15)", async () => {
204
+ const fresh = learn({ content: "alpha keyword fresh memory body", category: "pattern", project_scope: "p", files: ["src/x.ts"], skipExport: true });
205
+ const stale = learn({ content: "alpha keyword stale memory body", category: "pattern", project_scope: "p", files: ["src/y.ts"], skipExport: true });
206
+ flagStaleByPaths(["src/y.ts"], { project_scope: "p" });
207
+
208
+ const res = await recall({ query: "alpha keyword", limit: 10 });
209
+ const ids = res.map((r) => r.id);
210
+ expect(ids).toContain(fresh.id);
211
+ expect(ids).toContain(stale.id);
212
+ expect(res.find((r) => r.id === stale.id)?.stale).toBe(true);
213
+ expect(res.find((r) => r.id === fresh.id)?.stale).toBe(false);
214
+ expect(ids.indexOf(fresh.id)).toBeLessThan(ids.indexOf(stale.id));
215
+ });
216
+
217
+ it("decay deprecates a flagged memory but never importance:5 or unflagged-fresh (AC16)", () => {
218
+ const flagged = learn({ content: "beta flagged should decay now", category: "pattern", project_scope: "p", files: ["src/a.ts"], skipExport: true });
219
+ const permanent = learn({ content: "beta permanent rule stays", category: "architecture", importance: 5, project_scope: "p", files: ["src/a.ts"], skipExport: true });
220
+ const freshUnflagged = learn({ content: "beta fresh untouched stays", category: "pattern", project_scope: "p", files: ["src/b.ts"], skipExport: true });
221
+
222
+ flagStaleByPaths(["src/a.ts"], { project_scope: "p" }); // flags `flagged`; skips imp5 `permanent`
223
+ decayMemories();
224
+
225
+ expect(statusOf(flagged.id)).toBe("deprecated");
226
+ expect(statusOf(permanent.id)).toBe("active");
227
+ expect(statusOf(freshUnflagged.id)).toBe("active");
228
+ });
229
+
230
+ it("reinforce (re-learn same content) clears the stale flag (AC17)", () => {
231
+ const m = learn({ content: "gamma reconfirm body text", category: "gotcha", project_scope: "p", files: ["src/a.ts"], skipExport: true });
232
+ flagStaleByPaths(["src/a.ts"], { project_scope: "p" });
233
+ expect(staleAt(m.id)).not.toBeNull();
234
+ const again = learn({ content: "gamma reconfirm body text", category: "gotcha", project_scope: "p", skipExport: true });
235
+ expect(again.id).toBe(m.id);
236
+ expect(staleAt(m.id)).toBeNull();
237
+ });
238
+
239
+ it("revalidate clears the flag and is a no-op when not flagged (AC18)", () => {
240
+ const m = learn({ content: "delta revalidate body text", category: "pattern", project_scope: "p", files: ["src/a.ts"], skipExport: true });
241
+ flagStaleByPaths(["src/a.ts"], { project_scope: "p" });
242
+ expect(revalidate(m.id).revalidated).toBe(true);
243
+ expect(staleAt(m.id)).toBeNull();
244
+ expect(revalidate(m.id).revalidated).toBe(false);
245
+ });
246
+ });
@@ -14,6 +14,7 @@ import {
14
14
  MEMORY_ADDED,
15
15
  } from "../../events/index.js";
16
16
  import { _resetHonkerForTesting } from "../../lib/honker.js";
17
+ import { resetCapabilitiesForTesting } from "../../extensions.js";
17
18
 
18
19
  function seedDb(): Database {
19
20
  const db = new Database(":memory:");
@@ -25,6 +26,9 @@ describe("cross-process sync — flag + dormant contract (no honker)", () => {
25
26
  beforeEach(() => {
26
27
  _resetHonkerForTesting();
27
28
  delete process.env["LTM_HONKER_EXT"];
29
+ // Reset cached caps so a prior test's honker=true (vendored binary is
30
+ // discoverable here) does not leak in — keeps this the dormant contract.
31
+ resetCapabilitiesForTesting();
28
32
  });
29
33
  afterEach(() => {
30
34
  _resetHonkerForTesting();
@@ -7,11 +7,15 @@
7
7
  import { describe, it, expect, beforeEach } from "bun:test";
8
8
  import { notifyLtm, startLtmListener, LTM_CHANNEL } from "../../events/index.js";
9
9
  import { _resetHonkerForTesting } from "../../lib/honker.js";
10
+ import { resetCapabilitiesForTesting } from "../../extensions.js";
10
11
 
11
12
  describe("events — dormant path (no honker)", () => {
12
13
  beforeEach(() => {
13
14
  _resetHonkerForTesting();
14
15
  delete process.env["LTM_HONKER_EXT"];
16
+ // Reset cached caps so a prior test's honker=true (vendored binary is
17
+ // discoverable here) does not leak in — keeps this the dormant contract.
18
+ resetCapabilitiesForTesting();
15
19
  });
16
20
 
17
21
  it("exposes the stable channel name", () => {
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { getMigrationFiles, parseMigration } from "../../migrations.js";
4
+
5
+ async function migration023() {
6
+ const files = await getMigrationFiles();
7
+ const f = files.find((m) => m.version === 23 && m.name === "memory_files");
8
+ if (!f) throw new Error("migration 023_memory_files.sql not found");
9
+ return parseMigration(f.content);
10
+ }
11
+
12
+ // Minimal pre-021 memories table (the upgrade path only needs the FK target).
13
+ function baseMemories(db: Database) {
14
+ db.exec(
15
+ "CREATE TABLE memories (id INTEGER PRIMARY KEY AUTOINCREMENT, importance INTEGER NOT NULL DEFAULT 3);",
16
+ );
17
+ }
18
+
19
+ function colNames(db: Database, table: string): string[] {
20
+ return db
21
+ .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
22
+ .all()
23
+ .map((c) => c.name);
24
+ }
25
+
26
+ describe("migration 021 — memory_files + staleness columns", () => {
27
+ it("UP creates memory_files and adds stale columns (AC1, AC2)", async () => {
28
+ const db = new Database(":memory:");
29
+ db.exec("PRAGMA foreign_keys=ON;");
30
+ baseMemories(db);
31
+
32
+ const { up } = await migration023();
33
+ expect(up).toBeTruthy();
34
+ db.exec(up!);
35
+
36
+ const tbl = db
37
+ .query<{ name: string }, []>(
38
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_files'",
39
+ )
40
+ .get();
41
+ expect(tbl?.name).toBe("memory_files");
42
+
43
+ const cols = colNames(db, "memories");
44
+ expect(cols).toContain("stale_flagged_at");
45
+ expect(cols).toContain("stale_reason");
46
+
47
+ // Anchor row inserts and ON DELETE CASCADE wipes it with the parent.
48
+ db.exec("INSERT INTO memories (id, importance) VALUES (1, 3);");
49
+ db.exec("INSERT INTO memory_files (memory_id, path, project_scope) VALUES (1, 'src/a.ts', 'demo');");
50
+ expect(
51
+ db.query<{ n: number }, []>("SELECT count(*) n FROM memory_files").get()?.n,
52
+ ).toBe(1);
53
+ db.exec("DELETE FROM memories WHERE id=1;");
54
+ expect(
55
+ db.query<{ n: number }, []>("SELECT count(*) n FROM memory_files").get()?.n,
56
+ ).toBe(0);
57
+ });
58
+
59
+ it("DOWN reverses cleanly and re-UP works (AC3 idempotent roundtrip)", async () => {
60
+ const db = new Database(":memory:");
61
+ baseMemories(db);
62
+ const { up, down } = await migration023();
63
+ expect(down).toBeTruthy();
64
+
65
+ db.exec(up!);
66
+ db.exec(down!);
67
+
68
+ expect(
69
+ db
70
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name='memory_files'")
71
+ .get(),
72
+ ).toBeNull();
73
+ expect(colNames(db, "memories")).not.toContain("stale_flagged_at");
74
+
75
+ // Re-applying after a clean DOWN must succeed.
76
+ db.exec(up!);
77
+ expect(
78
+ db
79
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name='memory_files'")
80
+ .get(),
81
+ ).toBeTruthy();
82
+ });
83
+ });
@@ -15,10 +15,15 @@ import {
15
15
  } from "../../queue/index.js";
16
16
  import { startEmbeddingWorker } from "../../queue/worker.js";
17
17
  import { isHonkerAvailable, _resetHonkerForTesting } from "../../lib/honker.js";
18
+ import { resetCapabilitiesForTesting } from "../../extensions.js";
18
19
 
19
20
  describe("queue — dormant path (no honker)", () => {
20
21
  beforeEach(() => {
21
22
  _resetHonkerForTesting();
23
+ // Force the dormant contract: a prior test may have cached caps.honker=true
24
+ // process-wide (the vendored binary is discoverable here). Reset so
25
+ // getCapabilities() returns the all-false default and honker stays dormant.
26
+ resetCapabilitiesForTesting();
22
27
  });
23
28
 
24
29
  it("EMBED_QUEUE has a stable name", () => {
@@ -13,11 +13,15 @@ import {
13
13
  DEFAULT_JANITOR_CRON,
14
14
  } from "../../scheduler/index.js";
15
15
  import { _resetHonkerForTesting } from "../../lib/honker.js";
16
+ import { resetCapabilitiesForTesting } from "../../extensions.js";
16
17
 
17
18
  describe("scheduler — dormant path (no honker)", () => {
18
19
  beforeEach(() => {
19
20
  _resetHonkerForTesting();
20
21
  delete process.env["LTM_HONKER_EXT"];
22
+ // Reset cached caps so a prior test's honker=true (vendored binary is
23
+ // discoverable here) does not leak in — keeps this the dormant contract.
24
+ resetCapabilitiesForTesting();
21
25
  });
22
26
 
23
27
  it("exposes stable names + default cron", () => {
package/src/anchors.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * anchors.ts — file-path normalisation for code-anchored memory invalidation.
3
+ *
4
+ * Both sides of the feature MUST normalise identically or flagging silently misses:
5
+ * - learn() stores anchors a memory references (agent may pass absolute paths)
6
+ * - the post-commit hook flags memories anchored to files `git diff-tree
7
+ * --name-only` reports (always repo-relative, forward-slash, no leading ./)
8
+ *
9
+ * This module is the single source of truth for that normalisation. Output mirrors
10
+ * git's repo-relative path form so anchors and changed-file lists compare exactly.
11
+ */
12
+
13
+ /**
14
+ * Normalise one path to the repo-relative, forward-slash form git emits.
15
+ * When `repoRoot` is given, an absolute path under it is made relative.
16
+ */
17
+ export function normalizeAnchorPath(path: string, repoRoot?: string): string {
18
+ let s = path.trim().replace(/\\/g, "/");
19
+ if (!s) return "";
20
+ if (repoRoot) {
21
+ const root = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
22
+ if (root && (s === root || s.startsWith(`${root}/`))) {
23
+ s = s.slice(root.length + 1);
24
+ }
25
+ }
26
+ // Strip leading "./" and any leading slashes — git paths are repo-relative.
27
+ s = s.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
28
+ return s;
29
+ }
30
+
31
+ /** Normalise + de-duplicate a list of anchor paths, dropping empties. */
32
+ export function normalizeAnchorPaths(paths: string[], repoRoot?: string): string[] {
33
+ const seen = new Set<string>();
34
+ for (const p of paths) {
35
+ const n = normalizeAnchorPath(p, repoRoot);
36
+ if (n) seen.add(n);
37
+ }
38
+ return [...seen];
39
+ }
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/extensions.ts CHANGED
@@ -105,8 +105,16 @@ export function ensureCustomSqlite(): boolean {
105
105
  const lib = locateSystemSqlite();
106
106
  if (!lib) return false;
107
107
  try {
108
- (Database as unknown as { setCustomSQLite: (p: string) => void }).setCustomSQLite(lib);
108
+ const DB = Database as unknown as { setCustomSQLite: (p: string) => void };
109
+ DB.setCustomSQLite(lib);
109
110
  _customSqliteApplied = true;
111
+ // setCustomSQLite is a once-per-process global (Bun throws "SQLite already
112
+ // loaded" on a second call). honker-bun's open() calls it again on its own
113
+ // connection and would throw — silently killing the honker handle even
114
+ // though the extension-enabled SQLite is already active process-wide. Now
115
+ // that we've loaded it, neutralise further calls so honker-bun (and any
116
+ // other consumer) no-ops instead of throwing.
117
+ DB.setCustomSQLite = () => {};
110
118
  return true;
111
119
  } catch {
112
120
  return false;
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
package/src/schema.sql CHANGED
@@ -63,7 +63,10 @@ CREATE TABLE IF NOT EXISTS memories (
63
63
  user_note TEXT,
64
64
  -- Personal relevance signal: 'works' | 'doesnt' | NULL (unrated)
65
65
  relevance_signal TEXT,
66
- relevance_signal_at TEXT
66
+ relevance_signal_at TEXT,
67
+ -- Code-anchored invalidation (migration 023). NULL = not flagged stale.
68
+ stale_flagged_at TEXT,
69
+ stale_reason TEXT
67
70
  );
68
71
 
69
72
  CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
@@ -74,6 +77,20 @@ CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
74
77
  CREATE INDEX IF NOT EXISTS idx_memories_last_used ON memories(last_used_at);
75
78
  CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by);
76
79
  CREATE INDEX IF NOT EXISTS idx_memories_recall_count ON memories(recall_count DESC);
80
+ CREATE INDEX IF NOT EXISTS idx_memories_stale ON memories(stale_flagged_at);
81
+
82
+ -- ============================================================
83
+ -- memory_files: code anchors — files a memory references (migration 023)
84
+ -- A commit touching one of these paths can flag the memory stale.
85
+ -- ============================================================
86
+ CREATE TABLE IF NOT EXISTS memory_files (
87
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
88
+ path TEXT NOT NULL,
89
+ project_scope TEXT,
90
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
91
+ PRIMARY KEY (memory_id, path)
92
+ );
93
+ CREATE INDEX IF NOT EXISTS idx_memory_files_path ON memory_files(path);
77
94
 
78
95
  -- ============================================================
79
96
  -- tags + memory_tags: many-to-many tagging for memories