chapterhouse 0.3.26 → 0.4.1

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 (53) 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 +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -0,0 +1,73 @@
1
+ import { getDb } from "../store/db.js";
2
+ import { getActiveScope } from "./active-scope.js";
3
+ import { getScope } from "./scopes.js";
4
+ function toInboxItem(row) {
5
+ return {
6
+ id: row.id,
7
+ scopeId: row.scope_id ?? undefined,
8
+ kind: row.kind,
9
+ payload: row.payload,
10
+ sourceAgent: row.source_agent,
11
+ sourceTaskId: row.source_task_id ?? undefined,
12
+ status: row.status,
13
+ createdAt: row.created_at,
14
+ resolvedAt: row.resolved_at ?? undefined,
15
+ resolutionReason: row.resolution_reason ?? undefined,
16
+ };
17
+ }
18
+ export function resolveProposalScope(scopeSlug) {
19
+ if (scopeSlug) {
20
+ const explicit = getScope(scopeSlug);
21
+ if (!explicit) {
22
+ throw new Error(`Unknown memory scope '${scopeSlug}'.`);
23
+ }
24
+ return { scopeId: explicit.id, scopeSlug: explicit.slug };
25
+ }
26
+ const activeScope = getActiveScope();
27
+ if (!activeScope) {
28
+ throw new Error("No active memory scope is set. Use memory_set_scope or pass scope_slug explicitly.");
29
+ }
30
+ return { scopeId: activeScope.id, scopeSlug: activeScope.slug };
31
+ }
32
+ export function queueMemoryProposal(input) {
33
+ const { scopeId, scopeSlug } = resolveProposalScope(input.scopeSlug);
34
+ const envelope = {
35
+ kind: input.kind,
36
+ scope_slug: scopeSlug,
37
+ confidence: input.confidence ?? 0.5,
38
+ reason: input.reason,
39
+ payload: input.payload,
40
+ };
41
+ const result = getDb().prepare(`
42
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status, created_at)
43
+ VALUES (?, 'memory_proposal', ?, ?, ?, 'pending', CURRENT_TIMESTAMP)
44
+ `).run(scopeId, JSON.stringify(envelope), input.sourceAgent, input.sourceTaskId ?? null);
45
+ return getInboxItem(Number(result.lastInsertRowid));
46
+ }
47
+ export function getInboxItem(id) {
48
+ const row = getDb().prepare(`
49
+ SELECT id, scope_id, kind, payload, source_agent, source_task_id, status, created_at, resolved_at, resolution_reason
50
+ FROM mem_inbox
51
+ WHERE id = ?
52
+ `).get(id);
53
+ return row ? toInboxItem(row) : undefined;
54
+ }
55
+ export function listPendingMemoryProposalsForTask(taskId) {
56
+ const rows = getDb().prepare(`
57
+ SELECT id, scope_id, kind, payload, source_agent, source_task_id, status, created_at, resolved_at, resolution_reason
58
+ FROM mem_inbox
59
+ WHERE source_task_id = ?
60
+ AND status = 'pending'
61
+ AND kind = 'memory_proposal'
62
+ ORDER BY id ASC
63
+ `).all(taskId);
64
+ return rows.map(toInboxItem);
65
+ }
66
+ export function resolveInboxItem(id, status, reason) {
67
+ getDb().prepare(`
68
+ UPDATE mem_inbox
69
+ SET status = ?, resolution_reason = ?, resolved_at = CURRENT_TIMESTAMP
70
+ WHERE id = ?
71
+ `).run(status, reason, id);
72
+ }
73
+ //# sourceMappingURL=inbox.js.map
@@ -0,0 +1,11 @@
1
+ export { getActiveScope, inferScopeFromText, setActiveScope } from "./active-scope.js";
2
+ export { recordDecision, getDecision, listDecisions, supersedeDecision } from "./decisions.js";
3
+ export { getEntity, findEntityByName, listEntities, upsertEntity } from "./entities.js";
4
+ export { getHotTierEntries, renderHotTierForActiveScope, renderHotTierXML } from "./hot-tier.js";
5
+ export { compactInboxPass, decayPass, dedupDecisionsPass, dedupObservationsPass, isHousekeepingInFlight, orphanCleanupPass, runHousekeeping, } from "./housekeeping.js";
6
+ export { getInboxItem, listPendingMemoryProposalsForTask, queueMemoryProposal, resolveInboxItem, resolveProposalScope } from "./inbox.js";
7
+ export { recordObservation, getObservation, listObservations, deleteObservation } from "./observations.js";
8
+ export { recall } from "./recall.js";
9
+ export { createScope, deactivateScope, getScope, listScopes, updateScope } from "./scopes.js";
10
+ export { demoteToCold, demoteToWarm, inferTierFromSignals, promoteToHot, tieringPass } from "./tiering.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,46 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toObservation(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ entityId: row.entity_id ?? undefined,
7
+ content: row.content,
8
+ source: row.source,
9
+ tier: row.tier,
10
+ confidence: row.confidence,
11
+ embedding: row.embedding ?? undefined,
12
+ supersededBy: row.superseded_by ?? undefined,
13
+ archivedAt: row.archived_at ?? undefined,
14
+ createdAt: row.created_at,
15
+ };
16
+ }
17
+ export function recordObservation(input) {
18
+ const result = getDb().prepare(`
19
+ INSERT INTO mem_observations (scope_id, entity_id, content, source, tier, confidence, created_at)
20
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
21
+ `).run(input.scope_id, input.entity_id ?? null, input.content, input.source, input.tier ?? "warm", input.confidence ?? 1.0);
22
+ return getObservation(Number(result.lastInsertRowid));
23
+ }
24
+ export function getObservation(id) {
25
+ const row = getDb().prepare(`
26
+ SELECT id, scope_id, entity_id, content, source, tier, confidence, embedding, superseded_by, archived_at, created_at
27
+ FROM mem_observations
28
+ WHERE id = ?
29
+ `).get(id);
30
+ return row ? toObservation(row) : undefined;
31
+ }
32
+ export function listObservations(input = {}) {
33
+ const rows = getDb().prepare(`
34
+ SELECT id, scope_id, entity_id, content, source, tier, confidence, embedding, superseded_by, archived_at, created_at
35
+ FROM mem_observations
36
+ WHERE (? IS NULL OR scope_id = ?)
37
+ AND (? IS NULL OR entity_id = ?)
38
+ ORDER BY id DESC
39
+ LIMIT ? OFFSET ?
40
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.entity_id ?? null, input.entity_id ?? null, input.limit ?? 50, input.offset ?? 0);
41
+ return rows.map(toObservation);
42
+ }
43
+ export function deleteObservation(id) {
44
+ getDb().prepare(`DELETE FROM mem_observations WHERE id = ?`).run(id);
45
+ }
46
+ //# sourceMappingURL=observations.js.map
@@ -0,0 +1,86 @@
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-observations-${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("recordObservation supports CRUD and keeps the observation FTS index in sync", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ const db = dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const recordObservation = getFunction(memoryModule, "recordObservation");
39
+ const getObservation = getFunction(memoryModule, "getObservation");
40
+ const listObservations = getFunction(memoryModule, "listObservations");
41
+ const deleteObservation = getFunction(memoryModule, "deleteObservation");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
44
+ const created = recordObservation({
45
+ scope_id: chapterhouse.id,
46
+ content: "SQLite WAL mode keeps Chapterhouse memory writes fast.",
47
+ source: "user",
48
+ tier: "hot",
49
+ confidence: 0.9,
50
+ });
51
+ assert.equal(created.content, "SQLite WAL mode keeps Chapterhouse memory writes fast.");
52
+ assert.deepEqual(getObservation(created.id)?.content, created.content);
53
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.id === created.id), true);
54
+ const initialHits = db.prepare(`
55
+ SELECT rowid
56
+ FROM mem_observations_fts
57
+ WHERE mem_observations_fts MATCH 'WAL'
58
+ `).all();
59
+ assert.deepEqual(initialHits, [{ rowid: created.id }]);
60
+ db.prepare(`
61
+ UPDATE mem_observations
62
+ SET content = ?
63
+ WHERE id = ?
64
+ `).run("FTS rebuild test for hot-tier prompts and scoped recall.", created.id);
65
+ const walHits = db.prepare(`
66
+ SELECT rowid
67
+ FROM mem_observations_fts
68
+ WHERE mem_observations_fts MATCH 'WAL'
69
+ `).all();
70
+ const rebuildHits = db.prepare(`
71
+ SELECT rowid
72
+ FROM mem_observations_fts
73
+ WHERE mem_observations_fts MATCH 'rebuild'
74
+ `).all();
75
+ assert.deepEqual(walHits, []);
76
+ assert.deepEqual(rebuildHits, [{ rowid: created.id }]);
77
+ deleteObservation(created.id);
78
+ assert.equal(getObservation(created.id), undefined);
79
+ const postDeleteHits = db.prepare(`
80
+ SELECT rowid
81
+ FROM mem_observations_fts
82
+ WHERE mem_observations_fts MATCH 'rebuild'
83
+ `).all();
84
+ assert.deepEqual(postDeleteHits, []);
85
+ });
86
+ //# sourceMappingURL=observations.test.js.map
@@ -0,0 +1,210 @@
1
+ import { config } from "../config.js";
2
+ import { getDb, isFts5Available } from "../store/db.js";
3
+ import { getActiveScope } from "./active-scope.js";
4
+ function quoteFts5QueryTerms(query) {
5
+ return query
6
+ .trim()
7
+ .split(/\s+/)
8
+ .filter((term) => term.length > 0)
9
+ .map((term) => {
10
+ const unquoted = term.replace(/^["']|["']$/g, "");
11
+ return `"${unquoted.replace(/"/g, "\"\"")}"`;
12
+ })
13
+ .join(" ");
14
+ }
15
+ function recallHotTier(scopeId, options = {}) {
16
+ const rows = getDb().prepare(`
17
+ SELECT 'observation' AS kind, id, content
18
+ FROM mem_observations
19
+ WHERE scope_id = ? AND tier = 'hot'
20
+ AND (? = 1 OR superseded_by IS NULL)
21
+ AND (? = 1 OR archived_at IS NULL)
22
+ UNION ALL
23
+ SELECT 'decision' AS kind, id, title || ' — ' || rationale AS content
24
+ FROM mem_decisions
25
+ WHERE scope_id = ? AND tier = 'hot'
26
+ AND (? = 1 OR superseded_by IS NULL)
27
+ AND (? = 1 OR archived_at IS NULL)
28
+ UNION ALL
29
+ SELECT 'entity' AS kind, id, name || COALESCE(' — ' || summary, '') AS content
30
+ FROM mem_entities
31
+ WHERE scope_id = ? AND tier = 'hot'
32
+ ORDER BY id DESC
33
+ LIMIT 10
34
+ `).all(scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, scopeId);
35
+ return rows;
36
+ }
37
+ function recallObservationHits(query, scopeId, options = {}) {
38
+ if (isFts5Available()) {
39
+ const ftsQuery = quoteFts5QueryTerms(query);
40
+ const rows = getDb().prepare(`
41
+ SELECT
42
+ o.id,
43
+ o.scope_id,
44
+ s.slug AS scope,
45
+ o.content,
46
+ o.tier,
47
+ -bm25(mem_observations_fts) * CASE WHEN o.tier = 'hot' THEN ? ELSE 1 END AS score,
48
+ snippet(mem_observations_fts, 0, '[', ']', '…', 12) AS snippet
49
+ FROM mem_observations o
50
+ JOIN mem_scopes s ON s.id = o.scope_id
51
+ JOIN mem_observations_fts ON mem_observations_fts.rowid = o.id
52
+ WHERE mem_observations_fts MATCH ?
53
+ AND (? IS NULL OR o.scope_id = ?)
54
+ AND (? = 1 OR o.tier != 'cold')
55
+ AND (? = 1 OR o.superseded_by IS NULL)
56
+ AND (? = 1 OR o.archived_at IS NULL)
57
+ ORDER BY score DESC, o.id DESC
58
+ `).all(config.memoryHotRecallBoost, ftsQuery, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
59
+ return rows.map((row) => ({
60
+ kind: "observation",
61
+ id: row.id,
62
+ scopeId: row.scope_id,
63
+ scope: row.scope,
64
+ content: row.content,
65
+ score: row.score,
66
+ snippet: row.snippet ?? row.content,
67
+ }));
68
+ }
69
+ const pattern = `%${query}%`;
70
+ const rows = getDb().prepare(`
71
+ SELECT o.id, o.scope_id, s.slug AS scope, o.content, o.tier
72
+ FROM mem_observations o
73
+ JOIN mem_scopes s ON s.id = o.scope_id
74
+ WHERE (? IS NULL OR o.scope_id = ?)
75
+ AND (? = 1 OR o.tier != 'cold')
76
+ AND (? = 1 OR o.superseded_by IS NULL)
77
+ AND (? = 1 OR o.archived_at IS NULL)
78
+ AND o.content LIKE ?
79
+ ORDER BY o.id DESC
80
+ `).all(scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, pattern);
81
+ return rows.map((row) => ({
82
+ kind: "observation",
83
+ id: row.id,
84
+ scopeId: row.scope_id,
85
+ scope: row.scope,
86
+ content: row.content,
87
+ score: row.tier === "hot" ? config.memoryHotRecallBoost : 1,
88
+ snippet: row.content,
89
+ }));
90
+ }
91
+ function recallDecisionHits(query, scopeId, options = {}) {
92
+ if (isFts5Available()) {
93
+ const ftsQuery = quoteFts5QueryTerms(query);
94
+ const rows = getDb().prepare(`
95
+ SELECT
96
+ d.id,
97
+ d.scope_id,
98
+ s.slug AS scope,
99
+ d.title,
100
+ d.rationale,
101
+ d.decided_at,
102
+ d.tier,
103
+ -bm25(mem_decisions_fts) * CASE WHEN d.tier = 'hot' THEN ? ELSE 1 END AS score,
104
+ snippet(mem_decisions_fts, 0, '[', ']', '…', 8) || ' — ' ||
105
+ snippet(mem_decisions_fts, 1, '[', ']', '…', 12) AS snippet
106
+ FROM mem_decisions d
107
+ JOIN mem_scopes s ON s.id = d.scope_id
108
+ JOIN mem_decisions_fts ON mem_decisions_fts.rowid = d.id
109
+ WHERE mem_decisions_fts MATCH ?
110
+ AND (? IS NULL OR d.scope_id = ?)
111
+ AND (? = 1 OR d.tier != 'cold')
112
+ AND (? = 1 OR d.superseded_by IS NULL)
113
+ AND (? = 1 OR d.archived_at IS NULL)
114
+ ORDER BY score DESC, d.id DESC
115
+ `).all(config.memoryHotRecallBoost, ftsQuery, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
116
+ return rows.map((row) => ({
117
+ kind: "decision",
118
+ id: row.id,
119
+ scopeId: row.scope_id,
120
+ scope: row.scope,
121
+ content: `${row.title} — ${row.rationale}`,
122
+ decidedAt: row.decided_at,
123
+ score: row.score,
124
+ snippet: row.snippet ?? `${row.title} — ${row.rationale}`,
125
+ }));
126
+ }
127
+ const pattern = `%${query}%`;
128
+ const rows = getDb().prepare(`
129
+ SELECT d.id, d.scope_id, s.slug AS scope, d.title, d.rationale, d.decided_at, d.tier
130
+ FROM mem_decisions d
131
+ JOIN mem_scopes s ON s.id = d.scope_id
132
+ WHERE (? IS NULL OR d.scope_id = ?)
133
+ AND (? = 1 OR d.tier != 'cold')
134
+ AND (? = 1 OR d.superseded_by IS NULL)
135
+ AND (? = 1 OR d.archived_at IS NULL)
136
+ AND (d.title LIKE ? OR d.rationale LIKE ?)
137
+ ORDER BY d.decided_at DESC, d.id DESC
138
+ `).all(scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, pattern, pattern);
139
+ return rows.map((row) => ({
140
+ kind: "decision",
141
+ id: row.id,
142
+ scopeId: row.scope_id,
143
+ scope: row.scope,
144
+ content: `${row.title} — ${row.rationale}`,
145
+ decidedAt: row.decided_at,
146
+ score: row.tier === "hot" ? config.memoryHotRecallBoost : 1,
147
+ snippet: `${row.title} — ${row.rationale}`,
148
+ }));
149
+ }
150
+ function recallEntityHits(query, scopeId, options = {}) {
151
+ const pattern = `%${query}%`;
152
+ const rows = getDb().prepare(`
153
+ SELECT e.id, e.scope_id, s.slug AS scope, e.name, e.summary, e.tier
154
+ FROM mem_entities e
155
+ JOIN mem_scopes s ON s.id = e.scope_id
156
+ WHERE (? IS NULL OR e.scope_id = ?)
157
+ AND (e.name LIKE ? OR COALESCE(e.summary, '') LIKE ?)
158
+ AND (? = 1 OR e.tier != 'cold')
159
+ ORDER BY e.updated_at DESC, e.id DESC
160
+ `).all(scopeId ?? null, scopeId ?? null, pattern, pattern, options.includeCold ? 1 : 0);
161
+ return rows.map((row) => ({
162
+ kind: "entity",
163
+ id: row.id,
164
+ scopeId: row.scope_id,
165
+ scope: row.scope,
166
+ content: `${row.name}${row.summary ? ` — ${row.summary}` : ""}`,
167
+ score: (row.name.toLowerCase().includes(query.toLowerCase()) ? 2 : 1)
168
+ * (row.tier === "hot" ? config.memoryHotRecallBoost : 1),
169
+ snippet: `${row.name}${row.summary ? ` — ${row.summary}` : ""}`,
170
+ }));
171
+ }
172
+ export function recall(input) {
173
+ const activeScope = getActiveScope();
174
+ const effectiveScopeId = input.scope_id ?? activeScope?.id;
175
+ const requestedKinds = new Set(input.kinds ?? ["observation", "decision", "entity"]);
176
+ const hotTier = activeScope && (!effectiveScopeId || activeScope.id === effectiveScopeId)
177
+ ? recallHotTier(activeScope.id, input)
178
+ : [];
179
+ const hits = [
180
+ ...(requestedKinds.has("observation") ? recallObservationHits(input.query, effectiveScopeId, input) : []),
181
+ ...(requestedKinds.has("decision") ? recallDecisionHits(input.query, effectiveScopeId, input) : []),
182
+ ...(requestedKinds.has("entity") ? recallEntityHits(input.query, effectiveScopeId, input) : []),
183
+ ]
184
+ .sort((a, b) => {
185
+ if (b.score !== a.score)
186
+ return b.score - a.score;
187
+ return b.id - a.id;
188
+ })
189
+ .slice(0, input.limit ?? 10);
190
+ if (hits.length > 0) {
191
+ const db = getDb();
192
+ const tables = {
193
+ observation: "mem_observations",
194
+ decision: "mem_decisions",
195
+ entity: "mem_entities",
196
+ };
197
+ for (const kind of Object.keys(tables)) {
198
+ const ids = hits.filter((hit) => hit.kind === kind).map((hit) => hit.id);
199
+ if (ids.length > 0) {
200
+ db.prepare(`UPDATE ${tables[kind]} SET last_recalled_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => "?").join(",")})`).run(...ids);
201
+ }
202
+ }
203
+ }
204
+ return {
205
+ activeScope,
206
+ hotTier,
207
+ hits,
208
+ };
209
+ }
210
+ //# sourceMappingURL=recall.js.map
@@ -0,0 +1,238 @@
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-recall-${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("recall returns active-scope hot-tier entries before ranked FTS hits and respects filters", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
39
+ const recordObservation = getFunction(memoryModule, "recordObservation");
40
+ const recordDecision = getFunction(memoryModule, "recordDecision");
41
+ const upsertEntity = getFunction(memoryModule, "upsertEntity");
42
+ const recall = getFunction(memoryModule, "recall");
43
+ const chapterhouse = getScope("chapterhouse");
44
+ const global = getScope("global");
45
+ assert.ok(chapterhouse && global, "seeded scopes should be available");
46
+ setActiveScope("chapterhouse");
47
+ const hotObservation = recordObservation({
48
+ scope_id: chapterhouse.id,
49
+ content: "Hot memory: build with npm run build before releasing scoped memory changes.",
50
+ source: "user",
51
+ tier: "hot",
52
+ });
53
+ const bestDecision = recordDecision({
54
+ scope_id: chapterhouse.id,
55
+ title: "Use SQLite FTS5 for scoped recall",
56
+ rationale: "SQLite FTS5 gives ranked scoped recall and snippet generation for agent memory.",
57
+ decided_at: "2026-05-13",
58
+ });
59
+ recordObservation({
60
+ scope_id: chapterhouse.id,
61
+ content: "SQLite recall fallback exists, but FTS5 is preferred for ranked snippet output.",
62
+ source: "user",
63
+ tier: "warm",
64
+ });
65
+ recordObservation({
66
+ scope_id: global.id,
67
+ content: "Global memory should not leak into chapterhouse-scoped recall.",
68
+ source: "user",
69
+ tier: "hot",
70
+ });
71
+ upsertEntity({
72
+ scope_id: chapterhouse.id,
73
+ kind: "tool",
74
+ name: "better-sqlite3",
75
+ summary: "SQLite driver backing FTS5 recall and scope settings.",
76
+ tier: "warm",
77
+ confidence: 0.9,
78
+ });
79
+ const full = recall({ query: "SQLite FTS5 scoped recall", scope_id: chapterhouse.id, limit: 10 });
80
+ assert.equal(full.activeScope?.slug, "chapterhouse");
81
+ assert.equal(full.hotTier.some((entry) => entry.id === hotObservation.id), true);
82
+ assert.equal(full.hits.some((entry) => entry.id === bestDecision.id && entry.kind === "decision"), true);
83
+ assert.equal(full.hits.every((entry) => entry.snippet.toLowerCase().includes("sqlite")), true);
84
+ assert.equal(full.hits[0]?.id, bestDecision.id);
85
+ const decisionOnly = recall({
86
+ query: "SQLite FTS5 scoped recall",
87
+ scope_id: chapterhouse.id,
88
+ kinds: ["decision"],
89
+ limit: 10,
90
+ });
91
+ assert.equal(decisionOnly.hits.every((entry) => entry.kind === "decision"), true);
92
+ const limited = recall({
93
+ query: "SQLite FTS5 scoped recall",
94
+ scope_id: chapterhouse.id,
95
+ limit: 1,
96
+ });
97
+ assert.equal(limited.hits.length, 1);
98
+ });
99
+ test("recall excludes superseded and archived rows by default with opt-in inclusion", async () => {
100
+ const { dbModule, memoryModule } = await loadModules();
101
+ const db = dbModule.getDb();
102
+ const getScope = getFunction(memoryModule, "getScope");
103
+ const recordObservation = getFunction(memoryModule, "recordObservation");
104
+ const recordDecision = getFunction(memoryModule, "recordDecision");
105
+ const recall = getFunction(memoryModule, "recall");
106
+ const chapterhouse = getScope("chapterhouse");
107
+ assert.ok(chapterhouse);
108
+ const liveObservation = recordObservation({
109
+ scope_id: chapterhouse.id,
110
+ content: "retention sentinel live observation",
111
+ source: "test",
112
+ });
113
+ const supersededObservation = recordObservation({
114
+ scope_id: chapterhouse.id,
115
+ content: "retention sentinel superseded observation",
116
+ source: "test",
117
+ });
118
+ const archivedObservation = recordObservation({
119
+ scope_id: chapterhouse.id,
120
+ content: "retention sentinel archived observation",
121
+ source: "test",
122
+ });
123
+ const liveDecision = recordDecision({
124
+ scope_id: chapterhouse.id,
125
+ title: "retention sentinel live decision",
126
+ rationale: "visible decision",
127
+ });
128
+ const supersededDecision = recordDecision({
129
+ scope_id: chapterhouse.id,
130
+ title: "retention sentinel superseded decision",
131
+ rationale: "hidden decision",
132
+ decided_at: "2026-05-12",
133
+ });
134
+ const archivedDecision = recordDecision({
135
+ scope_id: chapterhouse.id,
136
+ title: "retention sentinel archived decision",
137
+ rationale: "hidden decision",
138
+ decided_at: "2026-05-11",
139
+ });
140
+ db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ?`).run(liveObservation.id, supersededObservation.id);
141
+ db.prepare(`UPDATE mem_observations SET archived_at = CURRENT_TIMESTAMP WHERE id = ?`).run(archivedObservation.id);
142
+ db.prepare(`UPDATE mem_decisions SET superseded_by = ? WHERE id = ?`).run(liveDecision.id, supersededDecision.id);
143
+ db.prepare(`UPDATE mem_decisions SET archived_at = CURRENT_TIMESTAMP WHERE id = ?`).run(archivedDecision.id);
144
+ const defaults = recall({ query: "retention sentinel", scope_id: chapterhouse.id, limit: 20 });
145
+ assert.equal(defaults.hits.some((hit) => hit.id === liveObservation.id && hit.kind === "observation"), true);
146
+ assert.equal(defaults.hits.some((hit) => hit.id === liveDecision.id && hit.kind === "decision"), true);
147
+ assert.equal(defaults.hits.some((hit) => hit.id === supersededObservation.id && hit.kind === "observation"), false);
148
+ assert.equal(defaults.hits.some((hit) => hit.id === archivedObservation.id && hit.kind === "observation"), false);
149
+ assert.equal(defaults.hits.some((hit) => hit.id === supersededDecision.id && hit.kind === "decision"), false);
150
+ assert.equal(defaults.hits.some((hit) => hit.id === archivedDecision.id && hit.kind === "decision"), false);
151
+ const included = recall({
152
+ query: "retention sentinel",
153
+ scope_id: chapterhouse.id,
154
+ limit: 20,
155
+ includeSuperseded: true,
156
+ includeArchived: true,
157
+ });
158
+ assert.equal(included.hits.some((hit) => hit.id === supersededObservation.id && hit.kind === "observation"), true);
159
+ assert.equal(included.hits.some((hit) => hit.id === archivedObservation.id && hit.kind === "observation"), true);
160
+ assert.equal(included.hits.some((hit) => hit.id === supersededDecision.id && hit.kind === "decision"), true);
161
+ assert.equal(included.hits.some((hit) => hit.id === archivedDecision.id && hit.kind === "decision"), true);
162
+ });
163
+ test("recall boosts hot rows and excludes cold rows unless includeCold is set", async () => {
164
+ const { dbModule, memoryModule } = await loadModules();
165
+ dbModule.getDb();
166
+ const getScope = getFunction(memoryModule, "getScope");
167
+ const recordObservation = getFunction(memoryModule, "recordObservation");
168
+ const recall = getFunction(memoryModule, "recall");
169
+ const chapterhouse = getScope("chapterhouse");
170
+ assert.ok(chapterhouse);
171
+ const warm = recordObservation({
172
+ scope_id: chapterhouse.id,
173
+ content: "tierboost sentinel equal lexical match",
174
+ source: "test",
175
+ tier: "warm",
176
+ });
177
+ const hot = recordObservation({
178
+ scope_id: chapterhouse.id,
179
+ content: "tierboost sentinel equal lexical match",
180
+ source: "test",
181
+ tier: "hot",
182
+ });
183
+ const cold = recordObservation({
184
+ scope_id: chapterhouse.id,
185
+ content: "tierboost sentinel cold lexical match",
186
+ source: "test",
187
+ tier: "cold",
188
+ });
189
+ const defaults = recall({ query: "tierboost sentinel", scope_id: chapterhouse.id, kinds: ["observation"], limit: 10 });
190
+ assert.equal(defaults.hits[0]?.id, hot.id);
191
+ assert.equal(defaults.hits.some((hit) => hit.id === warm.id), true);
192
+ assert.equal(defaults.hits.some((hit) => hit.id === cold.id), false);
193
+ const withCold = recall({ query: "tierboost sentinel", scope_id: chapterhouse.id, kinds: ["observation"], limit: 10, includeCold: true });
194
+ assert.equal(withCold.hits.some((hit) => hit.id === cold.id), true);
195
+ });
196
+ test("recall matches multi-word observation queries with exact tokens", async () => {
197
+ const { dbModule, memoryModule } = await loadModules();
198
+ dbModule.getDb();
199
+ const getScope = getFunction(memoryModule, "getScope");
200
+ const recordObservation = getFunction(memoryModule, "recordObservation");
201
+ const recall = getFunction(memoryModule, "recall");
202
+ const chapterhouse = getScope("chapterhouse");
203
+ assert.ok(chapterhouse);
204
+ const observation = recordObservation({
205
+ scope_id: chapterhouse.id,
206
+ content: "Chapterhouse memory P1 shipped on 2026-05-13",
207
+ source: "test",
208
+ });
209
+ const result = recall({
210
+ query: "memory P1 shipped",
211
+ scope_id: chapterhouse.id,
212
+ kinds: ["observation"],
213
+ limit: 10,
214
+ });
215
+ assert.equal(result.hits.some((hit) => hit.id === observation.id), true);
216
+ });
217
+ test("recall treats hyphenated observation query terms literally", async () => {
218
+ const { dbModule, memoryModule } = await loadModules();
219
+ dbModule.getDb();
220
+ const getScope = getFunction(memoryModule, "getScope");
221
+ const recordObservation = getFunction(memoryModule, "recordObservation");
222
+ const recall = getFunction(memoryModule, "recall");
223
+ const chapterhouse = getScope("chapterhouse");
224
+ assert.ok(chapterhouse);
225
+ const observation = recordObservation({
226
+ scope_id: chapterhouse.id,
227
+ content: "Chapterhouse agent-memory recall shipped safely",
228
+ source: "test",
229
+ });
230
+ const result = recall({
231
+ query: "agent-memory",
232
+ scope_id: chapterhouse.id,
233
+ kinds: ["observation"],
234
+ limit: 10,
235
+ });
236
+ assert.equal(result.hits.some((hit) => hit.id === observation.id), true);
237
+ });
238
+ //# sourceMappingURL=recall.test.js.map