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.
@@ -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(async () => {
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(async () => {
52
+ test.after(() => {
35
53
  delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
36
54
  delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
37
- const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
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 pages directory is empty or absent", async () => {
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")), false);
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
- export const CHAPTERHOUSE_HOME = resolveChapterhouseHome();
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 const DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
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 const ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
26
+ export let ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
26
27
  /** Path to user-local skills */
27
- export const SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
28
+ export let SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
28
29
  /** Path to Chapterhouse's isolated session state (keeps CLI history clean) */
29
- export const SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
30
+ export let SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
30
31
  /** Path to the API bearer token file */
31
- export const API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
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 const AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
36
+ export let AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
34
37
  /** Root of the LLM-maintained wiki knowledge base */
35
- export const WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
38
+ export let WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
36
39
  /** Wiki pages (entity, concept, summary files) */
37
- export const WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
40
+ export let WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
38
41
  /** Raw ingested source documents (immutable) */
39
- export const WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
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
- mkdirSync(resolveChapterhouseHome(), { recursive: true });
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
@@ -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
@@ -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
- export {};
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 () => {