chapterhouse 0.3.26 → 0.4.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.
Files changed (52) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +27 -4
  6. package/dist/copilot/agents.test.js +7 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +227 -3
  9. package/dist/copilot/orchestrator.test.js +372 -0
  10. package/dist/copilot/system-message.js +4 -0
  11. package/dist/copilot/system-message.test.js +24 -0
  12. package/dist/copilot/tools.agent.test.js +23 -0
  13. package/dist/copilot/tools.js +350 -4
  14. package/dist/copilot/tools.memory.test.js +248 -0
  15. package/dist/copilot/turn-event-log-env.test.js +19 -0
  16. package/dist/copilot/turn-event-log.js +22 -23
  17. package/dist/copilot/turn-event-log.test.js +61 -2
  18. package/dist/memory/active-scope.js +69 -0
  19. package/dist/memory/active-scope.test.js +76 -0
  20. package/dist/memory/checkpoint-prompt.js +71 -0
  21. package/dist/memory/checkpoint.js +257 -0
  22. package/dist/memory/checkpoint.test.js +255 -0
  23. package/dist/memory/decisions.js +53 -0
  24. package/dist/memory/decisions.test.js +92 -0
  25. package/dist/memory/entities.js +59 -0
  26. package/dist/memory/entities.test.js +65 -0
  27. package/dist/memory/eot.js +219 -0
  28. package/dist/memory/eot.test.js +263 -0
  29. package/dist/memory/hot-tier.js +187 -0
  30. package/dist/memory/hot-tier.test.js +197 -0
  31. package/dist/memory/housekeeping.js +352 -0
  32. package/dist/memory/housekeeping.test.js +280 -0
  33. package/dist/memory/inbox.js +73 -0
  34. package/dist/memory/index.js +11 -0
  35. package/dist/memory/observations.js +46 -0
  36. package/dist/memory/observations.test.js +86 -0
  37. package/dist/memory/recall.js +197 -0
  38. package/dist/memory/recall.test.js +196 -0
  39. package/dist/memory/scopes.js +89 -0
  40. package/dist/memory/scopes.test.js +201 -0
  41. package/dist/memory/tiering.js +193 -0
  42. package/dist/memory/types.js +2 -0
  43. package/dist/paths.js +7 -1
  44. package/dist/store/db.js +412 -8
  45. package/dist/store/db.test.js +83 -0
  46. package/dist/test/setup-env.js +16 -0
  47. package/dist/test/setup-env.test.js +4 -0
  48. package/package.json +1 -1
  49. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  50. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -0,0 +1,255 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-checkpoint-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
17
+ const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
18
+ const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
19
+ return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
20
+ }
21
+ function getFunction(module, name) {
22
+ const value = module[name];
23
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
24
+ return value;
25
+ }
26
+ function getConstructor(module, name) {
27
+ const value = module[name];
28
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
29
+ return value;
30
+ }
31
+ test.beforeEach(async () => {
32
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED;
33
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE;
34
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE;
35
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
36
+ dbModule.closeDb();
37
+ resetSandbox();
38
+ });
39
+ test.after(async () => {
40
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED;
41
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE;
42
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE;
43
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
44
+ dbModule.closeDb();
45
+ rmSync(sandboxRoot, { recursive: true, force: true });
46
+ });
47
+ test("CheckpointTracker increments on orchestrator turns, fires at cadence, and resets after markFired", async () => {
48
+ const { checkpointModule } = await loadModules();
49
+ const CheckpointTracker = getConstructor(checkpointModule, "CheckpointTracker");
50
+ const tracker = new CheckpointTracker({ turns: 3, enabled: true });
51
+ assert.equal(tracker.turnsSinceLastFire(), 0);
52
+ assert.equal(tracker.shouldFire(), false);
53
+ tracker.tickOrchestratorTurn();
54
+ assert.equal(tracker.turnsSinceLastFire(), 1);
55
+ assert.equal(tracker.shouldFire(), false);
56
+ tracker.tickOrchestratorTurn();
57
+ assert.equal(tracker.turnsSinceLastFire(), 2);
58
+ assert.equal(tracker.shouldFire(), false);
59
+ tracker.tickOrchestratorTurn();
60
+ assert.equal(tracker.turnsSinceLastFire(), 3);
61
+ assert.equal(tracker.shouldFire(), true);
62
+ tracker.markFired();
63
+ assert.equal(tracker.turnsSinceLastFire(), 0);
64
+ assert.equal(tracker.shouldFire(), false);
65
+ tracker.tickOrchestratorTurn();
66
+ assert.equal(tracker.turnsSinceLastFire(), 1);
67
+ assert.equal(tracker.shouldFire(), false);
68
+ tracker.markScopeChangeFire();
69
+ assert.equal(tracker.turnsSinceLastFire(), 0);
70
+ tracker.reset();
71
+ assert.equal(tracker.turnsSinceLastFire(), 0);
72
+ assert.equal(tracker.shouldFire(), false);
73
+ });
74
+ test("CheckpointTracker stays disabled when CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED=0", async () => {
75
+ process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED = "0";
76
+ const { checkpointModule } = await loadModules("disabled");
77
+ const CheckpointTracker = getConstructor(checkpointModule, "CheckpointTracker");
78
+ const tracker = new CheckpointTracker();
79
+ for (let index = 0; index < 10; index++) {
80
+ tracker.tickOrchestratorTurn();
81
+ }
82
+ assert.equal(tracker.shouldFire(), false);
83
+ });
84
+ test("runCheckpointExtraction writes high-confidence memories, skips low-confidence duplicates, and caps at five writes", async () => {
85
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
86
+ dbModule.getDb();
87
+ const getScope = getFunction(memoryModule, "getScope");
88
+ const recordObservation = getFunction(memoryModule, "recordObservation");
89
+ const listObservations = getFunction(memoryModule, "listObservations");
90
+ const listDecisions = getFunction(memoryModule, "listDecisions");
91
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
92
+ const chapterhouse = getScope("chapterhouse");
93
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
94
+ recordObservation({
95
+ scope_id: chapterhouse.id,
96
+ content: "Base new memory implementation branches from origin/main after dependent PRs merge.",
97
+ source: "agent:test",
98
+ });
99
+ const result = await runCheckpointExtraction({
100
+ turns: [
101
+ {
102
+ user: "Implement checkpoint extraction after the response is committed.",
103
+ assistant: "I will add an async checkpoint pass with a five-turn cadence.",
104
+ },
105
+ ],
106
+ activeScope: chapterhouse,
107
+ copilotClient: {},
108
+ callLLM: async () => JSON.stringify({
109
+ proposals: [
110
+ {
111
+ kind: "observation",
112
+ content: "Base new memory implementation branches from origin/main after dependent PRs merge.",
113
+ confidence: 0.99,
114
+ },
115
+ {
116
+ kind: "decision",
117
+ title: "Run checkpoint extraction asynchronously",
118
+ content: "Checkpoint extraction runs in the background after the assistant response completes so the next user turn is never blocked.",
119
+ confidence: 0.98,
120
+ },
121
+ {
122
+ kind: "observation",
123
+ content: "Only orchestrator turns count toward the checkpoint cadence.",
124
+ confidence: 0.97,
125
+ },
126
+ {
127
+ kind: "observation",
128
+ content: "Checkpoint extraction skips overlapping runs instead of queueing them.",
129
+ confidence: 0.96,
130
+ },
131
+ {
132
+ kind: "observation",
133
+ content: "Checkpoint extraction defaults to the active scope when writing remembered items.",
134
+ confidence: 0.95,
135
+ },
136
+ {
137
+ kind: "observation",
138
+ content: "Checkpoint prompts include recent durable decisions already recorded in the active scope to avoid duplicates.",
139
+ confidence: 0.94,
140
+ },
141
+ {
142
+ kind: "observation",
143
+ content: "Checkpoint prompts stay under a tight token budget.",
144
+ confidence: 0.93,
145
+ },
146
+ {
147
+ kind: "observation",
148
+ content: "Low-confidence chatter about ephemeral commit messages should not be remembered.",
149
+ confidence: 0.49,
150
+ },
151
+ ],
152
+ }),
153
+ });
154
+ assert.deepEqual(result.errors, []);
155
+ assert.equal(result.written, 5);
156
+ assert.equal(result.skipped, 3);
157
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
158
+ assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
159
+ });
160
+ test("runCheckpointExtraction handles malformed JSON responses without crashing", async () => {
161
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
162
+ dbModule.getDb();
163
+ const getScope = getFunction(memoryModule, "getScope");
164
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
165
+ const chapterhouse = getScope("chapterhouse");
166
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
167
+ const result = await runCheckpointExtraction({
168
+ turns: [{ user: "Remember this.", assistant: "Okay." }],
169
+ activeScope: chapterhouse,
170
+ copilotClient: {},
171
+ callLLM: async () => "{ definitely-not-json",
172
+ });
173
+ assert.equal(result.written, 0);
174
+ assert.ok(result.errors.length >= 1);
175
+ });
176
+ test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
177
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
178
+ dbModule.getDb();
179
+ const getScope = getFunction(memoryModule, "getScope");
180
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
181
+ const isCheckpointInFlight = getFunction(checkpointModule, "isCheckpointInFlight");
182
+ const chapterhouse = getScope("chapterhouse");
183
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
184
+ let resolveFirst;
185
+ let llmCalls = 0;
186
+ const pendingResponse = new Promise((resolve) => {
187
+ resolveFirst = resolve;
188
+ });
189
+ const firstRun = runCheckpointExtraction({
190
+ turns: [{ user: "First", assistant: "Turn" }],
191
+ activeScope: chapterhouse,
192
+ copilotClient: {},
193
+ callLLM: async () => {
194
+ llmCalls++;
195
+ return await pendingResponse;
196
+ },
197
+ });
198
+ assert.equal(isCheckpointInFlight(), true);
199
+ const secondRun = await runCheckpointExtraction({
200
+ turns: [{ user: "Second", assistant: "Turn" }],
201
+ activeScope: chapterhouse,
202
+ copilotClient: {},
203
+ callLLM: async () => {
204
+ llmCalls++;
205
+ return JSON.stringify({ proposals: [] });
206
+ },
207
+ });
208
+ assert.equal(secondRun.written, 0);
209
+ assert.equal(llmCalls, 1);
210
+ resolveFirst(JSON.stringify({ proposals: [] }));
211
+ await firstRun;
212
+ assert.equal(isCheckpointInFlight(), false);
213
+ });
214
+ test("buildCheckpointUserPrompt adds scope-change instructions when context is provided", async () => {
215
+ const { checkpointPromptModule } = await loadModules("scope-change-prompt");
216
+ const buildCheckpointUserPrompt = getFunction(checkpointPromptModule, "buildCheckpointUserPrompt");
217
+ const prompt = buildCheckpointUserPrompt({
218
+ turns: [{ user: "Wrap PR 4.5", assistant: "Switching to wiki lint next." }],
219
+ activeScope: {
220
+ id: 1,
221
+ slug: "chapterhouse",
222
+ title: "Chapterhouse",
223
+ description: "Core Chapterhouse work.",
224
+ },
225
+ entities: [],
226
+ decisions: [],
227
+ scopeChangeContext: { from: "chapterhouse", to: "wiki" },
228
+ });
229
+ assert.match(prompt, /moving from scope chapterhouse to wiki/i);
230
+ assert.match(prompt, /extract everything worth remembering about scope chapterhouse/i);
231
+ });
232
+ test("runCheckpointExtraction passes scope-change context through to the prompt", async () => {
233
+ const { dbModule, memoryModule, checkpointModule } = await loadModules("scope-change-context");
234
+ dbModule.getDb();
235
+ const getScope = getFunction(memoryModule, "getScope");
236
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
237
+ const chapterhouse = getScope("chapterhouse");
238
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
239
+ let capturedUserPrompt = "";
240
+ const result = await runCheckpointExtraction({
241
+ turns: [{ user: "Finish memory work", assistant: "Next I will move to wiki cleanup." }],
242
+ activeScope: chapterhouse,
243
+ copilotClient: {},
244
+ trigger: "scope_change",
245
+ scopeChangeContext: { from: "chapterhouse", to: "wiki" },
246
+ callLLM: async ({ user }) => {
247
+ capturedUserPrompt = user;
248
+ return JSON.stringify({ proposals: [] });
249
+ },
250
+ });
251
+ assert.deepEqual(result.errors, []);
252
+ assert.match(capturedUserPrompt, /moving from scope chapterhouse to wiki/i);
253
+ assert.match(capturedUserPrompt, /before the context shifts/i);
254
+ });
255
+ //# sourceMappingURL=checkpoint.test.js.map
@@ -0,0 +1,53 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toDecision(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ entityId: row.entity_id ?? undefined,
7
+ title: row.title,
8
+ rationale: row.rationale,
9
+ decidedAt: row.decided_at,
10
+ tier: row.tier,
11
+ supersededBy: row.superseded_by ?? undefined,
12
+ archivedAt: row.archived_at ?? undefined,
13
+ createdAt: row.created_at,
14
+ };
15
+ }
16
+ export function recordDecision(input) {
17
+ const result = getDb().prepare(`
18
+ INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, tier, created_at)
19
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
20
+ `).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.tier ?? "warm");
21
+ return getDecision(Number(result.lastInsertRowid));
22
+ }
23
+ export function getDecision(id) {
24
+ const row = getDb().prepare(`
25
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
26
+ FROM mem_decisions
27
+ WHERE id = ?
28
+ `).get(id);
29
+ return row ? toDecision(row) : undefined;
30
+ }
31
+ export function listDecisions(input = {}) {
32
+ const rows = getDb().prepare(`
33
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
34
+ FROM mem_decisions
35
+ WHERE (? IS NULL OR scope_id = ?)
36
+ AND (? IS NULL OR entity_id = ?)
37
+ ORDER BY decided_at DESC, id DESC
38
+ LIMIT ? OFFSET ?
39
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.entity_id ?? null, input.entity_id ?? null, input.limit ?? 50, input.offset ?? 0);
40
+ return rows.map(toDecision);
41
+ }
42
+ export function supersedeDecision(id, supersededByDecisionId) {
43
+ const result = getDb().prepare(`
44
+ UPDATE mem_decisions
45
+ SET superseded_by = ?
46
+ WHERE id = ?
47
+ `).run(supersededByDecisionId, id);
48
+ if (result.changes === 0) {
49
+ throw new Error(`Unknown decision id '${id}'.`);
50
+ }
51
+ return getDecision(id);
52
+ }
53
+ //# sourceMappingURL=decisions.js.map
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-decisions-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
26
+ dbModule.closeDb();
27
+ resetSandbox();
28
+ });
29
+ test.after(async () => {
30
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
+ dbModule.closeDb();
32
+ rmSync(sandboxRoot, { recursive: true, force: true });
33
+ });
34
+ test("recordDecision supports CRUD, supersession, and keeps the decision FTS index in sync", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ const db = dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const recordDecision = getFunction(memoryModule, "recordDecision");
39
+ const getDecision = getFunction(memoryModule, "getDecision");
40
+ const listDecisions = getFunction(memoryModule, "listDecisions");
41
+ const supersedeDecision = getFunction(memoryModule, "supersedeDecision");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
44
+ const original = recordDecision({
45
+ scope_id: chapterhouse.id,
46
+ title: "Use SQLite for scoped memory",
47
+ rationale: "SQLite FTS5 keeps memory recall local, fast, and dependency-free.",
48
+ decided_at: "2026-05-13",
49
+ });
50
+ const replacement = recordDecision({
51
+ scope_id: chapterhouse.id,
52
+ title: "Keep SQLite for agent memory v1",
53
+ rationale: "SQLite remains the only persistence layer for PR 2.",
54
+ decided_at: "2026-05-14",
55
+ });
56
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).length >= 2, true);
57
+ assert.deepEqual(getDecision(original.id)?.title, original.title);
58
+ const initialHits = db.prepare(`
59
+ SELECT rowid
60
+ FROM mem_decisions_fts
61
+ WHERE mem_decisions_fts MATCH 'dependency'
62
+ `).all();
63
+ assert.deepEqual(initialHits, [{ rowid: original.id }]);
64
+ db.prepare(`
65
+ UPDATE mem_decisions
66
+ SET rationale = ?
67
+ WHERE id = ?
68
+ `).run("FTS snippets should refresh when the rationale changes.", original.id);
69
+ const oldHits = db.prepare(`
70
+ SELECT rowid
71
+ FROM mem_decisions_fts
72
+ WHERE mem_decisions_fts MATCH 'dependency'
73
+ `).all();
74
+ const newHits = db.prepare(`
75
+ SELECT rowid
76
+ FROM mem_decisions_fts
77
+ WHERE mem_decisions_fts MATCH 'snippets'
78
+ `).all();
79
+ assert.deepEqual(oldHits, []);
80
+ assert.deepEqual(newHits, [{ rowid: original.id }]);
81
+ const superseded = supersedeDecision(original.id, replacement.id);
82
+ assert.equal(superseded.supersededBy, replacement.id);
83
+ assert.equal(getDecision(original.id)?.supersededBy, replacement.id);
84
+ db.prepare(`DELETE FROM mem_decisions WHERE id = ?`).run(original.id);
85
+ const deletedHits = db.prepare(`
86
+ SELECT rowid
87
+ FROM mem_decisions_fts
88
+ WHERE mem_decisions_fts MATCH 'snippets'
89
+ `).all();
90
+ assert.deepEqual(deletedHits, []);
91
+ });
92
+ //# sourceMappingURL=decisions.test.js.map
@@ -0,0 +1,59 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toEntity(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ kind: row.kind,
7
+ name: row.name,
8
+ summary: row.summary ?? undefined,
9
+ tier: row.tier,
10
+ confidence: row.confidence,
11
+ createdAt: row.created_at,
12
+ updatedAt: row.updated_at,
13
+ };
14
+ }
15
+ export function getEntity(id) {
16
+ const row = getDb().prepare(`
17
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
18
+ FROM mem_entities
19
+ WHERE id = ?
20
+ `).get(id);
21
+ return row ? toEntity(row) : undefined;
22
+ }
23
+ export function findEntityByName(scopeId, kind, name) {
24
+ const row = getDb().prepare(`
25
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
26
+ FROM mem_entities
27
+ WHERE scope_id = ? AND kind = ? AND name = ?
28
+ `).get(scopeId, kind, name);
29
+ return row ? toEntity(row) : undefined;
30
+ }
31
+ export function upsertEntity(input) {
32
+ const db = getDb();
33
+ const existing = findEntityByName(input.scope_id, input.kind, input.name);
34
+ if (existing) {
35
+ db.prepare(`
36
+ UPDATE mem_entities
37
+ SET summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
38
+ WHERE id = ?
39
+ `).run(input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
40
+ return getEntity(existing.id);
41
+ }
42
+ const result = db.prepare(`
43
+ INSERT INTO mem_entities (scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
44
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
45
+ `).run(input.scope_id, input.kind, input.name, input.summary ?? null, input.tier ?? "warm", input.confidence ?? 1.0);
46
+ return getEntity(Number(result.lastInsertRowid));
47
+ }
48
+ export function listEntities(input = {}) {
49
+ const rows = getDb().prepare(`
50
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
51
+ FROM mem_entities
52
+ WHERE (? IS NULL OR scope_id = ?)
53
+ AND (? IS NULL OR kind = ?)
54
+ ORDER BY updated_at DESC, id DESC
55
+ LIMIT ? OFFSET ?
56
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.kind ?? null, input.kind ?? null, input.limit ?? 50, input.offset ?? 0);
57
+ return rows.map(toEntity);
58
+ }
59
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-entities-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
26
+ dbModule.closeDb();
27
+ resetSandbox();
28
+ });
29
+ test.after(async () => {
30
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
+ dbModule.closeDb();
32
+ rmSync(sandboxRoot, { recursive: true, force: true });
33
+ });
34
+ test("upsertEntity is idempotent on scope, kind, and name", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const upsertEntity = getFunction(memoryModule, "upsertEntity");
39
+ const getEntity = getFunction(memoryModule, "getEntity");
40
+ const findEntityByName = getFunction(memoryModule, "findEntityByName");
41
+ const listEntities = getFunction(memoryModule, "listEntities");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
44
+ const first = upsertEntity({
45
+ scope_id: chapterhouse.id,
46
+ kind: "tool",
47
+ name: "better-sqlite3",
48
+ summary: "SQLite driver used for memory persistence.",
49
+ tier: "warm",
50
+ confidence: 0.7,
51
+ });
52
+ const second = upsertEntity({
53
+ scope_id: chapterhouse.id,
54
+ kind: "tool",
55
+ name: "better-sqlite3",
56
+ summary: "Sync SQLite driver used throughout the daemon.",
57
+ tier: "hot",
58
+ confidence: 0.95,
59
+ });
60
+ assert.equal(second.id, first.id);
61
+ assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "tool" }).length, 1);
62
+ assert.equal(findEntityByName(chapterhouse.id, "tool", "better-sqlite3")?.id, first.id);
63
+ assert.deepEqual(getEntity(first.id), second);
64
+ });
65
+ //# sourceMappingURL=entities.test.js.map