chapterhouse 0.8.0 → 0.8.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 +91 -3
- package/dist/api/server.test.js +222 -1
- package/dist/copilot/agents.js +1 -1
- package/dist/copilot/system-message.js +1 -0
- package/dist/copilot/tools.js +11 -1
- package/dist/copilot/tools.wiki.test.js +27 -0
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +10 -0
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/migration.test.js +10 -2
- package/dist/paths.js +31 -11
- package/dist/store/db.js +68 -0
- package/dist/store/db.test.js +47 -1
- package/dist/test/helpers/reset-singletons.js +8 -0
- package/dist/test/helpers/reset-singletons.test.js +37 -0
- package/dist/test/setup-env.js +8 -1
- package/dist/wiki/consolidation.test.js +3 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +82 -23
- package/dist/wiki/index-manager.test.js +129 -1
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/package.json +1 -1
package/dist/memory/eot.test.js
CHANGED
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
5
6
|
const repoRoot = process.cwd();
|
|
6
7
|
const sandboxRoot = join(repoRoot, ".test-work", `memory-eot-${process.pid}`);
|
|
7
8
|
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
@@ -18,24 +19,40 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
18
19
|
const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
20
|
return { dbModule, memoryModule, eotModule, agentsModule };
|
|
20
21
|
}
|
|
22
|
+
async function loadModulesWithWarnSpy(t, cacheBust) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
t.mock.module("../util/logger.js", {
|
|
25
|
+
namedExports: {
|
|
26
|
+
childLogger: () => ({
|
|
27
|
+
info: () => { },
|
|
28
|
+
warn: (...args) => {
|
|
29
|
+
warnings.push(args);
|
|
30
|
+
},
|
|
31
|
+
error: () => { },
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
36
|
+
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
37
|
+
const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
|
|
38
|
+
return { dbModule, memoryModule, eotModule, warnings };
|
|
39
|
+
}
|
|
21
40
|
function getFunction(module, name) {
|
|
22
41
|
const value = module[name];
|
|
23
42
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
24
43
|
return value;
|
|
25
44
|
}
|
|
26
|
-
test.beforeEach(
|
|
45
|
+
test.beforeEach(() => {
|
|
27
46
|
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
28
47
|
delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
|
|
29
48
|
delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
|
|
30
|
-
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
31
|
-
dbModule.closeDb();
|
|
32
49
|
resetSandbox();
|
|
50
|
+
resetSingletons();
|
|
33
51
|
});
|
|
34
|
-
test.after(
|
|
52
|
+
test.after(() => {
|
|
35
53
|
delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
|
|
36
54
|
delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
|
|
37
|
-
|
|
38
|
-
dbModule.closeDb();
|
|
55
|
+
resetSingletons();
|
|
39
56
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
40
57
|
});
|
|
41
58
|
test("runEndOfTaskMemoryHook does nothing when CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED=0", async () => {
|
|
@@ -239,6 +256,98 @@ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear
|
|
|
239
256
|
assert.match(inbox.resolution_reason ?? "", /title/i);
|
|
240
257
|
assert.ok(inbox.resolved_at);
|
|
241
258
|
});
|
|
259
|
+
async function runAcceptedObservationHookScenario(t, cacheBust, taskId, payload) {
|
|
260
|
+
const warnings = [];
|
|
261
|
+
t.mock.module("../util/logger.js", {
|
|
262
|
+
namedExports: {
|
|
263
|
+
childLogger: () => ({
|
|
264
|
+
info: () => { },
|
|
265
|
+
warn: (obj, msg) => warnings.push({ obj, msg }),
|
|
266
|
+
error: () => { },
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const { dbModule, memoryModule, eotModule } = await loadModules(cacheBust);
|
|
271
|
+
const db = dbModule.getDb();
|
|
272
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
273
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
274
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
275
|
+
const chapterhouse = getScope("chapterhouse");
|
|
276
|
+
assert.ok(chapterhouse);
|
|
277
|
+
const before = listObservations({ scope_id: chapterhouse.id });
|
|
278
|
+
const inserted = db.prepare(`
|
|
279
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
280
|
+
VALUES (?, 'memory_proposal', ?, 'coder', ?, 'pending')
|
|
281
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
282
|
+
kind: "observation",
|
|
283
|
+
payload,
|
|
284
|
+
confidence: 0.9,
|
|
285
|
+
}), taskId);
|
|
286
|
+
await runEndOfTaskMemoryHook({
|
|
287
|
+
taskId,
|
|
288
|
+
finalResult: "Completed and reviewed an observation proposal.",
|
|
289
|
+
copilotClient: {},
|
|
290
|
+
callLLM: async () => JSON.stringify({
|
|
291
|
+
decisions: [{
|
|
292
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
293
|
+
decision: "accept",
|
|
294
|
+
reason: "Durable finding.",
|
|
295
|
+
}],
|
|
296
|
+
implicit_memories: [],
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
const inbox = db.prepare(`
|
|
300
|
+
SELECT status, resolution_reason
|
|
301
|
+
FROM mem_inbox
|
|
302
|
+
WHERE id = ?
|
|
303
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
304
|
+
return {
|
|
305
|
+
beforeCount: before.length,
|
|
306
|
+
after: listObservations({ scope_id: chapterhouse.id }),
|
|
307
|
+
warnings,
|
|
308
|
+
inbox,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with null content and warns", async (t) => {
|
|
312
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-null-content", "task-eot-observation-null-content", { content: null });
|
|
313
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
314
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
315
|
+
assert.equal(result.inbox.status, "accepted");
|
|
316
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
317
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
318
|
+
});
|
|
319
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with undefined content and warns", async (t) => {
|
|
320
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-undefined-content", "task-eot-observation-undefined-content", {});
|
|
321
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
322
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
323
|
+
assert.equal(result.inbox.status, "accepted");
|
|
324
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
325
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
326
|
+
});
|
|
327
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with empty string content and warns", async (t) => {
|
|
328
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-empty-string-content", "task-eot-observation-empty-string-content", { content: "" });
|
|
329
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
330
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
331
|
+
assert.equal(result.inbox.status, "accepted");
|
|
332
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
333
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
334
|
+
});
|
|
335
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with whitespace-only content and warns", async (t) => {
|
|
336
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-whitespace-content", "task-eot-observation-whitespace-content", { content: " " });
|
|
337
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
338
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
339
|
+
assert.equal(result.inbox.status, "accepted");
|
|
340
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
341
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
342
|
+
});
|
|
343
|
+
test("runEndOfTaskMemoryHook inserts accepted observation proposals with valid content", async (t) => {
|
|
344
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-valid-content", "task-eot-observation-valid-content", { content: " Durable finding from the task. " });
|
|
345
|
+
assert.equal(result.after.length, result.beforeCount + 1);
|
|
346
|
+
assert.ok(result.after.some((row) => row.content === "Durable finding from the task."));
|
|
347
|
+
assert.equal(result.inbox.status, "accepted");
|
|
348
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
349
|
+
assert.equal(result.warnings.length, 0);
|
|
350
|
+
});
|
|
242
351
|
test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
|
|
243
352
|
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
|
|
244
353
|
const db = dbModule.getDb();
|
|
@@ -549,4 +658,109 @@ test("runEndOfTaskMemoryHook accepts implicit entity memories with entity_kind",
|
|
|
549
658
|
&& entity.kind === "host"
|
|
550
659
|
&& entity.summary === "NAS host used by Bellonda."), true);
|
|
551
660
|
});
|
|
661
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with null content and warns", async (t) => {
|
|
662
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-null-content");
|
|
663
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
664
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
665
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
666
|
+
const chapterhouse = getScope("chapterhouse");
|
|
667
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
668
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
669
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
670
|
+
taskId: "task-eot-implicit-null",
|
|
671
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
672
|
+
copilotClient: {},
|
|
673
|
+
callLLM: async () => JSON.stringify({
|
|
674
|
+
decisions: [],
|
|
675
|
+
implicit_memories: [{
|
|
676
|
+
kind: "observation",
|
|
677
|
+
scope_slug: "chapterhouse",
|
|
678
|
+
payload: {
|
|
679
|
+
content: null,
|
|
680
|
+
},
|
|
681
|
+
}],
|
|
682
|
+
}),
|
|
683
|
+
});
|
|
684
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
685
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
686
|
+
assert.equal(warnings.length, 1);
|
|
687
|
+
});
|
|
688
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with undefined content and warns", async (t) => {
|
|
689
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-undefined-content");
|
|
690
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
691
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
692
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
693
|
+
const chapterhouse = getScope("chapterhouse");
|
|
694
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
695
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
696
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
697
|
+
taskId: "task-eot-implicit-undefined",
|
|
698
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
699
|
+
copilotClient: {},
|
|
700
|
+
callLLM: async () => JSON.stringify({
|
|
701
|
+
decisions: [],
|
|
702
|
+
implicit_memories: [{
|
|
703
|
+
kind: "observation",
|
|
704
|
+
scope_slug: "chapterhouse",
|
|
705
|
+
payload: {},
|
|
706
|
+
}],
|
|
707
|
+
}),
|
|
708
|
+
});
|
|
709
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
710
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
711
|
+
assert.equal(warnings.length, 1);
|
|
712
|
+
});
|
|
713
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with empty content and warns", async (t) => {
|
|
714
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-empty-content");
|
|
715
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
716
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
717
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
718
|
+
const chapterhouse = getScope("chapterhouse");
|
|
719
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
720
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
721
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
722
|
+
taskId: "task-eot-implicit-empty",
|
|
723
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
724
|
+
copilotClient: {},
|
|
725
|
+
callLLM: async () => JSON.stringify({
|
|
726
|
+
decisions: [],
|
|
727
|
+
implicit_memories: [{
|
|
728
|
+
kind: "observation",
|
|
729
|
+
scope_slug: "chapterhouse",
|
|
730
|
+
payload: {
|
|
731
|
+
content: " ",
|
|
732
|
+
},
|
|
733
|
+
}],
|
|
734
|
+
}),
|
|
735
|
+
});
|
|
736
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
737
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
738
|
+
assert.equal(warnings.length, 1);
|
|
739
|
+
});
|
|
740
|
+
test("runEndOfTaskMemoryHook persists implicit observation memories with valid content", async (t) => {
|
|
741
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-valid-content");
|
|
742
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
743
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
744
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
745
|
+
const chapterhouse = getScope("chapterhouse");
|
|
746
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
747
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
748
|
+
taskId: "task-eot-implicit-valid",
|
|
749
|
+
finalResult: "The reviewer discovered a valid durable memory.",
|
|
750
|
+
copilotClient: {},
|
|
751
|
+
callLLM: async () => JSON.stringify({
|
|
752
|
+
decisions: [],
|
|
753
|
+
implicit_memories: [{
|
|
754
|
+
kind: "observation",
|
|
755
|
+
scope_slug: "chapterhouse",
|
|
756
|
+
payload: {
|
|
757
|
+
content: "A valid implicit memory should still be stored.",
|
|
758
|
+
},
|
|
759
|
+
}],
|
|
760
|
+
}),
|
|
761
|
+
});
|
|
762
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "A valid implicit memory should still be stored."), true);
|
|
763
|
+
assert.equal(summary.implicit_extracted, 1);
|
|
764
|
+
assert.equal(warnings.length, 0);
|
|
765
|
+
});
|
|
552
766
|
//# sourceMappingURL=eot.test.js.map
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
5
6
|
let sandboxRoot;
|
|
6
7
|
let chapterhouseHome;
|
|
7
8
|
let dbModule;
|
|
@@ -20,12 +21,15 @@ test.before(async () => {
|
|
|
20
21
|
sandboxRoot = mkdtempSync(join(process.cwd(), ".test-work", "memory-migration-"));
|
|
21
22
|
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
22
23
|
chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
24
|
+
resetSingletons();
|
|
23
25
|
const nonce = `${Date.now()}-${Math.random()}`;
|
|
24
26
|
dbModule = await import(new URL(`../store/db.js?case=${nonce}`, import.meta.url).href);
|
|
25
27
|
migrationModule = await import(new URL(`./migration.js?case=${nonce}`, import.meta.url).href);
|
|
26
28
|
});
|
|
27
29
|
test.beforeEach(() => {
|
|
30
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
28
31
|
dbModule.closeDb();
|
|
32
|
+
resetSingletons();
|
|
29
33
|
resetSandbox();
|
|
30
34
|
});
|
|
31
35
|
test.after(() => {
|
|
@@ -33,6 +37,10 @@ test.after(() => {
|
|
|
33
37
|
dbModule.closeDb();
|
|
34
38
|
}
|
|
35
39
|
catch { }
|
|
40
|
+
try {
|
|
41
|
+
resetSingletons();
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
36
44
|
try {
|
|
37
45
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
38
46
|
}
|
|
@@ -84,9 +92,9 @@ test("runP6Migration is idempotent across repeated runs", async () => {
|
|
|
84
92
|
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM mem_observations WHERE source = 'migration:p6'`).get().count, 1);
|
|
85
93
|
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM mem_migrations WHERE name = 'p6-wiki-seed'`).get().count, 1);
|
|
86
94
|
});
|
|
87
|
-
test("runP6Migration skips gracefully when the wiki
|
|
95
|
+
test("runP6Migration skips gracefully when the wiki skeleton exists but contains no migratable pages", async () => {
|
|
88
96
|
const db = dbModule.getDb();
|
|
89
|
-
assert.equal(existsSync(join(chapterhouseHome, "wiki", "pages")),
|
|
97
|
+
assert.equal(existsSync(join(chapterhouseHome, "wiki", "pages")), true);
|
|
90
98
|
const result = await migrationModule.runP6Migration(db);
|
|
91
99
|
assert.deepEqual(result, {
|
|
92
100
|
entitiesCreated: 0,
|
package/dist/paths.js
CHANGED
|
@@ -12,36 +12,56 @@ function resolveChapterhouseHome() {
|
|
|
12
12
|
? configuredHome
|
|
13
13
|
: join(configuredHome, ".chapterhouse");
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// Reset in tests via src/test/helpers/reset-singletons.ts
|
|
16
|
+
export let CHAPTERHOUSE_HOME = resolveChapterhouseHome();
|
|
16
17
|
export function getChapterhouseHome() {
|
|
17
18
|
return resolveChapterhouseHome();
|
|
18
19
|
}
|
|
19
20
|
/** Path to the SQLite database */
|
|
20
|
-
export
|
|
21
|
+
export let DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
|
|
21
22
|
export function getDbPath() {
|
|
22
23
|
return join(resolveChapterhouseHome(), "chapterhouse.db");
|
|
23
24
|
}
|
|
24
25
|
/** Path to the user .env file */
|
|
25
|
-
export
|
|
26
|
+
export let ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
|
|
26
27
|
/** Path to user-local skills */
|
|
27
|
-
export
|
|
28
|
+
export let SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
|
|
28
29
|
/** Path to Chapterhouse's isolated session state (keeps CLI history clean) */
|
|
29
|
-
export
|
|
30
|
+
export let SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
|
|
30
31
|
/** Path to the API bearer token file */
|
|
31
|
-
export
|
|
32
|
+
export let API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
|
|
33
|
+
/** Path to Chapterhouse runtime logs */
|
|
34
|
+
export const LOGS_DIR = join(CHAPTERHOUSE_HOME, "logs");
|
|
32
35
|
/** Agent definition files (~/.chapterhouse/agents/) */
|
|
33
|
-
export
|
|
36
|
+
export let AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
|
|
34
37
|
/** Root of the LLM-maintained wiki knowledge base */
|
|
35
|
-
export
|
|
38
|
+
export let WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
|
|
36
39
|
/** Wiki pages (entity, concept, summary files) */
|
|
37
|
-
export
|
|
40
|
+
export let WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
|
|
38
41
|
/** Raw ingested source documents (immutable) */
|
|
39
|
-
export
|
|
42
|
+
export let WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
|
|
43
|
+
function refreshCachedPaths() {
|
|
44
|
+
CHAPTERHOUSE_HOME = resolveChapterhouseHome();
|
|
45
|
+
DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
|
|
46
|
+
ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
|
|
47
|
+
SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
|
|
48
|
+
SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
|
|
49
|
+
API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
|
|
50
|
+
AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
|
|
51
|
+
WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
|
|
52
|
+
WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
|
|
53
|
+
WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
|
|
54
|
+
}
|
|
55
|
+
export function resetPathsForTests() {
|
|
56
|
+
refreshCachedPaths();
|
|
57
|
+
}
|
|
40
58
|
export function resolveWikiRelativePath(relativePath) {
|
|
41
59
|
return join(WIKI_DIR, ...normalizeWikiPath(relativePath).split("/"));
|
|
42
60
|
}
|
|
43
61
|
/** Ensure ~/.chapterhouse/ exists */
|
|
44
62
|
export function ensureChapterhouseHome() {
|
|
45
|
-
|
|
63
|
+
const home = resolveChapterhouseHome();
|
|
64
|
+
mkdirSync(home, { recursive: true });
|
|
65
|
+
mkdirSync(join(home, "logs"), { recursive: true });
|
|
46
66
|
}
|
|
47
67
|
//# sourceMappingURL=paths.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { ensureChapterhouseHome, getDbPath } from "../paths.js";
|
|
4
|
+
import { ensureWikiStructure, listPages, readPage } from "../wiki/fs.js";
|
|
5
|
+
import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
6
|
+
// Reset in tests via src/test/helpers/reset-singletons.ts
|
|
4
7
|
let db;
|
|
5
8
|
let logInsertCount = 0;
|
|
6
9
|
let fts5Available = false;
|
|
@@ -49,6 +52,63 @@ function tableCreateSql(database, table) {
|
|
|
49
52
|
`).get(table);
|
|
50
53
|
return row?.sql ?? "";
|
|
51
54
|
}
|
|
55
|
+
const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
|
|
56
|
+
const LEGACY_INDEX_PAGE = "pages/index.md";
|
|
57
|
+
function isIgnoredWikiIndexPage(path) {
|
|
58
|
+
return path === LEGACY_INDEX_PAGE || ACTION_LOG_PAGE_RE.test(path);
|
|
59
|
+
}
|
|
60
|
+
function wikiBasenameTitle(path) {
|
|
61
|
+
const segs = path.split("/").filter(Boolean);
|
|
62
|
+
const file = segs[segs.length - 1] || path;
|
|
63
|
+
const base = file.replace(/\.md$/, "");
|
|
64
|
+
const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
|
|
65
|
+
return titleBase.split(/[-_]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
66
|
+
}
|
|
67
|
+
function summarizeWikiBody(body) {
|
|
68
|
+
for (const raw of body.split("\n")) {
|
|
69
|
+
const line = raw.trim();
|
|
70
|
+
if (!line || line.startsWith("#") || line.startsWith("<!--")) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
|
|
74
|
+
if (summary) {
|
|
75
|
+
return summary.length > 160 ? `${summary.slice(0, 157)}…` : summary;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
function seedWikiPagesFromDisk(database) {
|
|
81
|
+
ensureWikiStructure();
|
|
82
|
+
const pages = listPages().filter((page) => !isIgnoredWikiIndexPage(page));
|
|
83
|
+
if (pages.length === 0) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const wikiPageCount = database.prepare(`SELECT COUNT(*) AS count FROM wiki_pages`).get().count;
|
|
87
|
+
if (wikiPageCount > 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const upsert = database.prepare(`
|
|
91
|
+
INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated)
|
|
92
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
93
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
94
|
+
title = excluded.title,
|
|
95
|
+
entity_type = excluded.entity_type,
|
|
96
|
+
tags = excluded.tags,
|
|
97
|
+
summary = excluded.summary,
|
|
98
|
+
last_updated = excluded.last_updated,
|
|
99
|
+
version = wiki_pages.version + 1
|
|
100
|
+
`);
|
|
101
|
+
for (const page of pages) {
|
|
102
|
+
const content = readPage(page);
|
|
103
|
+
if (!content) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const { parsed: fm, body } = parseWikiFrontmatter(content);
|
|
107
|
+
const summary = fm.summary?.trim() || summarizeWikiBody(body) || fm.title || wikiBasenameTitle(page);
|
|
108
|
+
const entityType = fm.metadata?.["entity_type"] ?? null;
|
|
109
|
+
upsert.run(page, fm.title ?? wikiBasenameTitle(page), entityType, JSON.stringify(fm.tags ?? []), summary, fm.updated ?? new Date().toISOString());
|
|
110
|
+
}
|
|
111
|
+
}
|
|
52
112
|
function rebuildMemoryTierTables(database) {
|
|
53
113
|
const needsRebuild = ["mem_entities", "mem_observations", "mem_decisions"]
|
|
54
114
|
.some((table) => tableCreateSql(database, table).includes("'glacier'"));
|
|
@@ -1108,6 +1168,7 @@ export function getDb() {
|
|
|
1108
1168
|
VALUES(new.rowid, new.path, new.title, new.entity_type, new.tags, new.summary);
|
|
1109
1169
|
END
|
|
1110
1170
|
`);
|
|
1171
|
+
seedWikiPagesFromDisk(db);
|
|
1111
1172
|
// Backfill: check if FTS is in sync by comparing row counts
|
|
1112
1173
|
const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
|
|
1113
1174
|
const ftsCount = db.prepare(`SELECT COUNT(*) as c FROM memories_fts`).get().c;
|
|
@@ -1427,4 +1488,11 @@ export function closeDb() {
|
|
|
1427
1488
|
daemonRunRecorded = false;
|
|
1428
1489
|
}
|
|
1429
1490
|
}
|
|
1491
|
+
export function resetDbForTests() {
|
|
1492
|
+
closeDb();
|
|
1493
|
+
logInsertCount = 0;
|
|
1494
|
+
fts5Available = false;
|
|
1495
|
+
currentDaemonRunId = undefined;
|
|
1496
|
+
daemonRunRecorded = false;
|
|
1497
|
+
}
|
|
1430
1498
|
//# sourceMappingURL=db.js.map
|
package/dist/store/db.test.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import Database from "better-sqlite3";
|
|
6
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
6
7
|
const repoRoot = process.cwd();
|
|
7
8
|
const sandboxRoot = join(repoRoot, ".test-work", `store-db-${process.pid}`);
|
|
8
9
|
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
@@ -17,11 +18,56 @@ function resetSandbox() {
|
|
|
17
18
|
mkdirSync(chapterhouseHome, { recursive: true });
|
|
18
19
|
}
|
|
19
20
|
test.beforeEach(() => {
|
|
21
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
20
22
|
resetSandbox();
|
|
23
|
+
resetSingletons();
|
|
21
24
|
});
|
|
22
25
|
test.after(() => {
|
|
26
|
+
resetSingletons();
|
|
23
27
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
24
28
|
});
|
|
29
|
+
test("getDb startup reindex populates wiki_pages when pages exist on disk", async () => {
|
|
30
|
+
mkdirSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust"), { recursive: true });
|
|
31
|
+
writeFileSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust", "index.md"), "---\ntitle: Rust\nsummary: Systems language\nupdated: 2026-05-15\n---\n\n# Rust\n\nFearless concurrency.\n", "utf-8");
|
|
32
|
+
const dbModule = await loadDbModule();
|
|
33
|
+
try {
|
|
34
|
+
const db = dbModule.getDb();
|
|
35
|
+
const count = db.prepare(`SELECT COUNT(*) AS count FROM wiki_pages`).get().count;
|
|
36
|
+
assert.equal(count, 1);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
dbModule.closeDb();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
test("getDb startup reindex is idempotent across repeated startups", async () => {
|
|
43
|
+
mkdirSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust"), { recursive: true });
|
|
44
|
+
writeFileSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust", "index.md"), "---\ntitle: Rust\nsummary: Systems language\nupdated: 2026-05-15\n---\n\n# Rust\n\nFearless concurrency.\n", "utf-8");
|
|
45
|
+
const dbModule = await loadDbModule();
|
|
46
|
+
try {
|
|
47
|
+
dbModule.getDb();
|
|
48
|
+
dbModule.closeDb();
|
|
49
|
+
dbModule.getDb();
|
|
50
|
+
const reopened = new Database(dbPath, { readonly: true });
|
|
51
|
+
const count = reopened.prepare(`SELECT COUNT(*) AS count FROM wiki_pages WHERE path = 'pages/topics/rust/index.md'`).get().count;
|
|
52
|
+
reopened.close();
|
|
53
|
+
assert.equal(count, 1);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
dbModule.closeDb();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("getDb startup reindex creates the wiki log directory when it is missing", async () => {
|
|
60
|
+
mkdirSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust"), { recursive: true });
|
|
61
|
+
writeFileSync(join(chapterhouseHome, "wiki", "pages", "topics", "rust", "index.md"), "---\ntitle: Rust\nsummary: Systems language\nupdated: 2026-05-15\n---\n\n# Rust\n\nFearless concurrency.\n", "utf-8");
|
|
62
|
+
const dbModule = await loadDbModule();
|
|
63
|
+
try {
|
|
64
|
+
dbModule.getDb();
|
|
65
|
+
assert.equal(existsSync(join(chapterhouseHome, "wiki", "pages", "_meta", "log.md")), true);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
dbModule.closeDb();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
25
71
|
test("getDb initializes schema, state helpers, and conversation formatting", async () => {
|
|
26
72
|
const dbModule = await loadDbModule();
|
|
27
73
|
try {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { resetPathsForTests } from "../../paths.js";
|
|
2
|
+
import { resetDbForTests } from "../../store/db.js";
|
|
3
|
+
export function resetSingletons() {
|
|
4
|
+
resetDbForTests();
|
|
5
|
+
resetPathsForTests();
|
|
6
|
+
}
|
|
7
|
+
export const resetModuleCache = resetSingletons;
|
|
8
|
+
//# sourceMappingURL=reset-singletons.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, 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", `reset-singletons-${process.pid}`);
|
|
7
|
+
function sandboxPath(name) {
|
|
8
|
+
return join(sandboxRoot, name);
|
|
9
|
+
}
|
|
10
|
+
test.before(() => {
|
|
11
|
+
mkdirSync(sandboxRoot, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
test.after(() => {
|
|
14
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
test("resetSingletons refreshes cached db and path singletons after CHAPTERHOUSE_HOME changes", async () => {
|
|
17
|
+
const helper = await import("./reset-singletons.js");
|
|
18
|
+
const paths = await import("../../paths.js");
|
|
19
|
+
const dbModule = await import("../../store/db.js");
|
|
20
|
+
const firstHome = sandboxPath("first-home");
|
|
21
|
+
const secondHome = sandboxPath("second-home");
|
|
22
|
+
mkdirSync(firstHome, { recursive: true });
|
|
23
|
+
process.env.CHAPTERHOUSE_HOME = firstHome;
|
|
24
|
+
await helper.resetSingletons();
|
|
25
|
+
dbModule.getDb().prepare("SELECT 1").get();
|
|
26
|
+
assert.equal(paths.CHAPTERHOUSE_HOME, join(firstHome, ".chapterhouse"));
|
|
27
|
+
assert.equal(paths.DB_PATH, join(firstHome, ".chapterhouse", "chapterhouse.db"));
|
|
28
|
+
assert.equal(existsSync(join(firstHome, ".chapterhouse", "chapterhouse.db")), true);
|
|
29
|
+
mkdirSync(secondHome, { recursive: true });
|
|
30
|
+
process.env.CHAPTERHOUSE_HOME = secondHome;
|
|
31
|
+
await helper.resetSingletons();
|
|
32
|
+
dbModule.getDb().prepare("SELECT 1").get();
|
|
33
|
+
assert.equal(paths.CHAPTERHOUSE_HOME, join(secondHome, ".chapterhouse"));
|
|
34
|
+
assert.equal(paths.DB_PATH, join(secondHome, ".chapterhouse", "chapterhouse.db"));
|
|
35
|
+
assert.equal(existsSync(join(secondHome, ".chapterhouse", "chapterhouse.db")), true);
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=reset-singletons.test.js.map
|
package/dist/test/setup-env.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import { resetSingletons } from "./helpers/reset-singletons.js";
|
|
1
3
|
const RUNTIME_OVERRIDE_ENV_VARS = [
|
|
2
4
|
"CHAPTERHOUSE_MODE",
|
|
3
5
|
"CHAPTERHOUSE_SELF_EDIT",
|
|
@@ -26,5 +28,10 @@ for (const name of [...RUNTIME_OVERRIDE_ENV_VARS, ...AUTH_ENV_VARS]) {
|
|
|
26
28
|
delete process.env[name];
|
|
27
29
|
}
|
|
28
30
|
process.env.CHAPTERHOUSE_DISABLE_DOTENV = "1";
|
|
29
|
-
|
|
31
|
+
test.beforeEach(() => {
|
|
32
|
+
resetSingletons();
|
|
33
|
+
});
|
|
34
|
+
test.afterEach(() => {
|
|
35
|
+
resetSingletons();
|
|
36
|
+
});
|
|
30
37
|
//# sourceMappingURL=setup-env.js.map
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { mkdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
5
6
|
const repoRoot = process.cwd();
|
|
6
7
|
const sandboxRoot = join(repoRoot, ".test-work", `wiki-consolidation-${process.pid}`);
|
|
7
8
|
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
@@ -27,10 +28,12 @@ test.beforeEach(async () => {
|
|
|
27
28
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
28
29
|
dbModule.closeDb();
|
|
29
30
|
resetSandbox();
|
|
31
|
+
resetSingletons();
|
|
30
32
|
});
|
|
31
33
|
test.after(async () => {
|
|
32
34
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
33
35
|
dbModule.closeDb();
|
|
36
|
+
resetSingletons();
|
|
34
37
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
35
38
|
});
|
|
36
39
|
test("runConsolidation rewrites stale compiled truth and skips pinned pages", async () => {
|