@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/package.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohirik/openltm-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
|
-
"./cli": "./src/cli/index.ts"
|
|
9
|
+
"./cli": "./src/cli/index.ts",
|
|
10
|
+
"./mcp": "./src/mcp/server.ts"
|
|
10
11
|
},
|
|
11
12
|
"bin": {
|
|
12
|
-
"ltm": "src/cli/bin.ts"
|
|
13
|
+
"ltm": "src/cli/bin.ts",
|
|
14
|
+
"openltm": "src/cli/bin.ts",
|
|
15
|
+
"openltm-core": "src/cli/bin.ts"
|
|
13
16
|
},
|
|
14
17
|
"repository": {
|
|
15
18
|
"type": "git",
|
|
@@ -29,8 +32,10 @@
|
|
|
29
32
|
"dependencies": {
|
|
30
33
|
"@clack/prompts": "^1.3.0",
|
|
31
34
|
"@iarna/toml": "^2.2.5",
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
32
36
|
"bun-types": "^1.0.0",
|
|
33
|
-
"sqlite-vec": "0.1.9"
|
|
37
|
+
"sqlite-vec": "0.1.9",
|
|
38
|
+
"zod": "^4.3.6"
|
|
34
39
|
},
|
|
35
40
|
"optionalDependencies": {
|
|
36
41
|
"@russellthehippo/honker-bun": "^0.2.2"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.test.ts — unit tests for packages/openltm-core/src/cli/memory.ts
|
|
3
|
+
*
|
|
4
|
+
* RED phase: written before implementation. Defines the contract for the
|
|
5
|
+
* `ltm memory <learn|recall|forget|relate|context>` CLI surface that gives
|
|
6
|
+
* headless/CLI agents a direct path to LTM without the agent TUI.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
|
|
14
|
+
// ── Arg parsing (pure) ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("cli/memory — parseMemoryArgs", () => {
|
|
17
|
+
it("parses learn with all flags", async () => {
|
|
18
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
19
|
+
const parsed = parseMemoryArgs([
|
|
20
|
+
"learn",
|
|
21
|
+
"--text", "Use WAL mode for SQLite",
|
|
22
|
+
"--title", "SQLite WAL",
|
|
23
|
+
"--category", "pattern",
|
|
24
|
+
"--importance", "4",
|
|
25
|
+
"--project", "homelab",
|
|
26
|
+
"--tags", "sqlite,db",
|
|
27
|
+
"--json",
|
|
28
|
+
]);
|
|
29
|
+
expect(parsed.ok).toBe(true);
|
|
30
|
+
if (!parsed.ok) return;
|
|
31
|
+
expect(parsed.command).toBe("learn");
|
|
32
|
+
expect(parsed.options["text"]).toBe("Use WAL mode for SQLite");
|
|
33
|
+
expect(parsed.options["title"]).toBe("SQLite WAL");
|
|
34
|
+
expect(parsed.options["category"]).toBe("pattern");
|
|
35
|
+
expect(parsed.options["importance"]).toBe(4);
|
|
36
|
+
expect(parsed.options["project"]).toBe("homelab");
|
|
37
|
+
expect(parsed.options["tags"]).toEqual(["sqlite", "db"]);
|
|
38
|
+
expect(parsed.json).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("learn without --text is a usage error", async () => {
|
|
42
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
43
|
+
const parsed = parseMemoryArgs(["learn", "--category", "pattern"]);
|
|
44
|
+
expect(parsed.ok).toBe(false);
|
|
45
|
+
if (parsed.ok) return;
|
|
46
|
+
expect(parsed.error).toContain("--text");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("learn rejects invalid category", async () => {
|
|
50
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
51
|
+
const parsed = parseMemoryArgs(["learn", "--text", "x", "--category", "bogus"]);
|
|
52
|
+
expect(parsed.ok).toBe(false);
|
|
53
|
+
if (parsed.ok) return;
|
|
54
|
+
expect(parsed.error).toContain("category");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("learn rejects importance outside 1-5", async () => {
|
|
58
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
59
|
+
const parsed = parseMemoryArgs(["learn", "--text", "x", "--importance", "9"]);
|
|
60
|
+
expect(parsed.ok).toBe(false);
|
|
61
|
+
if (parsed.ok) return;
|
|
62
|
+
expect(parsed.error).toContain("importance");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("parses recall with query/limit/project", async () => {
|
|
66
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
67
|
+
const parsed = parseMemoryArgs(["recall", "--query", "docker", "--limit", "5", "--project", "homelab"]);
|
|
68
|
+
expect(parsed.ok).toBe(true);
|
|
69
|
+
if (!parsed.ok) return;
|
|
70
|
+
expect(parsed.command).toBe("recall");
|
|
71
|
+
expect(parsed.options["query"]).toBe("docker");
|
|
72
|
+
expect(parsed.options["limit"]).toBe(5);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("forget requires numeric --id", async () => {
|
|
76
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
77
|
+
expect(parseMemoryArgs(["forget"]).ok).toBe(false);
|
|
78
|
+
expect(parseMemoryArgs(["forget", "--id", "abc"]).ok).toBe(false);
|
|
79
|
+
const parsed = parseMemoryArgs(["forget", "--id", "12", "--reason", "stale"]);
|
|
80
|
+
expect(parsed.ok).toBe(true);
|
|
81
|
+
if (!parsed.ok) return;
|
|
82
|
+
expect(parsed.options["id"]).toBe(12);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("relate requires --from --to --type with valid relationship", async () => {
|
|
86
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
87
|
+
expect(parseMemoryArgs(["relate", "--from", "1"]).ok).toBe(false);
|
|
88
|
+
expect(parseMemoryArgs(["relate", "--from", "1", "--to", "2", "--type", "nope"]).ok).toBe(false);
|
|
89
|
+
const parsed = parseMemoryArgs(["relate", "--from", "1", "--to", "2", "--type", "supports"]);
|
|
90
|
+
expect(parsed.ok).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("context requires --project", async () => {
|
|
94
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
95
|
+
expect(parseMemoryArgs(["context"]).ok).toBe(false);
|
|
96
|
+
expect(parseMemoryArgs(["context", "--project", "homelab"]).ok).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("unknown memory subcommand is a usage error", async () => {
|
|
100
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
101
|
+
const parsed = parseMemoryArgs(["destroy-everything"]);
|
|
102
|
+
expect(parsed.ok).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("no subcommand is a usage error", async () => {
|
|
106
|
+
const { parseMemoryArgs } = await import("../../cli/memory.js");
|
|
107
|
+
expect(parseMemoryArgs([]).ok).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Round-trip against temp DB ────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("cli/memory — runMemoryCommand round-trip", () => {
|
|
114
|
+
let dir: string;
|
|
115
|
+
|
|
116
|
+
beforeAll(async () => {
|
|
117
|
+
dir = mkdtempSync(join(tmpdir(), "ltm-cli-test-"));
|
|
118
|
+
const dbPath = join(dir, "test.db");
|
|
119
|
+
const { initDb, _setDbForTesting } = await import("../../shared-db.js");
|
|
120
|
+
const db = await initDb({ dbPath });
|
|
121
|
+
_setDbForTesting(db as Database);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(() => {
|
|
125
|
+
rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("learn then recall returns the stored memory", async () => {
|
|
129
|
+
const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
|
|
130
|
+
const learnParsed = parseMemoryArgs([
|
|
131
|
+
"learn", "--text", "CLI round trip memory about traefik routing", "--category", "gotcha", "--json",
|
|
132
|
+
]);
|
|
133
|
+
expect(learnParsed.ok).toBe(true);
|
|
134
|
+
if (!learnParsed.ok) return;
|
|
135
|
+
const learnRes = await runMemoryCommand(learnParsed);
|
|
136
|
+
expect(learnRes.exitCode).toBe(0);
|
|
137
|
+
const learnOut = JSON.parse(learnRes.output);
|
|
138
|
+
expect(typeof learnOut.id).toBe("number");
|
|
139
|
+
expect(["created", "reinforced"]).toContain(learnOut.action);
|
|
140
|
+
|
|
141
|
+
const recallParsed = parseMemoryArgs(["recall", "--query", "traefik routing", "--json"]);
|
|
142
|
+
expect(recallParsed.ok).toBe(true);
|
|
143
|
+
if (!recallParsed.ok) return;
|
|
144
|
+
const recallRes = await runMemoryCommand(recallParsed);
|
|
145
|
+
expect(recallRes.exitCode).toBe(0);
|
|
146
|
+
const rows = JSON.parse(recallRes.output);
|
|
147
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
148
|
+
expect(rows.some((r: { id: number }) => r.id === learnOut.id)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("forget removes the memory", async () => {
|
|
152
|
+
const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
|
|
153
|
+
const learnParsed = parseMemoryArgs(["learn", "--text", "Temporary memory to forget immediately", "--category", "pattern", "--json"]);
|
|
154
|
+
if (!learnParsed.ok) throw new Error("parse failed");
|
|
155
|
+
const learnRes = await runMemoryCommand(learnParsed);
|
|
156
|
+
const { id } = JSON.parse(learnRes.output);
|
|
157
|
+
|
|
158
|
+
const forgetParsed = parseMemoryArgs(["forget", "--id", String(id), "--reason", "test", "--json"]);
|
|
159
|
+
if (!forgetParsed.ok) throw new Error("parse failed");
|
|
160
|
+
const forgetRes = await runMemoryCommand(forgetParsed);
|
|
161
|
+
expect(forgetRes.exitCode).toBe(0);
|
|
162
|
+
expect(JSON.parse(forgetRes.output).ok).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("relate links two memories", async () => {
|
|
166
|
+
const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
|
|
167
|
+
const a = JSON.parse((await runMemoryCommand(parseMemoryArgs(["learn", "--text", "Memory A for relate test", "--category", "pattern", "--json"]) as never)).output);
|
|
168
|
+
const b = JSON.parse((await runMemoryCommand(parseMemoryArgs(["learn", "--text", "Memory B for relate test", "--category", "pattern", "--json"]) as never)).output);
|
|
169
|
+
const parsed = parseMemoryArgs(["relate", "--from", String(a.id), "--to", String(b.id), "--type", "related_to", "--json"]);
|
|
170
|
+
if (!parsed.ok) throw new Error("parse failed");
|
|
171
|
+
const res = await runMemoryCommand(parsed);
|
|
172
|
+
expect(res.exitCode).toBe(0);
|
|
173
|
+
expect(JSON.parse(res.output).ok).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("context returns globals and scoped arrays", async () => {
|
|
177
|
+
const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
|
|
178
|
+
const parsed = parseMemoryArgs(["context", "--project", "ltm-cli-test", "--json"]);
|
|
179
|
+
if (!parsed.ok) throw new Error("parse failed");
|
|
180
|
+
const res = await runMemoryCommand(parsed);
|
|
181
|
+
expect(res.exitCode).toBe(0);
|
|
182
|
+
const out = JSON.parse(res.output);
|
|
183
|
+
expect(Array.isArray(out.globals)).toBe(true);
|
|
184
|
+
expect(Array.isArray(out.scoped)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server.test.ts — smoke tests for packages/openltm-core/src/mcp/server.ts
|
|
3
|
+
*
|
|
4
|
+
* The full stdio handshake is covered by manual smoke tests; here we assert the
|
|
5
|
+
* module loads, the factory builds a connectable server, and the tool surface
|
|
6
|
+
* matches the documented contract.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
|
|
10
|
+
describe("mcp/server — buildMcpServer", () => {
|
|
11
|
+
it("exports buildMcpServer and startMcpServer", async () => {
|
|
12
|
+
const mod = await import("../mcp/server.js");
|
|
13
|
+
expect(typeof mod.buildMcpServer).toBe("function");
|
|
14
|
+
expect(typeof mod.startMcpServer).toBe("function");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("builds a server exposing the documented tool set", async () => {
|
|
18
|
+
const { buildMcpServer } = await import("../mcp/server.js");
|
|
19
|
+
const server = buildMcpServer();
|
|
20
|
+
expect(typeof server.connect).toBe("function");
|
|
21
|
+
// McpServer keeps registered tools in a private map — assert via the
|
|
22
|
+
// public-ish _registeredTools record the SDK maintains.
|
|
23
|
+
const tools = (server as unknown as { _registeredTools: Record<string, unknown> })._registeredTools;
|
|
24
|
+
const names = Object.keys(tools);
|
|
25
|
+
for (const expected of ["recall", "learn", "relate", "forget", "revalidate", "admin_audit", "context", "graph", "context_items"]) {
|
|
26
|
+
expect(names).toContain(expected);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("accepts host config hooks without invoking them at build time", async () => {
|
|
31
|
+
const { buildMcpServer } = await import("../mcp/server.js");
|
|
32
|
+
let called = false;
|
|
33
|
+
buildMcpServer({
|
|
34
|
+
isEnabled: async () => { called = true; return true; },
|
|
35
|
+
categoriseThreshold: async () => { called = true; return 0.6; },
|
|
36
|
+
});
|
|
37
|
+
expect(called).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -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
|
+
}
|