@rohirik/openltm-core 2.9.1 → 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 +4 -2
- package/src/__tests__/codeAnchors.test.ts +246 -0
- package/src/__tests__/migrations/023_memory_files.test.ts +83 -0
- package/src/anchors.ts +39 -0
- package/src/db.ts +123 -5
- package/src/index.ts +3 -2
- package/src/schema.sql +18 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohirik/openltm-core",
|
|
3
|
-
"version": "2.
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|
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
|
|
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
|
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
|