chapterhouse 0.5.1 → 0.5.2
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 +5 -3
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +44 -0
- package/dist/copilot/orchestrator.js +2 -5
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +30 -18
- package/dist/copilot/tools.js +4 -2
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +95 -3
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +152 -95
- package/dist/setup.test.js +122 -0
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
- package/web/dist/assets/index-CPaILy2j.js.map +1 -0
- package/web/dist/assets/index-Cs7AGeaL.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
|
@@ -8,7 +8,8 @@ import { tieringPass } from "./tiering.js";
|
|
|
8
8
|
export { tieringPass };
|
|
9
9
|
const log = childLogger("memory.housekeeping");
|
|
10
10
|
const SIMILARITY_THRESHOLD = 0.8;
|
|
11
|
-
const
|
|
11
|
+
const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
|
|
12
|
+
const inFlightScopesByPass = new Map();
|
|
12
13
|
const PASS_ORDER = [
|
|
13
14
|
"dedup_observations",
|
|
14
15
|
"dedup_decisions",
|
|
@@ -280,38 +281,91 @@ function resolveScopeIds(input) {
|
|
|
280
281
|
const activeScope = getActiveScope();
|
|
281
282
|
return activeScope ? [activeScope.id] : [];
|
|
282
283
|
}
|
|
283
|
-
function
|
|
284
|
+
function getInFlightScopes(pass) {
|
|
285
|
+
let scopes = inFlightScopesByPass.get(pass);
|
|
286
|
+
if (!scopes) {
|
|
287
|
+
scopes = new Set();
|
|
288
|
+
inFlightScopesByPass.set(pass, scopes);
|
|
289
|
+
}
|
|
290
|
+
return scopes;
|
|
291
|
+
}
|
|
292
|
+
function getReservedPassScopes(scopeIds, passes) {
|
|
293
|
+
const reserved = [];
|
|
294
|
+
for (const pass of passes) {
|
|
295
|
+
if (pass === "compact_inbox") {
|
|
296
|
+
reserved.push({ pass, scope: GLOBAL_PASS_SCOPE });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
for (const scopeId of scopeIds) {
|
|
300
|
+
reserved.push({ pass, scope: scopeId });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return reserved;
|
|
304
|
+
}
|
|
305
|
+
function reservePassScopes(reserved) {
|
|
306
|
+
if (reserved.some(({ pass, scope }) => getInFlightScopes(pass).has(scope))) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
for (const { pass, scope } of reserved) {
|
|
310
|
+
getInFlightScopes(pass).add(scope);
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
function releasePassScopes(reserved) {
|
|
315
|
+
for (const { pass, scope } of reserved) {
|
|
316
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
317
|
+
scopes?.delete(scope);
|
|
318
|
+
if (scopes && scopes.size === 0) {
|
|
319
|
+
inFlightScopesByPass.delete(pass);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function runPass(pass, scopeId) {
|
|
284
324
|
switch (pass) {
|
|
285
325
|
case "dedup_observations":
|
|
286
|
-
return dedupObservationsPass(scopeId);
|
|
326
|
+
return await Promise.resolve(dedupObservationsPass(scopeId));
|
|
287
327
|
case "dedup_decisions":
|
|
288
|
-
return dedupDecisionsPass(scopeId);
|
|
328
|
+
return await Promise.resolve(dedupDecisionsPass(scopeId));
|
|
289
329
|
case "orphan_cleanup":
|
|
290
|
-
return orphanCleanupPass(scopeId);
|
|
330
|
+
return await Promise.resolve(orphanCleanupPass(scopeId));
|
|
291
331
|
case "decay":
|
|
292
|
-
return decayPass(scopeId);
|
|
332
|
+
return await Promise.resolve(decayPass(scopeId));
|
|
293
333
|
case "compact_inbox":
|
|
294
|
-
return compactInboxPass();
|
|
334
|
+
return await Promise.resolve(compactInboxPass());
|
|
295
335
|
case "tiering":
|
|
296
|
-
return tieringPass(scopeId);
|
|
336
|
+
return await Promise.resolve(tieringPass(scopeId));
|
|
297
337
|
}
|
|
298
338
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
339
|
+
async function runScopePasses(scopeId, passes) {
|
|
340
|
+
const summaries = [];
|
|
341
|
+
for (const pass of passes) {
|
|
342
|
+
summaries.push(await runPass(pass, scopeId));
|
|
343
|
+
}
|
|
344
|
+
return summaries;
|
|
301
345
|
}
|
|
302
346
|
export function isHousekeepingInFlight(scopeIds, passes) {
|
|
347
|
+
const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
|
|
303
348
|
if (!scopeIds || scopeIds.length === 0) {
|
|
304
|
-
return
|
|
349
|
+
return normalizedPasses.some((pass) => (inFlightScopesByPass.get(pass)?.size ?? 0) > 0);
|
|
305
350
|
}
|
|
306
|
-
const
|
|
307
|
-
return
|
|
351
|
+
const uniqueScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b);
|
|
352
|
+
return normalizedPasses.some((pass) => {
|
|
353
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
354
|
+
if (!scopes) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (pass === "compact_inbox") {
|
|
358
|
+
return scopes.has(GLOBAL_PASS_SCOPE);
|
|
359
|
+
}
|
|
360
|
+
return uniqueScopeIds.some((scopeId) => scopes.has(scopeId));
|
|
361
|
+
});
|
|
308
362
|
}
|
|
309
|
-
export function runHousekeeping(opts = {}) {
|
|
363
|
+
export async function runHousekeeping(opts = {}) {
|
|
310
364
|
const started = performance.now();
|
|
311
365
|
const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
|
|
312
366
|
const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
367
|
+
const reservedPassScopes = getReservedPassScopes(scopeIds, passes);
|
|
368
|
+
if (!reservePassScopes(reservedPassScopes)) {
|
|
315
369
|
return {
|
|
316
370
|
scopeIds,
|
|
317
371
|
summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
|
|
@@ -320,18 +374,12 @@ export function runHousekeeping(opts = {}) {
|
|
|
320
374
|
durationMs: 0,
|
|
321
375
|
};
|
|
322
376
|
}
|
|
323
|
-
inFlightKeys.add(key);
|
|
324
|
-
const summaries = [];
|
|
325
377
|
try {
|
|
326
378
|
const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
|
|
327
379
|
const hasCompactInbox = passes.includes("compact_inbox");
|
|
328
|
-
|
|
329
|
-
for (const pass of scopedPasses) {
|
|
330
|
-
summaries.push(runPass(pass, scopeId));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
380
|
+
const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
|
|
333
381
|
if (hasCompactInbox) {
|
|
334
|
-
summaries.push(
|
|
382
|
+
summaries.push(await runPass("compact_inbox", undefined));
|
|
335
383
|
}
|
|
336
384
|
const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
|
|
337
385
|
const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
|
|
@@ -346,7 +394,7 @@ export function runHousekeeping(opts = {}) {
|
|
|
346
394
|
return { scopeIds, summaries, totalExamined, totalModified, durationMs };
|
|
347
395
|
}
|
|
348
396
|
finally {
|
|
349
|
-
|
|
397
|
+
releasePassScopes(reservedPassScopes);
|
|
350
398
|
}
|
|
351
399
|
}
|
|
352
400
|
//# sourceMappingURL=housekeeping.js.map
|
|
@@ -16,9 +16,51 @@ function resetSandbox() {
|
|
|
16
16
|
async function loadModules() {
|
|
17
17
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
18
18
|
const memoryModule = await import(new URL("./index.js", import.meta.url).href);
|
|
19
|
-
const housekeepingModule = await import(new URL(
|
|
19
|
+
const housekeepingModule = await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
20
20
|
return { dbModule, memoryModule, housekeepingModule };
|
|
21
21
|
}
|
|
22
|
+
async function loadMockedHousekeepingModule(t, options = {}) {
|
|
23
|
+
t.mock.module("../config.js", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
config: {
|
|
26
|
+
memoryDecayDays: 30,
|
|
27
|
+
memoryInboxRetentionDays: 7,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
t.mock.module("../store/db.js", {
|
|
32
|
+
namedExports: {
|
|
33
|
+
getDb: () => {
|
|
34
|
+
throw new Error("getDb should not be called in this test");
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
t.mock.module("../util/logger.js", {
|
|
39
|
+
namedExports: {
|
|
40
|
+
childLogger: () => ({
|
|
41
|
+
info: () => { },
|
|
42
|
+
warn: () => { },
|
|
43
|
+
error: () => { },
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
t.mock.module("./active-scope.js", {
|
|
48
|
+
namedExports: {
|
|
49
|
+
getActiveScope: () => (options.activeScopeId === undefined ? null : { id: options.activeScopeId }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
t.mock.module("./scopes.js", {
|
|
53
|
+
namedExports: {
|
|
54
|
+
listScopes: () => options.scopes ?? [],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
t.mock.module("./tiering.js", {
|
|
58
|
+
namedExports: {
|
|
59
|
+
tieringPass: (scopeId) => options.tieringPass?.(scopeId) ?? { pass: "tieringPass", examined: scopeId, modified: 1, errors: [] },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return await import(new URL(`./housekeeping.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
63
|
+
}
|
|
22
64
|
function getFunction(module, name) {
|
|
23
65
|
const value = module[name];
|
|
24
66
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
@@ -215,17 +257,67 @@ test("runHousekeeping defaults to the active scope and can target all active sco
|
|
|
215
257
|
const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
|
|
216
258
|
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
|
|
217
259
|
setActiveScope("chapterhouse");
|
|
218
|
-
const activeOnly = housekeepingModule.runHousekeeping({ passes: ["decay"] });
|
|
260
|
+
const activeOnly = await housekeepingModule.runHousekeeping({ passes: ["decay"] });
|
|
219
261
|
assert.deepEqual(activeOnly.scopeIds, [chapterhouse.id]);
|
|
220
262
|
assert.equal(activeOnly.summaries.length, 1);
|
|
221
263
|
assert.equal(activeOnly.summaries[0]?.modified, 1);
|
|
222
264
|
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(chapterhouseOld.id).archived_at);
|
|
223
265
|
assert.equal(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at, null);
|
|
224
|
-
const allScopes = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
|
|
266
|
+
const allScopes = await housekeepingModule.runHousekeeping({ allScopes: true, passes: ["decay"] });
|
|
225
267
|
assert.ok(allScopes.scopeIds.includes(team.id));
|
|
226
268
|
assert.equal(allScopes.summaries.some((summary) => summary.modified === 1), true);
|
|
227
269
|
assert.ok(db.prepare(`SELECT archived_at FROM mem_observations WHERE id = ?`).get(teamOld.id).archived_at);
|
|
228
270
|
});
|
|
271
|
+
test("runHousekeeping starts all scoped passes before awaiting completion", async (t) => {
|
|
272
|
+
const releases = new Map();
|
|
273
|
+
const startedScopes = [];
|
|
274
|
+
const housekeepingModule = await loadMockedHousekeepingModule(t, {
|
|
275
|
+
scopes: [
|
|
276
|
+
{ id: 11, active: true },
|
|
277
|
+
{ id: 22, active: true },
|
|
278
|
+
],
|
|
279
|
+
tieringPass: async (scopeId) => {
|
|
280
|
+
startedScopes.push(scopeId);
|
|
281
|
+
await new Promise((resolve) => {
|
|
282
|
+
releases.set(scopeId, resolve);
|
|
283
|
+
});
|
|
284
|
+
return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
const pending = housekeepingModule.runHousekeeping({ allScopes: true, passes: ["tiering"] });
|
|
288
|
+
assert.equal(typeof pending.then, "function");
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
assert.deepEqual(startedScopes.sort((left, right) => left - right), [11, 22]);
|
|
291
|
+
releases.get(11)?.();
|
|
292
|
+
releases.get(22)?.();
|
|
293
|
+
const result = await pending;
|
|
294
|
+
assert.deepEqual(result.scopeIds, [11, 22]);
|
|
295
|
+
assert.deepEqual(result.summaries.map((summary) => summary.pass), ["tieringPass:11", "tieringPass:22"]);
|
|
296
|
+
});
|
|
297
|
+
test("runHousekeeping rejects overlapping runs that share an in-flight scope", async (t) => {
|
|
298
|
+
const releases = new Map();
|
|
299
|
+
const housekeepingModule = await loadMockedHousekeepingModule(t, {
|
|
300
|
+
scopes: [
|
|
301
|
+
{ id: 11, active: true },
|
|
302
|
+
{ id: 22, active: true },
|
|
303
|
+
],
|
|
304
|
+
tieringPass: async (scopeId) => {
|
|
305
|
+
await new Promise((resolve) => {
|
|
306
|
+
releases.set(scopeId, resolve);
|
|
307
|
+
});
|
|
308
|
+
return { pass: `tieringPass:${scopeId}`, examined: 1, modified: 1, errors: [] };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const firstRun = housekeepingModule.runHousekeeping({ scopeIds: [11, 22], passes: ["tiering"] });
|
|
312
|
+
await Promise.resolve();
|
|
313
|
+
assert.equal(housekeepingModule.isHousekeepingInFlight([22], ["tiering"]), true);
|
|
314
|
+
const secondRun = await housekeepingModule.runHousekeeping({ scopeIds: [22], passes: ["tiering"] });
|
|
315
|
+
assert.deepEqual(secondRun.scopeIds, [22]);
|
|
316
|
+
assert.match(secondRun.summaries[0]?.errors[0] ?? "", /already in flight/i);
|
|
317
|
+
releases.get(11)?.();
|
|
318
|
+
releases.get(22)?.();
|
|
319
|
+
await firstRun;
|
|
320
|
+
});
|
|
229
321
|
test("tieringPass promotes and demotes rows from lifecycle signals and is idempotent", async () => {
|
|
230
322
|
const { dbModule, memoryModule, housekeepingModule } = await loadModules();
|
|
231
323
|
const db = dbModule.getDb();
|
|
@@ -0,0 +1,178 @@
|
|
|
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-inbox-${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 loadRealModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
15
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
16
|
+
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
17
|
+
const inboxModule = await import(new URL(`./inbox.js?case=${cacheBust}`, import.meta.url).href);
|
|
18
|
+
return { dbModule, memoryModule, inboxModule };
|
|
19
|
+
}
|
|
20
|
+
async function loadMockedInboxModule(t, options) {
|
|
21
|
+
t.mock.module("../store/db.js", {
|
|
22
|
+
namedExports: {
|
|
23
|
+
getDb: () => ({ prepare: options.prepare }),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
t.mock.module("./active-scope.js", {
|
|
27
|
+
namedExports: {
|
|
28
|
+
getActiveScope: () => options.activeScope ?? null,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
t.mock.module("./scopes.js", {
|
|
32
|
+
namedExports: {
|
|
33
|
+
getScope: (slug) => options.explicitScopes?.[slug],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return await import(new URL(`./inbox.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
37
|
+
}
|
|
38
|
+
function getFunction(module, name) {
|
|
39
|
+
const value = module[name];
|
|
40
|
+
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function createTestScope(memoryModule, slug) {
|
|
44
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
45
|
+
return createScope({
|
|
46
|
+
slug,
|
|
47
|
+
title: slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "),
|
|
48
|
+
description: `${slug} test scope`,
|
|
49
|
+
keywords: [slug],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
test.beforeEach(async () => {
|
|
53
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
54
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
55
|
+
dbModule.closeDb();
|
|
56
|
+
resetSandbox();
|
|
57
|
+
});
|
|
58
|
+
test.after(async () => {
|
|
59
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
60
|
+
dbModule.closeDb();
|
|
61
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
test("resolveProposalScope returns explicit scopes and falls back to the active scope for missing or empty values", async () => {
|
|
64
|
+
const { dbModule, memoryModule, inboxModule } = await loadRealModules("resolve-fallback");
|
|
65
|
+
dbModule.getDb();
|
|
66
|
+
const docs = createTestScope(memoryModule, "docs");
|
|
67
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
68
|
+
const active = createTestScope(memoryModule, "team-memory");
|
|
69
|
+
setActiveScope(active.slug);
|
|
70
|
+
assert.deepEqual(inboxModule.resolveProposalScope(docs.slug), { scopeId: docs.id, scopeSlug: docs.slug });
|
|
71
|
+
assert.deepEqual(inboxModule.resolveProposalScope(undefined), { scopeId: active.id, scopeSlug: active.slug });
|
|
72
|
+
assert.deepEqual(inboxModule.resolveProposalScope(""), { scopeId: active.id, scopeSlug: active.slug });
|
|
73
|
+
});
|
|
74
|
+
test("resolveProposalScope rejects invalid explicit scopes and missing default scopes", async () => {
|
|
75
|
+
const { dbModule, inboxModule } = await loadRealModules("resolve-errors");
|
|
76
|
+
dbModule.getDb();
|
|
77
|
+
assert.throws(() => inboxModule.resolveProposalScope("does-not-exist"), /Unknown memory scope 'does-not-exist'\./);
|
|
78
|
+
assert.throws(() => inboxModule.resolveProposalScope(), /No active memory scope is set\. Use memory_set_scope or pass scope_slug explicitly\./);
|
|
79
|
+
});
|
|
80
|
+
test("queueMemoryProposal enqueues explicit-scope proposals with the expected envelope defaults", async () => {
|
|
81
|
+
const { dbModule, memoryModule, inboxModule } = await loadRealModules("explicit-queue");
|
|
82
|
+
const db = dbModule.getDb();
|
|
83
|
+
const docs = createTestScope(memoryModule, "docs-queue");
|
|
84
|
+
const queued = inboxModule.queueMemoryProposal({
|
|
85
|
+
kind: "observation",
|
|
86
|
+
scopeSlug: docs.slug,
|
|
87
|
+
payload: {
|
|
88
|
+
content: "Inbox tests should verify the stored proposal envelope.",
|
|
89
|
+
source: "oracle",
|
|
90
|
+
},
|
|
91
|
+
sourceAgent: "oracle",
|
|
92
|
+
sourceTaskId: "task-inbox-explicit",
|
|
93
|
+
});
|
|
94
|
+
assert.equal(typeof queued.id, "number");
|
|
95
|
+
assert.equal(queued.scopeId, docs.id);
|
|
96
|
+
assert.equal(queued.kind, "memory_proposal");
|
|
97
|
+
assert.equal(queued.status, "pending");
|
|
98
|
+
assert.equal(queued.sourceAgent, "oracle");
|
|
99
|
+
assert.equal(queued.sourceTaskId, "task-inbox-explicit");
|
|
100
|
+
assert.match(queued.createdAt, /\d{4}-\d{2}-\d{2}/);
|
|
101
|
+
const persisted = db.prepare(`
|
|
102
|
+
SELECT payload
|
|
103
|
+
FROM mem_inbox
|
|
104
|
+
WHERE id = ?
|
|
105
|
+
`).get(queued.id);
|
|
106
|
+
assert.ok(persisted, "queueMemoryProposal should persist a mem_inbox row");
|
|
107
|
+
assert.deepEqual(JSON.parse(persisted.payload), {
|
|
108
|
+
kind: "observation",
|
|
109
|
+
scope_slug: docs.slug,
|
|
110
|
+
confidence: 0.5,
|
|
111
|
+
payload: {
|
|
112
|
+
content: "Inbox tests should verify the stored proposal envelope.",
|
|
113
|
+
source: "oracle",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
assert.deepEqual(inboxModule.getInboxItem(queued.id), queued);
|
|
117
|
+
});
|
|
118
|
+
test("queueMemoryProposal falls back to the active scope, does not deduplicate duplicates, and pending lists exclude resolved items", async () => {
|
|
119
|
+
const { dbModule, memoryModule, inboxModule } = await loadRealModules("active-scope-queue");
|
|
120
|
+
dbModule.getDb();
|
|
121
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
122
|
+
setActiveScope("chapterhouse");
|
|
123
|
+
const first = inboxModule.queueMemoryProposal({
|
|
124
|
+
kind: "observation",
|
|
125
|
+
scopeSlug: "",
|
|
126
|
+
payload: { content: "Duplicate proposal payloads should still queue separately." },
|
|
127
|
+
confidence: 0.8,
|
|
128
|
+
reason: "First proposal",
|
|
129
|
+
sourceAgent: "oracle",
|
|
130
|
+
sourceTaskId: "task-inbox-duplicates",
|
|
131
|
+
});
|
|
132
|
+
const second = inboxModule.queueMemoryProposal({
|
|
133
|
+
kind: "observation",
|
|
134
|
+
payload: { content: "Duplicate proposal payloads should still queue separately." },
|
|
135
|
+
confidence: 0.8,
|
|
136
|
+
reason: "Second proposal",
|
|
137
|
+
sourceAgent: "oracle",
|
|
138
|
+
sourceTaskId: "task-inbox-duplicates",
|
|
139
|
+
});
|
|
140
|
+
assert.notEqual(first.id, second.id);
|
|
141
|
+
assert.equal(first.scopeId, second.scopeId);
|
|
142
|
+
assert.deepEqual(inboxModule.listPendingMemoryProposalsForTask("task-inbox-duplicates").map((item) => item.id), [first.id, second.id]);
|
|
143
|
+
inboxModule.resolveInboxItem(first.id, "accepted", "Durable enough to keep");
|
|
144
|
+
const resolved = inboxModule.getInboxItem(first.id);
|
|
145
|
+
assert.equal(resolved?.status, "accepted");
|
|
146
|
+
assert.equal(resolved?.resolutionReason, "Durable enough to keep");
|
|
147
|
+
assert.match(resolved?.resolvedAt ?? "", /\d{4}-\d{2}-\d{2}/);
|
|
148
|
+
assert.deepEqual(inboxModule.listPendingMemoryProposalsForTask("task-inbox-duplicates").map((item) => item.id), [second.id]);
|
|
149
|
+
});
|
|
150
|
+
test("queueMemoryProposal surfaces insert failures such as a full queue", async (t) => {
|
|
151
|
+
const fullError = new Error("database or disk is full");
|
|
152
|
+
const seenStatements = [];
|
|
153
|
+
const inboxModule = await loadMockedInboxModule(t, {
|
|
154
|
+
activeScope: { id: 99, slug: "chapterhouse" },
|
|
155
|
+
prepare: (sql) => {
|
|
156
|
+
seenStatements.push(sql);
|
|
157
|
+
if (sql.includes("INSERT INTO mem_inbox")) {
|
|
158
|
+
return {
|
|
159
|
+
run: () => {
|
|
160
|
+
throw fullError;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
run: () => ({ changes: 0 }),
|
|
166
|
+
get: () => undefined,
|
|
167
|
+
all: () => [],
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
assert.throws(() => inboxModule.queueMemoryProposal({
|
|
172
|
+
kind: "observation",
|
|
173
|
+
payload: { content: "This proposal should fail before it is persisted." },
|
|
174
|
+
sourceAgent: "oracle",
|
|
175
|
+
}), /database or disk is full/);
|
|
176
|
+
assert.equal(seenStatements.some((sql) => sql.includes("INSERT INTO mem_inbox")), true);
|
|
177
|
+
});
|
|
178
|
+
//# sourceMappingURL=inbox.test.js.map
|