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.
- package/dist/api/server.js +12 -0
- package/dist/api/server.test.js +39 -0
- package/dist/config.js +70 -0
- package/dist/config.test.js +109 -0
- package/dist/copilot/agents.js +27 -4
- package/dist/copilot/agents.test.js +7 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +227 -3
- package/dist/copilot/orchestrator.test.js +372 -0
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +24 -0
- package/dist/copilot/tools.agent.test.js +23 -0
- package/dist/copilot/tools.js +350 -4
- package/dist/copilot/tools.memory.test.js +248 -0
- package/dist/copilot/turn-event-log-env.test.js +19 -0
- package/dist/copilot/turn-event-log.js +22 -23
- package/dist/copilot/turn-event-log.test.js +61 -2
- package/dist/memory/active-scope.js +69 -0
- package/dist/memory/active-scope.test.js +76 -0
- package/dist/memory/checkpoint-prompt.js +71 -0
- package/dist/memory/checkpoint.js +257 -0
- package/dist/memory/checkpoint.test.js +255 -0
- package/dist/memory/decisions.js +53 -0
- package/dist/memory/decisions.test.js +92 -0
- package/dist/memory/entities.js +59 -0
- package/dist/memory/entities.test.js +65 -0
- package/dist/memory/eot.js +219 -0
- package/dist/memory/eot.test.js +263 -0
- package/dist/memory/hot-tier.js +187 -0
- package/dist/memory/hot-tier.test.js +197 -0
- package/dist/memory/housekeeping.js +352 -0
- package/dist/memory/housekeeping.test.js +280 -0
- package/dist/memory/inbox.js +73 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/observations.js +46 -0
- package/dist/memory/observations.test.js +86 -0
- package/dist/memory/recall.js +197 -0
- package/dist/memory/recall.test.js +196 -0
- package/dist/memory/scopes.js +89 -0
- package/dist/memory/scopes.test.js +201 -0
- package/dist/memory/tiering.js +193 -0
- package/dist/memory/types.js +2 -0
- package/dist/paths.js +7 -1
- package/dist/store/db.js +412 -8
- package/dist/store/db.test.js +83 -0
- package/dist/test/setup-env.js +16 -0
- package/dist/test/setup-env.test.js +4 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
- package/web/dist/assets/index-DmYLALt0.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { getDb, isFts5Available } from "../store/db.js";
|
|
3
|
+
import { getActiveScope } from "./active-scope.js";
|
|
4
|
+
function recallHotTier(scopeId, options = {}) {
|
|
5
|
+
const rows = getDb().prepare(`
|
|
6
|
+
SELECT 'observation' AS kind, id, content
|
|
7
|
+
FROM mem_observations
|
|
8
|
+
WHERE scope_id = ? AND tier = 'hot'
|
|
9
|
+
AND (? = 1 OR superseded_by IS NULL)
|
|
10
|
+
AND (? = 1 OR archived_at IS NULL)
|
|
11
|
+
UNION ALL
|
|
12
|
+
SELECT 'decision' AS kind, id, title || ' — ' || rationale AS content
|
|
13
|
+
FROM mem_decisions
|
|
14
|
+
WHERE scope_id = ? AND tier = 'hot'
|
|
15
|
+
AND (? = 1 OR superseded_by IS NULL)
|
|
16
|
+
AND (? = 1 OR archived_at IS NULL)
|
|
17
|
+
UNION ALL
|
|
18
|
+
SELECT 'entity' AS kind, id, name || COALESCE(' — ' || summary, '') AS content
|
|
19
|
+
FROM mem_entities
|
|
20
|
+
WHERE scope_id = ? AND tier = 'hot'
|
|
21
|
+
ORDER BY id DESC
|
|
22
|
+
LIMIT 10
|
|
23
|
+
`).all(scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, scopeId);
|
|
24
|
+
return rows;
|
|
25
|
+
}
|
|
26
|
+
function recallObservationHits(query, scopeId, options = {}) {
|
|
27
|
+
if (isFts5Available()) {
|
|
28
|
+
const rows = getDb().prepare(`
|
|
29
|
+
SELECT
|
|
30
|
+
o.id,
|
|
31
|
+
o.scope_id,
|
|
32
|
+
s.slug AS scope,
|
|
33
|
+
o.content,
|
|
34
|
+
o.tier,
|
|
35
|
+
-bm25(mem_observations_fts) * CASE WHEN o.tier = 'hot' THEN ? ELSE 1 END AS score,
|
|
36
|
+
snippet(mem_observations_fts, 0, '[', ']', '…', 12) AS snippet
|
|
37
|
+
FROM mem_observations o
|
|
38
|
+
JOIN mem_scopes s ON s.id = o.scope_id
|
|
39
|
+
JOIN mem_observations_fts ON mem_observations_fts.rowid = o.id
|
|
40
|
+
WHERE mem_observations_fts MATCH ?
|
|
41
|
+
AND (? IS NULL OR o.scope_id = ?)
|
|
42
|
+
AND (? = 1 OR o.tier != 'cold')
|
|
43
|
+
AND (? = 1 OR o.superseded_by IS NULL)
|
|
44
|
+
AND (? = 1 OR o.archived_at IS NULL)
|
|
45
|
+
ORDER BY score DESC, o.id DESC
|
|
46
|
+
`).all(config.memoryHotRecallBoost, query, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
|
|
47
|
+
return rows.map((row) => ({
|
|
48
|
+
kind: "observation",
|
|
49
|
+
id: row.id,
|
|
50
|
+
scopeId: row.scope_id,
|
|
51
|
+
scope: row.scope,
|
|
52
|
+
content: row.content,
|
|
53
|
+
score: row.score,
|
|
54
|
+
snippet: row.snippet ?? row.content,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
const pattern = `%${query}%`;
|
|
58
|
+
const rows = getDb().prepare(`
|
|
59
|
+
SELECT o.id, o.scope_id, s.slug AS scope, o.content, o.tier
|
|
60
|
+
FROM mem_observations o
|
|
61
|
+
JOIN mem_scopes s ON s.id = o.scope_id
|
|
62
|
+
WHERE (? IS NULL OR o.scope_id = ?)
|
|
63
|
+
AND (? = 1 OR o.tier != 'cold')
|
|
64
|
+
AND (? = 1 OR o.superseded_by IS NULL)
|
|
65
|
+
AND (? = 1 OR o.archived_at IS NULL)
|
|
66
|
+
AND o.content LIKE ?
|
|
67
|
+
ORDER BY o.id DESC
|
|
68
|
+
`).all(scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, pattern);
|
|
69
|
+
return rows.map((row) => ({
|
|
70
|
+
kind: "observation",
|
|
71
|
+
id: row.id,
|
|
72
|
+
scopeId: row.scope_id,
|
|
73
|
+
scope: row.scope,
|
|
74
|
+
content: row.content,
|
|
75
|
+
score: row.tier === "hot" ? config.memoryHotRecallBoost : 1,
|
|
76
|
+
snippet: row.content,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
function recallDecisionHits(query, scopeId, options = {}) {
|
|
80
|
+
if (isFts5Available()) {
|
|
81
|
+
const rows = getDb().prepare(`
|
|
82
|
+
SELECT
|
|
83
|
+
d.id,
|
|
84
|
+
d.scope_id,
|
|
85
|
+
s.slug AS scope,
|
|
86
|
+
d.title,
|
|
87
|
+
d.rationale,
|
|
88
|
+
d.decided_at,
|
|
89
|
+
d.tier,
|
|
90
|
+
-bm25(mem_decisions_fts) * CASE WHEN d.tier = 'hot' THEN ? ELSE 1 END AS score,
|
|
91
|
+
snippet(mem_decisions_fts, 0, '[', ']', '…', 8) || ' — ' ||
|
|
92
|
+
snippet(mem_decisions_fts, 1, '[', ']', '…', 12) AS snippet
|
|
93
|
+
FROM mem_decisions d
|
|
94
|
+
JOIN mem_scopes s ON s.id = d.scope_id
|
|
95
|
+
JOIN mem_decisions_fts ON mem_decisions_fts.rowid = d.id
|
|
96
|
+
WHERE mem_decisions_fts MATCH ?
|
|
97
|
+
AND (? IS NULL OR d.scope_id = ?)
|
|
98
|
+
AND (? = 1 OR d.tier != 'cold')
|
|
99
|
+
AND (? = 1 OR d.superseded_by IS NULL)
|
|
100
|
+
AND (? = 1 OR d.archived_at IS NULL)
|
|
101
|
+
ORDER BY score DESC, d.id DESC
|
|
102
|
+
`).all(config.memoryHotRecallBoost, query, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
|
|
103
|
+
return rows.map((row) => ({
|
|
104
|
+
kind: "decision",
|
|
105
|
+
id: row.id,
|
|
106
|
+
scopeId: row.scope_id,
|
|
107
|
+
scope: row.scope,
|
|
108
|
+
content: `${row.title} — ${row.rationale}`,
|
|
109
|
+
decidedAt: row.decided_at,
|
|
110
|
+
score: row.score,
|
|
111
|
+
snippet: row.snippet ?? `${row.title} — ${row.rationale}`,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
const pattern = `%${query}%`;
|
|
115
|
+
const rows = getDb().prepare(`
|
|
116
|
+
SELECT d.id, d.scope_id, s.slug AS scope, d.title, d.rationale, d.decided_at, d.tier
|
|
117
|
+
FROM mem_decisions d
|
|
118
|
+
JOIN mem_scopes s ON s.id = d.scope_id
|
|
119
|
+
WHERE (? IS NULL OR d.scope_id = ?)
|
|
120
|
+
AND (? = 1 OR d.tier != 'cold')
|
|
121
|
+
AND (? = 1 OR d.superseded_by IS NULL)
|
|
122
|
+
AND (? = 1 OR d.archived_at IS NULL)
|
|
123
|
+
AND (d.title LIKE ? OR d.rationale LIKE ?)
|
|
124
|
+
ORDER BY d.decided_at DESC, d.id DESC
|
|
125
|
+
`).all(scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, pattern, pattern);
|
|
126
|
+
return rows.map((row) => ({
|
|
127
|
+
kind: "decision",
|
|
128
|
+
id: row.id,
|
|
129
|
+
scopeId: row.scope_id,
|
|
130
|
+
scope: row.scope,
|
|
131
|
+
content: `${row.title} — ${row.rationale}`,
|
|
132
|
+
decidedAt: row.decided_at,
|
|
133
|
+
score: row.tier === "hot" ? config.memoryHotRecallBoost : 1,
|
|
134
|
+
snippet: `${row.title} — ${row.rationale}`,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
function recallEntityHits(query, scopeId, options = {}) {
|
|
138
|
+
const pattern = `%${query}%`;
|
|
139
|
+
const rows = getDb().prepare(`
|
|
140
|
+
SELECT e.id, e.scope_id, s.slug AS scope, e.name, e.summary, e.tier
|
|
141
|
+
FROM mem_entities e
|
|
142
|
+
JOIN mem_scopes s ON s.id = e.scope_id
|
|
143
|
+
WHERE (? IS NULL OR e.scope_id = ?)
|
|
144
|
+
AND (e.name LIKE ? OR COALESCE(e.summary, '') LIKE ?)
|
|
145
|
+
AND (? = 1 OR e.tier != 'cold')
|
|
146
|
+
ORDER BY e.updated_at DESC, e.id DESC
|
|
147
|
+
`).all(scopeId ?? null, scopeId ?? null, pattern, pattern, options.includeCold ? 1 : 0);
|
|
148
|
+
return rows.map((row) => ({
|
|
149
|
+
kind: "entity",
|
|
150
|
+
id: row.id,
|
|
151
|
+
scopeId: row.scope_id,
|
|
152
|
+
scope: row.scope,
|
|
153
|
+
content: `${row.name}${row.summary ? ` — ${row.summary}` : ""}`,
|
|
154
|
+
score: (row.name.toLowerCase().includes(query.toLowerCase()) ? 2 : 1)
|
|
155
|
+
* (row.tier === "hot" ? config.memoryHotRecallBoost : 1),
|
|
156
|
+
snippet: `${row.name}${row.summary ? ` — ${row.summary}` : ""}`,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
export function recall(input) {
|
|
160
|
+
const activeScope = getActiveScope();
|
|
161
|
+
const effectiveScopeId = input.scope_id ?? activeScope?.id;
|
|
162
|
+
const requestedKinds = new Set(input.kinds ?? ["observation", "decision", "entity"]);
|
|
163
|
+
const hotTier = activeScope && (!effectiveScopeId || activeScope.id === effectiveScopeId)
|
|
164
|
+
? recallHotTier(activeScope.id, input)
|
|
165
|
+
: [];
|
|
166
|
+
const hits = [
|
|
167
|
+
...(requestedKinds.has("observation") ? recallObservationHits(input.query, effectiveScopeId, input) : []),
|
|
168
|
+
...(requestedKinds.has("decision") ? recallDecisionHits(input.query, effectiveScopeId, input) : []),
|
|
169
|
+
...(requestedKinds.has("entity") ? recallEntityHits(input.query, effectiveScopeId, input) : []),
|
|
170
|
+
]
|
|
171
|
+
.sort((a, b) => {
|
|
172
|
+
if (b.score !== a.score)
|
|
173
|
+
return b.score - a.score;
|
|
174
|
+
return b.id - a.id;
|
|
175
|
+
})
|
|
176
|
+
.slice(0, input.limit ?? 10);
|
|
177
|
+
if (hits.length > 0) {
|
|
178
|
+
const db = getDb();
|
|
179
|
+
const tables = {
|
|
180
|
+
observation: "mem_observations",
|
|
181
|
+
decision: "mem_decisions",
|
|
182
|
+
entity: "mem_entities",
|
|
183
|
+
};
|
|
184
|
+
for (const kind of Object.keys(tables)) {
|
|
185
|
+
const ids = hits.filter((hit) => hit.kind === kind).map((hit) => hit.id);
|
|
186
|
+
if (ids.length > 0) {
|
|
187
|
+
db.prepare(`UPDATE ${tables[kind]} SET last_recalled_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => "?").join(",")})`).run(...ids);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
activeScope,
|
|
193
|
+
hotTier,
|
|
194
|
+
hits,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=recall.js.map
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
//# sourceMappingURL=recall.test.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getDb } from "../store/db.js";
|
|
2
|
+
function parseKeywords(raw, scopeSlug) {
|
|
3
|
+
const parsed = JSON.parse(raw);
|
|
4
|
+
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
|
|
5
|
+
throw new Error(`Invalid mem_scopes.keywords payload for scope '${scopeSlug}'.`);
|
|
6
|
+
}
|
|
7
|
+
return parsed;
|
|
8
|
+
}
|
|
9
|
+
function serializeKeywords(keywords) {
|
|
10
|
+
if (!Array.isArray(keywords) || keywords.some((keyword) => typeof keyword !== "string")) {
|
|
11
|
+
throw new Error("Scope keywords must be an array of strings.");
|
|
12
|
+
}
|
|
13
|
+
return JSON.stringify(keywords);
|
|
14
|
+
}
|
|
15
|
+
function toScope(row) {
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
slug: row.slug,
|
|
19
|
+
title: row.title,
|
|
20
|
+
description: row.description,
|
|
21
|
+
keywords: parseKeywords(row.keywords, row.slug),
|
|
22
|
+
active: row.active === 1,
|
|
23
|
+
createdAt: row.created_at,
|
|
24
|
+
updatedAt: row.updated_at,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function getScopeRow(idOrSlug) {
|
|
28
|
+
const db = getDb();
|
|
29
|
+
if (typeof idOrSlug === "number") {
|
|
30
|
+
return db.prepare(`
|
|
31
|
+
SELECT id, slug, title, description, keywords, active, created_at, updated_at
|
|
32
|
+
FROM mem_scopes
|
|
33
|
+
WHERE id = ?
|
|
34
|
+
`).get(idOrSlug);
|
|
35
|
+
}
|
|
36
|
+
return db.prepare(`
|
|
37
|
+
SELECT id, slug, title, description, keywords, active, created_at, updated_at
|
|
38
|
+
FROM mem_scopes
|
|
39
|
+
WHERE slug = ?
|
|
40
|
+
`).get(idOrSlug);
|
|
41
|
+
}
|
|
42
|
+
export function listScopes() {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const rows = db.prepare(`
|
|
45
|
+
SELECT id, slug, title, description, keywords, active, created_at, updated_at
|
|
46
|
+
FROM mem_scopes
|
|
47
|
+
ORDER BY slug
|
|
48
|
+
`).all();
|
|
49
|
+
return rows.map(toScope);
|
|
50
|
+
}
|
|
51
|
+
export function getScope(idOrSlug) {
|
|
52
|
+
const row = getScopeRow(idOrSlug);
|
|
53
|
+
return row ? toScope(row) : undefined;
|
|
54
|
+
}
|
|
55
|
+
export function createScope(input) {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
const result = db.prepare(`
|
|
58
|
+
INSERT INTO mem_scopes (slug, title, description, keywords, active, created_at, updated_at)
|
|
59
|
+
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
60
|
+
`).run(input.slug, input.title, input.description, serializeKeywords(input.keywords));
|
|
61
|
+
return getScope(Number(result.lastInsertRowid));
|
|
62
|
+
}
|
|
63
|
+
export function updateScope(id, patch) {
|
|
64
|
+
const existing = getScope(id);
|
|
65
|
+
if (!existing) {
|
|
66
|
+
throw new Error(`Unknown scope id '${id}'.`);
|
|
67
|
+
}
|
|
68
|
+
const nextTitle = patch.title ?? existing.title;
|
|
69
|
+
const nextDescription = patch.description ?? existing.description;
|
|
70
|
+
const nextKeywords = patch.keywords ?? existing.keywords;
|
|
71
|
+
getDb().prepare(`
|
|
72
|
+
UPDATE mem_scopes
|
|
73
|
+
SET title = ?, description = ?, keywords = ?, updated_at = CURRENT_TIMESTAMP
|
|
74
|
+
WHERE id = ?
|
|
75
|
+
`).run(nextTitle, nextDescription, serializeKeywords(nextKeywords), id);
|
|
76
|
+
return getScope(id);
|
|
77
|
+
}
|
|
78
|
+
export function deactivateScope(id) {
|
|
79
|
+
const result = getDb().prepare(`
|
|
80
|
+
UPDATE mem_scopes
|
|
81
|
+
SET active = 0, updated_at = CURRENT_TIMESTAMP
|
|
82
|
+
WHERE id = ?
|
|
83
|
+
`).run(id);
|
|
84
|
+
if (result.changes === 0) {
|
|
85
|
+
throw new Error(`Unknown scope id '${id}'.`);
|
|
86
|
+
}
|
|
87
|
+
return getScope(id);
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=scopes.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
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-scopes-${process.pid}`);
|
|
7
|
+
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
8
|
+
const dbPath = join(chapterhouseHome, "chapterhouse.db");
|
|
9
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
10
|
+
async function loadModules() {
|
|
11
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
12
|
+
const memoryModule = await import(new URL("./scopes.js", import.meta.url).href);
|
|
13
|
+
return { dbModule, memoryModule };
|
|
14
|
+
}
|
|
15
|
+
function resetSandbox() {
|
|
16
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
17
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
18
|
+
mkdirSync(chapterhouseHome, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
test.beforeEach(async () => {
|
|
21
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
22
|
+
dbModule.closeDb();
|
|
23
|
+
resetSandbox();
|
|
24
|
+
});
|
|
25
|
+
test.after(async () => {
|
|
26
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
27
|
+
dbModule.closeDb();
|
|
28
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
test("getDb creates memory tables and indexes", async () => {
|
|
31
|
+
const { dbModule } = await loadModules();
|
|
32
|
+
try {
|
|
33
|
+
const db = dbModule.getDb();
|
|
34
|
+
const tables = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'mem_%'`).all()
|
|
35
|
+
.map((row) => row.name));
|
|
36
|
+
const indexes = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'mem_%'`).all()
|
|
37
|
+
.map((row) => row.name));
|
|
38
|
+
for (const name of [
|
|
39
|
+
"mem_scopes",
|
|
40
|
+
"mem_entities",
|
|
41
|
+
"mem_observations",
|
|
42
|
+
"mem_decisions",
|
|
43
|
+
"mem_inbox",
|
|
44
|
+
]) {
|
|
45
|
+
assert.equal(tables.has(name), true, `expected memory table ${name}`);
|
|
46
|
+
}
|
|
47
|
+
for (const name of [
|
|
48
|
+
"mem_scopes_slug_idx",
|
|
49
|
+
"mem_entities_scope_kind_idx",
|
|
50
|
+
"mem_observations_scope_idx",
|
|
51
|
+
"mem_decisions_scope_idx",
|
|
52
|
+
"mem_inbox_status_idx",
|
|
53
|
+
]) {
|
|
54
|
+
assert.equal(indexes.has(name), true, `expected memory index ${name}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
dbModule.closeDb();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("getDb seeds canonical memory scopes on first run", async () => {
|
|
62
|
+
const { dbModule } = await loadModules();
|
|
63
|
+
try {
|
|
64
|
+
const db = dbModule.getDb();
|
|
65
|
+
const rows = db.prepare(`
|
|
66
|
+
SELECT slug, title, description, keywords, active
|
|
67
|
+
FROM mem_scopes
|
|
68
|
+
ORDER BY slug
|
|
69
|
+
`).all();
|
|
70
|
+
assert.deepEqual(rows, [
|
|
71
|
+
{
|
|
72
|
+
slug: "brian",
|
|
73
|
+
title: "Brian",
|
|
74
|
+
description: "Brian's preferences, context, working style",
|
|
75
|
+
keywords: JSON.stringify(["brian"]),
|
|
76
|
+
active: 1,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
slug: "chapterhouse",
|
|
80
|
+
title: "Chapterhouse",
|
|
81
|
+
description: "Chapterhouse codebase, conventions, decisions, gotchas",
|
|
82
|
+
keywords: JSON.stringify(["chapterhouse", "this repo", "this project", "the daemon"]),
|
|
83
|
+
active: 1,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
slug: "global",
|
|
87
|
+
title: "Global",
|
|
88
|
+
description: "Cross-cutting facts that apply everywhere",
|
|
89
|
+
keywords: JSON.stringify(["everywhere", "general"]),
|
|
90
|
+
active: 1,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
slug: "infra",
|
|
94
|
+
title: "Infra",
|
|
95
|
+
description: "Infrastructure, hosting, deployment, CI/CD",
|
|
96
|
+
keywords: JSON.stringify(["infra"]),
|
|
97
|
+
active: 1,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
slug: "team",
|
|
101
|
+
title: "Team",
|
|
102
|
+
description: "Team processes, rituals, OKRs",
|
|
103
|
+
keywords: JSON.stringify(["team"]),
|
|
104
|
+
active: 1,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
dbModule.closeDb();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test("memory schema initialization is idempotent", async () => {
|
|
113
|
+
const { dbModule } = await loadModules();
|
|
114
|
+
try {
|
|
115
|
+
dbModule.getDb();
|
|
116
|
+
dbModule.closeDb();
|
|
117
|
+
const reopened = await loadModules();
|
|
118
|
+
const db = reopened.dbModule.getDb();
|
|
119
|
+
const counts = db.prepare(`
|
|
120
|
+
SELECT slug, COUNT(*) AS count
|
|
121
|
+
FROM mem_scopes
|
|
122
|
+
GROUP BY slug
|
|
123
|
+
ORDER BY slug
|
|
124
|
+
`).all();
|
|
125
|
+
assert.deepEqual(counts, [
|
|
126
|
+
{ slug: "brian", count: 1 },
|
|
127
|
+
{ slug: "chapterhouse", count: 1 },
|
|
128
|
+
{ slug: "global", count: 1 },
|
|
129
|
+
{ slug: "infra", count: 1 },
|
|
130
|
+
{ slug: "team", count: 1 },
|
|
131
|
+
]);
|
|
132
|
+
reopened.dbModule.closeDb();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
dbModule.closeDb();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
test("scope CRUD creates, reads, lists, updates, deactivates, and preserves keywords", async () => {
|
|
139
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
140
|
+
try {
|
|
141
|
+
const created = memoryModule.createScope({
|
|
142
|
+
slug: "docs-site",
|
|
143
|
+
title: "Docs Site",
|
|
144
|
+
description: "Documentation publishing and content workflows",
|
|
145
|
+
keywords: ["docs", "content", "publish"],
|
|
146
|
+
});
|
|
147
|
+
assert.equal(created.slug, "docs-site");
|
|
148
|
+
assert.equal(created.title, "Docs Site");
|
|
149
|
+
assert.equal(created.description, "Documentation publishing and content workflows");
|
|
150
|
+
assert.deepEqual(created.keywords, ["docs", "content", "publish"]);
|
|
151
|
+
assert.equal(created.active, true);
|
|
152
|
+
assert.deepEqual(memoryModule.getScope("docs-site"), created);
|
|
153
|
+
assert.deepEqual(memoryModule.getScope(created.id), created);
|
|
154
|
+
const listed = memoryModule.listScopes();
|
|
155
|
+
assert.equal(listed.some((scope) => scope.slug === "docs-site"), true);
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
157
|
+
const updated = memoryModule.updateScope(created.id, {
|
|
158
|
+
title: "Docs Platform",
|
|
159
|
+
description: "Docs platform and release content",
|
|
160
|
+
keywords: ["docs", "release", "guides"],
|
|
161
|
+
});
|
|
162
|
+
assert.equal(updated.id, created.id);
|
|
163
|
+
assert.equal(updated.slug, "docs-site");
|
|
164
|
+
assert.equal(updated.title, "Docs Platform");
|
|
165
|
+
assert.equal(updated.description, "Docs platform and release content");
|
|
166
|
+
assert.deepEqual(updated.keywords, ["docs", "release", "guides"]);
|
|
167
|
+
assert.notEqual(updated.updatedAt, created.updatedAt);
|
|
168
|
+
const inactive = memoryModule.deactivateScope(created.id);
|
|
169
|
+
assert.equal(inactive.active, false);
|
|
170
|
+
assert.deepEqual(inactive.keywords, ["docs", "release", "guides"]);
|
|
171
|
+
assert.deepEqual(memoryModule.getScope(created.id), inactive);
|
|
172
|
+
const storedKeywords = dbModule.getDb()
|
|
173
|
+
.prepare(`SELECT keywords FROM mem_scopes WHERE id = ?`)
|
|
174
|
+
.get(created.id).keywords;
|
|
175
|
+
assert.deepEqual(JSON.parse(storedKeywords), ["docs", "release", "guides"]);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
dbModule.closeDb();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test("createScope enforces unique slugs", async () => {
|
|
182
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
183
|
+
try {
|
|
184
|
+
memoryModule.createScope({
|
|
185
|
+
slug: "shared",
|
|
186
|
+
title: "Shared",
|
|
187
|
+
description: "Shared scope",
|
|
188
|
+
keywords: ["shared"],
|
|
189
|
+
});
|
|
190
|
+
assert.throws(() => memoryModule.createScope({
|
|
191
|
+
slug: "shared",
|
|
192
|
+
title: "Shared Again",
|
|
193
|
+
description: "Duplicate scope",
|
|
194
|
+
keywords: ["duplicate"],
|
|
195
|
+
}), /UNIQUE|constraint/i);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
dbModule.closeDb();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
//# sourceMappingURL=scopes.test.js.map
|