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,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