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.
- 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 +32 -6
- package/dist/copilot/agents.test.js +41 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +224 -3
- package/dist/copilot/orchestrator.test.js +380 -0
- package/dist/copilot/prompt-date.js +8 -0
- package/dist/copilot/system-message.js +8 -0
- package/dist/copilot/system-message.test.js +58 -0
- package/dist/copilot/tools.agent.test.js +24 -0
- package/dist/copilot/tools.js +351 -4
- package/dist/copilot/tools.memory.test.js +297 -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 +210 -0
- package/dist/memory/recall.test.js +238 -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,352 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { getDb } from "../store/db.js";
|
|
4
|
+
import { childLogger } from "../util/logger.js";
|
|
5
|
+
import { getActiveScope } from "./active-scope.js";
|
|
6
|
+
import { listScopes } from "./scopes.js";
|
|
7
|
+
import { tieringPass } from "./tiering.js";
|
|
8
|
+
export { tieringPass };
|
|
9
|
+
const log = childLogger("memory.housekeeping");
|
|
10
|
+
const SIMILARITY_THRESHOLD = 0.8;
|
|
11
|
+
const inFlightKeys = new Set();
|
|
12
|
+
const PASS_ORDER = [
|
|
13
|
+
"dedup_observations",
|
|
14
|
+
"dedup_decisions",
|
|
15
|
+
"orphan_cleanup",
|
|
16
|
+
"decay",
|
|
17
|
+
"compact_inbox",
|
|
18
|
+
"tiering",
|
|
19
|
+
];
|
|
20
|
+
function passSummary(pass, examined = 0, modified = 0, errors = []) {
|
|
21
|
+
return { pass, examined, modified, errors };
|
|
22
|
+
}
|
|
23
|
+
function tokens(value) {
|
|
24
|
+
const words = value
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.split(/[^a-z0-9]+/u)
|
|
27
|
+
.map((word) => word.replace(/s$/, ""))
|
|
28
|
+
.filter((word) => word.length > 1);
|
|
29
|
+
return new Set(words);
|
|
30
|
+
}
|
|
31
|
+
function jaccard(left, right) {
|
|
32
|
+
const leftTokens = tokens(left);
|
|
33
|
+
const rightTokens = tokens(right);
|
|
34
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
let intersection = 0;
|
|
38
|
+
for (const token of leftTokens) {
|
|
39
|
+
if (rightTokens.has(token)) {
|
|
40
|
+
intersection++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const union = leftTokens.size + rightTokens.size - intersection;
|
|
44
|
+
return union === 0 ? 0 : intersection / union;
|
|
45
|
+
}
|
|
46
|
+
function isSimilar(left, right) {
|
|
47
|
+
if (left.trim().toLowerCase() === right.trim().toLowerCase()) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return jaccard(left, right) >= SIMILARITY_THRESHOLD;
|
|
51
|
+
}
|
|
52
|
+
function compareObservationKeeper(left, right) {
|
|
53
|
+
if (right.confidence !== left.confidence) {
|
|
54
|
+
return right.confidence - left.confidence;
|
|
55
|
+
}
|
|
56
|
+
if (right.created_at !== left.created_at) {
|
|
57
|
+
return right.created_at.localeCompare(left.created_at);
|
|
58
|
+
}
|
|
59
|
+
return left.id - right.id;
|
|
60
|
+
}
|
|
61
|
+
function compareDecisionKeeper(left, right) {
|
|
62
|
+
if (right.decided_at !== left.decided_at) {
|
|
63
|
+
return right.decided_at.localeCompare(left.decided_at);
|
|
64
|
+
}
|
|
65
|
+
if (right.created_at !== left.created_at) {
|
|
66
|
+
return right.created_at.localeCompare(left.created_at);
|
|
67
|
+
}
|
|
68
|
+
return left.id - right.id;
|
|
69
|
+
}
|
|
70
|
+
function normalizePassName(pass) {
|
|
71
|
+
const normalized = pass.trim().toLowerCase().replace(/-/g, "_");
|
|
72
|
+
const aliases = {
|
|
73
|
+
dedup_observations: "dedup_observations",
|
|
74
|
+
dedupobservations: "dedup_observations",
|
|
75
|
+
dedupobservationspass: "dedup_observations",
|
|
76
|
+
observations: "dedup_observations",
|
|
77
|
+
dedup_decisions: "dedup_decisions",
|
|
78
|
+
dedupdecisions: "dedup_decisions",
|
|
79
|
+
dedupdecisionspass: "dedup_decisions",
|
|
80
|
+
decisions: "dedup_decisions",
|
|
81
|
+
orphan_cleanup: "orphan_cleanup",
|
|
82
|
+
orphancleanup: "orphan_cleanup",
|
|
83
|
+
orphancleanuppass: "orphan_cleanup",
|
|
84
|
+
orphans: "orphan_cleanup",
|
|
85
|
+
decay: "decay",
|
|
86
|
+
decaypass: "decay",
|
|
87
|
+
compact_inbox: "compact_inbox",
|
|
88
|
+
compactinbox: "compact_inbox",
|
|
89
|
+
compactinboxpass: "compact_inbox",
|
|
90
|
+
inbox: "compact_inbox",
|
|
91
|
+
tiering: "tiering",
|
|
92
|
+
tieringpass: "tiering",
|
|
93
|
+
tiers: "tiering",
|
|
94
|
+
};
|
|
95
|
+
const resolved = aliases[normalized];
|
|
96
|
+
if (!resolved) {
|
|
97
|
+
throw new Error(`Unknown housekeeping pass '${pass}'. Valid passes: ${PASS_ORDER.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
export function dedupObservationsPass(scopeId) {
|
|
102
|
+
try {
|
|
103
|
+
const db = getDb();
|
|
104
|
+
const candidates = db.prepare(`
|
|
105
|
+
SELECT id, content, confidence, created_at
|
|
106
|
+
FROM mem_observations
|
|
107
|
+
WHERE scope_id = ?
|
|
108
|
+
AND superseded_by IS NULL
|
|
109
|
+
AND archived_at IS NULL
|
|
110
|
+
ORDER BY id ASC
|
|
111
|
+
`).all(scopeId);
|
|
112
|
+
let modified = 0;
|
|
113
|
+
const visited = new Set();
|
|
114
|
+
const update = db.prepare(`UPDATE mem_observations SET superseded_by = ? WHERE id = ? AND superseded_by IS NULL`);
|
|
115
|
+
const tx = db.transaction(() => {
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
if (visited.has(candidate.id)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const cluster = candidates.filter((entry) => !visited.has(entry.id) && isSimilar(candidate.content, entry.content));
|
|
121
|
+
for (const entry of cluster) {
|
|
122
|
+
visited.add(entry.id);
|
|
123
|
+
}
|
|
124
|
+
if (cluster.length < 2) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const [keeper] = cluster.sort(compareObservationKeeper);
|
|
128
|
+
if (!keeper) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of cluster) {
|
|
132
|
+
if (entry.id === keeper.id) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
modified += update.run(keeper.id, entry.id).changes;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
tx();
|
|
140
|
+
return passSummary("dedupObservationsPass", candidates.length, modified);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return passSummary("dedupObservationsPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function dedupDecisionsPass(scopeId) {
|
|
147
|
+
try {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
const candidates = db.prepare(`
|
|
150
|
+
SELECT id, entity_id, title, decided_at, created_at
|
|
151
|
+
FROM mem_decisions
|
|
152
|
+
WHERE scope_id = ?
|
|
153
|
+
AND superseded_by IS NULL
|
|
154
|
+
AND archived_at IS NULL
|
|
155
|
+
ORDER BY id ASC
|
|
156
|
+
`).all(scopeId);
|
|
157
|
+
let modified = 0;
|
|
158
|
+
const update = db.prepare(`UPDATE mem_decisions SET superseded_by = ? WHERE id = ? AND superseded_by IS NULL`);
|
|
159
|
+
const tx = db.transaction(() => {
|
|
160
|
+
const visited = new Set();
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
if (visited.has(candidate.id)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const cluster = candidates.filter((entry) => !visited.has(entry.id) && isSimilar(candidate.title, entry.title));
|
|
166
|
+
for (const entry of cluster) {
|
|
167
|
+
visited.add(entry.id);
|
|
168
|
+
}
|
|
169
|
+
if (cluster.length < 2) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const [keeper] = cluster.sort(compareDecisionKeeper);
|
|
173
|
+
if (!keeper) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const entry of cluster) {
|
|
177
|
+
if (entry.id === keeper.id) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
modified += update.run(keeper.id, entry.id).changes;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
tx();
|
|
185
|
+
return passSummary("dedupDecisionsPass", candidates.length, modified);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return passSummary("dedupDecisionsPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export function orphanCleanupPass(scopeId) {
|
|
192
|
+
try {
|
|
193
|
+
const db = getDb();
|
|
194
|
+
const orphanIds = db.prepare(`
|
|
195
|
+
SELECT o.id
|
|
196
|
+
FROM mem_observations o
|
|
197
|
+
LEFT JOIN mem_entities e ON e.id = o.entity_id
|
|
198
|
+
WHERE o.scope_id = ?
|
|
199
|
+
AND o.entity_id IS NOT NULL
|
|
200
|
+
AND e.id IS NULL
|
|
201
|
+
ORDER BY o.id ASC
|
|
202
|
+
`).all(scopeId);
|
|
203
|
+
const tx = db.transaction(() => db.prepare(`
|
|
204
|
+
UPDATE mem_observations
|
|
205
|
+
SET entity_id = NULL
|
|
206
|
+
WHERE scope_id = ?
|
|
207
|
+
AND entity_id IS NOT NULL
|
|
208
|
+
AND NOT EXISTS (SELECT 1 FROM mem_entities e WHERE e.id = mem_observations.entity_id)
|
|
209
|
+
`).run(scopeId).changes);
|
|
210
|
+
const modified = tx();
|
|
211
|
+
if (modified > 0) {
|
|
212
|
+
log.info({ scope_id: scopeId, count: modified }, "memory.housekeeping.orphan_cleanup");
|
|
213
|
+
}
|
|
214
|
+
return passSummary("orphanCleanupPass", orphanIds.length, modified);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return passSummary("orphanCleanupPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function decayPass(scopeId) {
|
|
221
|
+
try {
|
|
222
|
+
const db = getDb();
|
|
223
|
+
const candidateIds = db.prepare(`
|
|
224
|
+
SELECT id
|
|
225
|
+
FROM mem_observations
|
|
226
|
+
WHERE scope_id = ?
|
|
227
|
+
AND superseded_by IS NULL
|
|
228
|
+
AND archived_at IS NULL
|
|
229
|
+
AND confidence < 0.3
|
|
230
|
+
AND datetime(created_at) < datetime('now', ?)
|
|
231
|
+
ORDER BY id ASC
|
|
232
|
+
`).all(scopeId, `-${config.memoryDecayDays} days`);
|
|
233
|
+
const tx = db.transaction(() => db.prepare(`
|
|
234
|
+
UPDATE mem_observations
|
|
235
|
+
SET archived_at = CURRENT_TIMESTAMP
|
|
236
|
+
WHERE scope_id = ?
|
|
237
|
+
AND superseded_by IS NULL
|
|
238
|
+
AND archived_at IS NULL
|
|
239
|
+
AND confidence < 0.3
|
|
240
|
+
AND datetime(created_at) < datetime('now', ?)
|
|
241
|
+
`).run(scopeId, `-${config.memoryDecayDays} days`).changes);
|
|
242
|
+
const modified = tx();
|
|
243
|
+
return passSummary("decayPass", candidateIds.length, modified);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
return passSummary("decayPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export function compactInboxPass() {
|
|
250
|
+
try {
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const candidateIds = db.prepare(`
|
|
253
|
+
SELECT id
|
|
254
|
+
FROM mem_inbox
|
|
255
|
+
WHERE status IN ('accepted', 'rejected')
|
|
256
|
+
AND resolved_at IS NOT NULL
|
|
257
|
+
AND datetime(resolved_at) < datetime('now', ?)
|
|
258
|
+
ORDER BY id ASC
|
|
259
|
+
`).all(`-${config.memoryInboxRetentionDays} days`);
|
|
260
|
+
const tx = db.transaction(() => db.prepare(`
|
|
261
|
+
DELETE FROM mem_inbox
|
|
262
|
+
WHERE status IN ('accepted', 'rejected')
|
|
263
|
+
AND resolved_at IS NOT NULL
|
|
264
|
+
AND datetime(resolved_at) < datetime('now', ?)
|
|
265
|
+
`).run(`-${config.memoryInboxRetentionDays} days`).changes);
|
|
266
|
+
const modified = tx();
|
|
267
|
+
return passSummary("compactInboxPass", candidateIds.length, modified);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return passSummary("compactInboxPass", 0, 0, [error instanceof Error ? error.message : String(error)]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function resolveScopeIds(input) {
|
|
274
|
+
if (input?.scopeIds && input.scopeIds.length > 0) {
|
|
275
|
+
return [...new Set(input.scopeIds)];
|
|
276
|
+
}
|
|
277
|
+
if (input?.allScopes) {
|
|
278
|
+
return listScopes().filter((scope) => scope.active).map((scope) => scope.id);
|
|
279
|
+
}
|
|
280
|
+
const activeScope = getActiveScope();
|
|
281
|
+
return activeScope ? [activeScope.id] : [];
|
|
282
|
+
}
|
|
283
|
+
function runPass(pass, scopeId) {
|
|
284
|
+
switch (pass) {
|
|
285
|
+
case "dedup_observations":
|
|
286
|
+
return dedupObservationsPass(scopeId);
|
|
287
|
+
case "dedup_decisions":
|
|
288
|
+
return dedupDecisionsPass(scopeId);
|
|
289
|
+
case "orphan_cleanup":
|
|
290
|
+
return orphanCleanupPass(scopeId);
|
|
291
|
+
case "decay":
|
|
292
|
+
return decayPass(scopeId);
|
|
293
|
+
case "compact_inbox":
|
|
294
|
+
return compactInboxPass();
|
|
295
|
+
case "tiering":
|
|
296
|
+
return tieringPass(scopeId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function inFlightKey(scopeIds, passes) {
|
|
300
|
+
return `${scopeIds.join(",") || "none"}:${passes.join(",")}`;
|
|
301
|
+
}
|
|
302
|
+
export function isHousekeepingInFlight(scopeIds, passes) {
|
|
303
|
+
if (!scopeIds || scopeIds.length === 0) {
|
|
304
|
+
return inFlightKeys.size > 0;
|
|
305
|
+
}
|
|
306
|
+
const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
|
|
307
|
+
return inFlightKeys.has(inFlightKey([...new Set(scopeIds)].sort((a, b) => a - b), normalizedPasses));
|
|
308
|
+
}
|
|
309
|
+
export function runHousekeeping(opts = {}) {
|
|
310
|
+
const started = performance.now();
|
|
311
|
+
const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
|
|
312
|
+
const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
|
|
313
|
+
const key = inFlightKey(scopeIds, passes);
|
|
314
|
+
if (inFlightKeys.has(key)) {
|
|
315
|
+
return {
|
|
316
|
+
scopeIds,
|
|
317
|
+
summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
|
|
318
|
+
totalExamined: 0,
|
|
319
|
+
totalModified: 0,
|
|
320
|
+
durationMs: 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
inFlightKeys.add(key);
|
|
324
|
+
const summaries = [];
|
|
325
|
+
try {
|
|
326
|
+
const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
|
|
327
|
+
const hasCompactInbox = passes.includes("compact_inbox");
|
|
328
|
+
for (const scopeId of scopeIds) {
|
|
329
|
+
for (const pass of scopedPasses) {
|
|
330
|
+
summaries.push(runPass(pass, scopeId));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (hasCompactInbox) {
|
|
334
|
+
summaries.push(compactInboxPass());
|
|
335
|
+
}
|
|
336
|
+
const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
|
|
337
|
+
const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
|
|
338
|
+
const durationMs = Math.round(performance.now() - started);
|
|
339
|
+
log.info({
|
|
340
|
+
passes_run: summaries.map((summary) => summary.pass),
|
|
341
|
+
total_examined: totalExamined,
|
|
342
|
+
total_modified: totalModified,
|
|
343
|
+
duration_ms: durationMs,
|
|
344
|
+
scope_ids: scopeIds,
|
|
345
|
+
}, "memory.housekeeping.run");
|
|
346
|
+
return { scopeIds, summaries, totalExamined, totalModified, durationMs };
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
inFlightKeys.delete(key);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=housekeeping.js.map
|
|
@@ -0,0 +1,280 @@
|
|
|
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-housekeeping-${process.pid}`);
|
|
7
|
+
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
process.env.CHAPTERHOUSE_MEMORY_DECAY_DAYS = "30";
|
|
10
|
+
process.env.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS = "7";
|
|
11
|
+
function resetSandbox() {
|
|
12
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
13
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
14
|
+
mkdirSync(chapterhouseHome, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
async function loadModules() {
|
|
17
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
18
|
+
const memoryModule = await import(new URL("./index.js", import.meta.url).href);
|
|
19
|
+
const housekeepingModule = await import(new URL("./housekeeping.js", import.meta.url).href);
|
|
20
|
+
return { dbModule, memoryModule, housekeepingModule };
|
|
21
|
+
}
|
|
22
|
+
function getFunction(module, name) {
|
|
23
|
+
const value = module[name];
|
|
24
|
+
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
test.beforeEach(async () => {
|
|
28
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
29
|
+
dbModule.closeDb();
|
|
30
|
+
resetSandbox();
|
|
31
|
+
});
|
|
32
|
+
test.after(async () => {
|
|
33
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
34
|
+
dbModule.closeDb();
|
|
35
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
36
|
+
delete process.env.CHAPTERHOUSE_MEMORY_DECAY_DAYS;
|
|
37
|
+
delete process.env.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS;
|
|
38
|
+
});
|
|
39
|
+
test("dedupObservationsPass supersedes similar observations in scope deterministically and is idempotent", async () => {
|
|
40
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
41
|
+
const db = dbModule.getDb();
|
|
42
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
43
|
+
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
44
|
+
const chapterhouse = getScope("chapterhouse");
|
|
45
|
+
const team = getScope("team");
|
|
46
|
+
assert.ok(chapterhouse && team);
|
|
47
|
+
const first = recordObservation({
|
|
48
|
+
scope_id: chapterhouse.id,
|
|
49
|
+
content: "The worker event stream uses server sent events for live task output.",
|
|
50
|
+
source: "test",
|
|
51
|
+
confidence: 0.4,
|
|
52
|
+
});
|
|
53
|
+
const keeper = recordObservation({
|
|
54
|
+
scope_id: chapterhouse.id,
|
|
55
|
+
content: "Worker event streams use server sent events for live task output.",
|
|
56
|
+
source: "test",
|
|
57
|
+
confidence: 0.9,
|
|
58
|
+
});
|
|
59
|
+
const third = recordObservation({
|
|
60
|
+
scope_id: chapterhouse.id,
|
|
61
|
+
content: "The worker event stream uses server sent events for live task output today.",
|
|
62
|
+
source: "test",
|
|
63
|
+
confidence: 0.9,
|
|
64
|
+
});
|
|
65
|
+
const otherScope = recordObservation({
|
|
66
|
+
scope_id: team.id,
|
|
67
|
+
content: "Worker event streams use server sent events for live task output.",
|
|
68
|
+
source: "test",
|
|
69
|
+
confidence: 0.1,
|
|
70
|
+
});
|
|
71
|
+
const summary = housekeepingModule.dedupObservationsPass(chapterhouse.id);
|
|
72
|
+
assert.equal(summary.pass, "dedupObservationsPass");
|
|
73
|
+
assert.equal(summary.examined, 3);
|
|
74
|
+
assert.equal(summary.modified, 2);
|
|
75
|
+
assert.deepEqual(summary.errors, []);
|
|
76
|
+
assert.deepEqual(db.prepare(`SELECT id, superseded_by FROM mem_observations WHERE id IN (?, ?, ?) ORDER BY id`).all(first.id, keeper.id, third.id), [
|
|
77
|
+
{ id: first.id, superseded_by: keeper.id },
|
|
78
|
+
{ id: keeper.id, superseded_by: null },
|
|
79
|
+
{ id: third.id, superseded_by: keeper.id },
|
|
80
|
+
]);
|
|
81
|
+
assert.equal(db.prepare(`SELECT superseded_by FROM mem_observations WHERE id = ?`).get(otherScope.id).superseded_by, null);
|
|
82
|
+
const second = housekeepingModule.dedupObservationsPass(chapterhouse.id);
|
|
83
|
+
assert.equal(second.modified, 0);
|
|
84
|
+
});
|
|
85
|
+
test("dedupDecisionsPass supersedes similar active decisions within scope and keeps the latest decision", async () => {
|
|
86
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
87
|
+
const db = dbModule.getDb();
|
|
88
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
89
|
+
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
90
|
+
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
91
|
+
const chapterhouse = getScope("chapterhouse");
|
|
92
|
+
const team = getScope("team");
|
|
93
|
+
assert.ok(chapterhouse && team);
|
|
94
|
+
const api = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "api" });
|
|
95
|
+
const web = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "web" });
|
|
96
|
+
const oldDecision = recordDecision({
|
|
97
|
+
scope_id: chapterhouse.id,
|
|
98
|
+
entity_id: api.id,
|
|
99
|
+
title: "Use SQLite FTS5 for memory recall",
|
|
100
|
+
rationale: "Initial choice.",
|
|
101
|
+
decided_at: "2026-05-11",
|
|
102
|
+
});
|
|
103
|
+
const keeper = recordDecision({
|
|
104
|
+
scope_id: chapterhouse.id,
|
|
105
|
+
entity_id: api.id,
|
|
106
|
+
title: "Use SQLite FTS5 for scoped memory recall",
|
|
107
|
+
rationale: "Latest choice.",
|
|
108
|
+
decided_at: "2026-05-13",
|
|
109
|
+
});
|
|
110
|
+
const otherEntity = recordDecision({
|
|
111
|
+
scope_id: chapterhouse.id,
|
|
112
|
+
entity_id: web.id,
|
|
113
|
+
title: "Use SQLite FTS5 for memory recall",
|
|
114
|
+
rationale: "Same title, different entity context, but newer scope-level decision.",
|
|
115
|
+
decided_at: "2026-05-14",
|
|
116
|
+
});
|
|
117
|
+
const otherScope = recordDecision({
|
|
118
|
+
scope_id: team.id,
|
|
119
|
+
title: "Use SQLite FTS5 for memory recall",
|
|
120
|
+
rationale: "Same title, different scope.",
|
|
121
|
+
decided_at: "2026-05-14",
|
|
122
|
+
});
|
|
123
|
+
const summary = housekeepingModule.dedupDecisionsPass(chapterhouse.id);
|
|
124
|
+
assert.equal(summary.pass, "dedupDecisionsPass");
|
|
125
|
+
assert.equal(summary.examined, 3);
|
|
126
|
+
assert.equal(summary.modified, 2);
|
|
127
|
+
assert.deepEqual(summary.errors, []);
|
|
128
|
+
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(oldDecision.id).superseded_by, otherEntity.id);
|
|
129
|
+
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(keeper.id).superseded_by, otherEntity.id);
|
|
130
|
+
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherEntity.id).superseded_by, null);
|
|
131
|
+
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherScope.id).superseded_by, null);
|
|
132
|
+
const second = housekeepingModule.dedupDecisionsPass(chapterhouse.id);
|
|
133
|
+
assert.equal(second.modified, 0);
|
|
134
|
+
});
|
|
135
|
+
test("orphanCleanupPass clears missing observation entity references without touching valid or out-of-scope rows", async () => {
|
|
136
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
137
|
+
const db = dbModule.getDb();
|
|
138
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
139
|
+
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
140
|
+
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
141
|
+
const chapterhouse = getScope("chapterhouse");
|
|
142
|
+
const team = getScope("team");
|
|
143
|
+
assert.ok(chapterhouse && team);
|
|
144
|
+
const entity = upsertEntity({ scope_id: chapterhouse.id, kind: "tool", name: "sqlite" });
|
|
145
|
+
const valid = recordObservation({ scope_id: chapterhouse.id, entity_id: entity.id, content: "Valid entity reference", source: "test" });
|
|
146
|
+
const orphan = recordObservation({ scope_id: chapterhouse.id, content: "Will become orphaned", source: "test" });
|
|
147
|
+
const otherScope = recordObservation({ scope_id: team.id, content: "Other scope orphan", source: "test" });
|
|
148
|
+
db.pragma("foreign_keys = OFF");
|
|
149
|
+
db.prepare(`UPDATE mem_observations SET entity_id = 987654 WHERE id IN (?, ?)`).run(orphan.id, otherScope.id);
|
|
150
|
+
db.pragma("foreign_keys = ON");
|
|
151
|
+
const summary = housekeepingModule.orphanCleanupPass(chapterhouse.id);
|
|
152
|
+
assert.equal(summary.pass, "orphanCleanupPass");
|
|
153
|
+
assert.equal(summary.examined, 1);
|
|
154
|
+
assert.equal(summary.modified, 1);
|
|
155
|
+
assert.equal(db.prepare(`SELECT entity_id FROM mem_observations WHERE id = ?`).get(orphan.id).entity_id, null);
|
|
156
|
+
assert.equal(db.prepare(`SELECT entity_id FROM mem_observations WHERE id = ?`).get(valid.id).entity_id, entity.id);
|
|
157
|
+
assert.equal(db.prepare(`SELECT entity_id FROM mem_observations WHERE id = ?`).get(otherScope.id).entity_id, 987654);
|
|
158
|
+
const second = housekeepingModule.orphanCleanupPass(chapterhouse.id);
|
|
159
|
+
assert.equal(second.modified, 0);
|
|
160
|
+
});
|
|
161
|
+
test("decayPass archives old low-confidence observations only in scope and compactInboxPass removes resolved inbox rows after retention", async () => {
|
|
162
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
163
|
+
const db = dbModule.getDb();
|
|
164
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
165
|
+
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
166
|
+
const chapterhouse = getScope("chapterhouse");
|
|
167
|
+
const team = getScope("team");
|
|
168
|
+
assert.ok(chapterhouse && team);
|
|
169
|
+
const archiveMe = recordObservation({ scope_id: chapterhouse.id, content: "Old low confidence", source: "test", confidence: 0.2 });
|
|
170
|
+
const highConfidence = recordObservation({ scope_id: chapterhouse.id, content: "Old high confidence", source: "test", confidence: 0.9 });
|
|
171
|
+
const fresh = recordObservation({ scope_id: chapterhouse.id, content: "Fresh low confidence", source: "test", confidence: 0.2 });
|
|
172
|
+
const otherScope = recordObservation({ scope_id: team.id, content: "Other scope old low confidence", source: "test", confidence: 0.2 });
|
|
173
|
+
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?, ?)`).run(archiveMe.id, highConfidence.id, otherScope.id);
|
|
174
|
+
const decay = housekeepingModule.decayPass(chapterhouse.id);
|
|
175
|
+
assert.equal(decay.pass, "decayPass");
|
|
176
|
+
assert.equal(decay.examined, 1);
|
|
177
|
+
assert.equal(decay.modified, 1);
|
|
178
|
+
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(archiveMe.id).archived_at);
|
|
179
|
+
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(highConfidence.id).archived_at, null);
|
|
180
|
+
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(fresh.id).archived_at, null);
|
|
181
|
+
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(otherScope.id).archived_at, null);
|
|
182
|
+
assert.equal(housekeepingModule.decayPass(chapterhouse.id).modified, 0);
|
|
183
|
+
db.prepare(`
|
|
184
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at, resolved_at)
|
|
185
|
+
VALUES
|
|
186
|
+
(?, 'memory_proposal', '{}', 'test', 'accepted', datetime('now', '-20 days'), datetime('now', '-8 days')),
|
|
187
|
+
(?, 'memory_proposal', '{}', 'test', 'rejected', datetime('now', '-20 days'), datetime('now', '-6 days')),
|
|
188
|
+
(?, 'memory_proposal', '{}', 'test', 'pending', datetime('now', '-20 days'), NULL)
|
|
189
|
+
`).run(chapterhouse.id, chapterhouse.id, chapterhouse.id);
|
|
190
|
+
const compact = housekeepingModule.compactInboxPass();
|
|
191
|
+
assert.equal(compact.pass, "compactInboxPass");
|
|
192
|
+
assert.equal(compact.examined, 1);
|
|
193
|
+
assert.equal(compact.modified, 1);
|
|
194
|
+
assert.deepEqual(db.prepare(`SELECT status FROM mem_inbox ORDER BY id`).all(), [{ status: "rejected" }, { status: "pending" }]);
|
|
195
|
+
assert.equal(housekeepingModule.compactInboxPass().modified, 0);
|
|
196
|
+
});
|
|
197
|
+
test("runHousekeeping defaults to the active scope and can target all active scopes", async () => {
|
|
198
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
199
|
+
const db = dbModule.getDb();
|
|
200
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
201
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
202
|
+
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
203
|
+
const chapterhouse = getScope("chapterhouse");
|
|
204
|
+
const team = getScope("team");
|
|
205
|
+
assert.ok(chapterhouse && team);
|
|
206
|
+
const chapterhouseOld = recordObservation({ scope_id: chapterhouse.id, content: "Chapterhouse old low", source: "test", confidence: 0.1 });
|
|
207
|
+
const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
|
|
208
|
+
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
|
|
209
|
+
setActiveScope("chapterhouse");
|
|
210
|
+
const activeOnly = housekeepingModule.runHousekeeping({ passes: ["decay"] });
|
|
211
|
+
assert.deepEqual(activeOnly.scopeIds, [chapterhouse.id]);
|
|
212
|
+
assert.equal(activeOnly.summaries.length, 1);
|
|
213
|
+
assert.equal(activeOnly.summaries[0]?.modified, 1);
|
|
214
|
+
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(chapterhouseOld.id).archived_at);
|
|
215
|
+
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at, null);
|
|
216
|
+
const allScopes = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
|
|
217
|
+
assert.ok(allScopes.scopeIds.includes(team.id));
|
|
218
|
+
assert.equal(allScopes.summaries.some((summary) => summary.modified === 1), true);
|
|
219
|
+
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at);
|
|
220
|
+
});
|
|
221
|
+
test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
|
|
222
|
+
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
223
|
+
const db = dbModule.getDb();
|
|
224
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
225
|
+
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
226
|
+
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
227
|
+
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
228
|
+
const chapterhouse = getScope("chapterhouse");
|
|
229
|
+
assert.ok(chapterhouse);
|
|
230
|
+
const entity = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "memory", tier: "warm" });
|
|
231
|
+
const referencedObservation = recordObservation({
|
|
232
|
+
scope_id: chapterhouse.id,
|
|
233
|
+
entity_id: entity.id,
|
|
234
|
+
content: "Referenced by a recent decision through its entity.",
|
|
235
|
+
source: "test",
|
|
236
|
+
tier: "warm",
|
|
237
|
+
});
|
|
238
|
+
const recentDecision = recordDecision({
|
|
239
|
+
scope_id: chapterhouse.id,
|
|
240
|
+
entity_id: entity.id,
|
|
241
|
+
title: "Restate memory tiering decision",
|
|
242
|
+
rationale: "Recent entity-linked decisions keep related observations hot.",
|
|
243
|
+
decided_at: new Date().toISOString(),
|
|
244
|
+
tier: "warm",
|
|
245
|
+
});
|
|
246
|
+
const oldHot = recordObservation({
|
|
247
|
+
scope_id: chapterhouse.id,
|
|
248
|
+
content: "Old hot row with no recall activity should cool down.",
|
|
249
|
+
source: "test",
|
|
250
|
+
tier: "hot",
|
|
251
|
+
});
|
|
252
|
+
const staleLowConfidence = recordObservation({
|
|
253
|
+
scope_id: chapterhouse.id,
|
|
254
|
+
content: "Low confidence stale row should go cold.",
|
|
255
|
+
source: "test",
|
|
256
|
+
tier: "warm",
|
|
257
|
+
confidence: 0.2,
|
|
258
|
+
});
|
|
259
|
+
const archived = recordObservation({
|
|
260
|
+
scope_id: chapterhouse.id,
|
|
261
|
+
content: "Archived row should always be cold.",
|
|
262
|
+
source: "test",
|
|
263
|
+
tier: "hot",
|
|
264
|
+
});
|
|
265
|
+
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-45 days') WHERE id = ?`).run(oldHot.id);
|
|
266
|
+
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-61 days') WHERE id = ?`).run(staleLowConfidence.id);
|
|
267
|
+
db.prepare(`UPDATE mem_observations SET archived_at = CURRENT_TIMESTAMP WHERE id = ?`).run(archived.id);
|
|
268
|
+
const summary = housekeepingModule.tieringPass(chapterhouse.id);
|
|
269
|
+
assert.equal(summary.pass, "tieringPass");
|
|
270
|
+
assert.equal(summary.modified, 5);
|
|
271
|
+
assert.deepEqual(summary.errors, []);
|
|
272
|
+
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(referencedObservation.id).tier, "hot");
|
|
273
|
+
assert.equal(db.prepare(`SELECT tier FROM mem_decisions WHERE id = ?`).get(recentDecision.id).tier, "hot");
|
|
274
|
+
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(oldHot.id).tier, "warm");
|
|
275
|
+
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(staleLowConfidence.id).tier, "cold");
|
|
276
|
+
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(archived.id).tier, "cold");
|
|
277
|
+
const second = housekeepingModule.tieringPass(chapterhouse.id);
|
|
278
|
+
assert.equal(second.modified, 0);
|
|
279
|
+
});
|
|
280
|
+
//# sourceMappingURL=housekeeping.test.js.map
|