chapterhouse 0.6.0 → 0.8.0

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 (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -0,0 +1,111 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Ingestion pipeline tests — ingestSource
3
+ // Sandbox: single CHAPTERHOUSE_HOME per file to avoid module-singleton confusion
4
+ // ---------------------------------------------------------------------------
5
+ import assert from "node:assert/strict";
6
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import test from "node:test";
9
+ // Single sandbox shared across all tests in this file
10
+ let SANDBOX;
11
+ let mods;
12
+ test.before(async () => {
13
+ mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
14
+ SANDBOX = mkdtempSync(join(process.cwd(), ".test-work", "ingest-"));
15
+ process.env.CHAPTERHOUSE_HOME = SANDBOX;
16
+ const nonce = `${Date.now()}-${Math.random()}`;
17
+ const ingestMod = await import(new URL(`./ingest.js?c=${nonce}`, import.meta.url).href);
18
+ const wikiFs = await import(new URL(`./fs.js?c=${nonce}`, import.meta.url).href);
19
+ const dbMod = await import(new URL(`../store/db.js?c=${nonce}`, import.meta.url).href);
20
+ mods = { ingestMod, wikiFs, dbMod };
21
+ mods.wikiFs.ensureWikiStructure();
22
+ });
23
+ test.after(() => {
24
+ try {
25
+ rmSync(SANDBOX, { recursive: true, force: true });
26
+ }
27
+ catch { /* best-effort */ }
28
+ });
29
+ test("ingestSource(text) creates wiki_sources record", async () => {
30
+ const text = `Source-A: Alice is a senior engineer at Acme Corp. timestamp=${Date.now()}`;
31
+ const result = await mods.ingestMod.ingestSource(text, "text", "people");
32
+ assert.ok(result.source_id, "Should return a source_id");
33
+ assert.equal(result.already_existed, false, "Should not already exist on first call");
34
+ const db = mods.dbMod.getDb();
35
+ const row = db.prepare(`SELECT * FROM wiki_sources WHERE id = ?`).get(result.source_id);
36
+ assert.ok(row, "Should be persisted in wiki_sources");
37
+ assert.equal(row.source_type, "text");
38
+ });
39
+ test("ingestSource(text) saves raw source file", async () => {
40
+ const text = `Source-B: Bob leads the platform engineering team at TechCo. timestamp=${Date.now()}`;
41
+ const result = await mods.ingestMod.ingestSource(text, "text");
42
+ const sources = mods.wikiFs.listSources();
43
+ assert.ok(sources.length > 0, "Should have saved a raw source file");
44
+ assert.ok(sources.some((s) => s.startsWith(result.source_id.slice(0, 16))), "Source file should be named with source_id prefix");
45
+ });
46
+ test("ingestSource duplicate ingestion returns already_existed=true", async () => {
47
+ const text = `Source-C: Carol is a product manager at StartupXYZ. timestamp=${Date.now()}`;
48
+ const first = await mods.ingestMod.ingestSource(text, "text");
49
+ assert.equal(first.already_existed, false, "First call should not be a duplicate");
50
+ const second = await mods.ingestMod.ingestSource(text, "text");
51
+ assert.equal(second.already_existed, true, "Second ingestion should be idempotent");
52
+ assert.equal(second.source_id, first.source_id, "Should return same source_id");
53
+ const db = mods.dbMod.getDb();
54
+ const count = db.prepare(`SELECT COUNT(*) as c FROM wiki_sources WHERE id = ?`).get(first.source_id).c;
55
+ assert.equal(count, 1, "Should only have one row in wiki_sources");
56
+ });
57
+ test("computeSourceId is deterministic and type-scoped", () => {
58
+ const id1 = mods.ingestMod.computeSourceId("text", "hello world");
59
+ const id2 = mods.ingestMod.computeSourceId("text", "hello world");
60
+ assert.equal(id1, id2, "Same input should give same id");
61
+ const id3 = mods.ingestMod.computeSourceId("url", "hello world");
62
+ assert.notEqual(id1, id3, "Different types should give different ids");
63
+ });
64
+ test("detectSourceType identifies URLs, repos, and text", () => {
65
+ assert.equal(mods.ingestMod.detectSourceType("https://tokio.rs"), "url");
66
+ assert.equal(mods.ingestMod.detectSourceType("http://example.com"), "url");
67
+ assert.equal(mods.ingestMod.detectSourceType("https://github.com/user/repo"), "repo");
68
+ assert.equal(mods.ingestMod.detectSourceType("some plain text content"), "text");
69
+ assert.equal(mods.ingestMod.detectSourceType(""), "text");
70
+ });
71
+ test("assertSafeRemoteUrl blocks all RFC 1918 private ranges", () => {
72
+ assert.throws(() => mods.ingestMod.assertSafeRemoteUrl("http://10.1.2.3"), /Cannot fetch internal\/private URLs\./);
73
+ assert.throws(() => mods.ingestMod.assertSafeRemoteUrl("http://172.16.5.4"), /Cannot fetch internal\/private URLs\./);
74
+ assert.throws(() => mods.ingestMod.assertSafeRemoteUrl("http://172.31.255.255"), /Cannot fetch internal\/private URLs\./);
75
+ assert.throws(() => mods.ingestMod.assertSafeRemoteUrl("http://192.168.1.9"), /Cannot fetch internal\/private URLs\./);
76
+ });
77
+ test("createEntityPageContent uses the Summary and Timeline headings", () => {
78
+ const content = mods.ingestMod.createEntityPageContent({
79
+ pageTitle: "Alice Example",
80
+ pageSummary: "Senior engineer at Acme.",
81
+ entityType: "people",
82
+ updatedAt: "2026-05-15",
83
+ });
84
+ assert.match(content, /## Summary/);
85
+ assert.match(content, /## Timeline/);
86
+ assert.doesNotMatch(content, /## Compiled Truth/);
87
+ });
88
+ test("ingestSource stores optional research session metadata in wiki_sources", async () => {
89
+ const text = `Source-D: Research session metadata should persist. timestamp=${Date.now()}`;
90
+ const result = await mods.ingestMod.ingestSource(text, "text", "topics", {
91
+ sessionId: "compiler-research",
92
+ sessionName: "Compiler research",
93
+ });
94
+ const db = mods.dbMod.getDb();
95
+ const row = db.prepare(`SELECT session_id, session_name FROM wiki_sources WHERE id = ?`).get(result.source_id);
96
+ assert.deepEqual(row, {
97
+ session_id: "compiler-research",
98
+ session_name: "Compiler research",
99
+ });
100
+ });
101
+ // URL/PDF/repo tests are skipped if connectivity or dependencies are unavailable
102
+ test.skip("ingestSource(url) fetches and parses content — requires network", async () => {
103
+ // Integration test: run manually with network access
104
+ });
105
+ test.skip("ingestSource(pdf) parses PDF — requires pdf-parse", async () => {
106
+ // Integration test: run manually with pdf-parse installed
107
+ });
108
+ test.skip("ingestSource(repo) clones and summarises repo — requires network + git", async () => {
109
+ // Integration test: run manually
110
+ });
111
+ //# sourceMappingURL=ingest.test.js.map
@@ -0,0 +1,151 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wiki entity graph — link extraction and graph traversal
3
+ // ---------------------------------------------------------------------------
4
+ import { getDb } from "../store/db.js";
5
+ import { readPage, pageExists } from "./fs.js";
6
+ import { parseWikiFrontmatter } from "./frontmatter.js";
7
+ import { normalizeWikiPath } from "./path-utils.js";
8
+ const RELATIONSHIP_PATTERNS = [
9
+ { regex: /\bimplements\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "implements" },
10
+ { regex: /\bsupersedes\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "supersedes" },
11
+ { regex: /\bmember\s+of\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "member_of" },
12
+ { regex: /\bworks?\s+at\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "member_of" },
13
+ { regex: /\bworks\s+on\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "works_on" },
14
+ { regex: /\bdecided\s+by\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "decided_by" },
15
+ { regex: /\bdepends\s+on\s+([A-Za-z0-9][^\n.,;:!?]{1,60})/gi, linkType: "depends_on" },
16
+ ];
17
+ function nameToSlug(name) {
18
+ return name
19
+ .toLowerCase()
20
+ .trim()
21
+ .replace(/\s+/g, "-")
22
+ .replace(/[^a-z0-9-]/g, "");
23
+ }
24
+ function wikiLinkToPath(name) {
25
+ return `pages/${nameToSlug(name)}/index.md`;
26
+ }
27
+ function tagToTopicPath(tag) {
28
+ return `pages/topics/${nameToSlug(tag)}/index.md`;
29
+ }
30
+ /** Extract typed links from a page. Returns deduplicated WikiLink array. */
31
+ export function extractLinks(pagePath) {
32
+ const normalizedPath = normalizeWikiPath(pagePath);
33
+ const content = readPage(normalizedPath);
34
+ if (!content)
35
+ return [];
36
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
37
+ const links = [];
38
+ const seen = new Set();
39
+ const extractedAt = new Date().toISOString();
40
+ function addLink(toPage, linkType) {
41
+ const normalized = normalizeWikiPath(toPage);
42
+ if (!normalized || normalized === normalizedPath)
43
+ return;
44
+ const key = `${normalized}:${linkType}`;
45
+ if (seen.has(key))
46
+ return;
47
+ seen.add(key);
48
+ links.push({ from_page: normalizedPath, to_page: normalized, link_type: linkType, extracted_at: extractedAt });
49
+ }
50
+ // 1. [[Page Name]] wiki links
51
+ const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
52
+ let m;
53
+ while ((m = wikiLinkRe.exec(body)) !== null) {
54
+ const target = wikiLinkToPath(m[1].trim());
55
+ addLink(target, "references");
56
+ }
57
+ // 2. Frontmatter `related` array
58
+ for (const rel of fm.related ?? []) {
59
+ if (typeof rel === "string" && rel.trim()) {
60
+ addLink(normalizeWikiPath(rel.trim()), "references");
61
+ }
62
+ }
63
+ // 3. Frontmatter `tags` → topic pages (only if target page exists on disk)
64
+ for (const tag of fm.tags ?? []) {
65
+ if (typeof tag === "string" && tag.trim()) {
66
+ const target = tagToTopicPath(tag.trim());
67
+ if (pageExists(target)) {
68
+ addLink(target, "references");
69
+ }
70
+ }
71
+ }
72
+ // 4. Relationship statements in body text
73
+ for (const { regex, linkType } of RELATIONSHIP_PATTERNS) {
74
+ regex.lastIndex = 0;
75
+ while ((m = regex.exec(body)) !== null) {
76
+ const rawTarget = nameToSlug(m[1].trim());
77
+ if (rawTarget) {
78
+ addLink(`pages/${rawTarget}/index.md`, linkType);
79
+ }
80
+ }
81
+ }
82
+ return links;
83
+ }
84
+ /** Re-extract links for a page and sync to wiki_links table. */
85
+ export function updateLinks(pagePath) {
86
+ const normalizedPath = normalizeWikiPath(pagePath);
87
+ const db = getDb();
88
+ const existing = db.prepare(`SELECT COUNT(*) as c FROM wiki_links WHERE from_page = ?`).get(normalizedPath);
89
+ const removedCount = existing.c;
90
+ const newLinks = extractLinks(normalizedPath);
91
+ db.transaction(() => {
92
+ db.prepare(`DELETE FROM wiki_links WHERE from_page = ?`).run(normalizedPath);
93
+ const insert = db.prepare(`
94
+ INSERT OR IGNORE INTO wiki_links (from_page, to_page, link_type, extracted_at)
95
+ VALUES (?, ?, ?, ?)
96
+ `);
97
+ for (const link of newLinks) {
98
+ insert.run(link.from_page, link.to_page, link.link_type, link.extracted_at);
99
+ }
100
+ })();
101
+ return { added: newLinks.length, removed: removedCount };
102
+ }
103
+ /**
104
+ * Walk the entity graph from a starting page.
105
+ * Default depth 1, max depth 3. Returns flat list sorted by depth then page.
106
+ */
107
+ export function traverse(pagePath, linkType, depth = 1) {
108
+ const MAX_DEPTH = 3;
109
+ const effectiveDepth = Math.min(Math.max(depth, 1), MAX_DEPTH);
110
+ const normalizedPath = normalizeWikiPath(pagePath);
111
+ const db = getDb();
112
+ const results = [];
113
+ const visited = new Set([normalizedPath]);
114
+ const queue = [{ page: normalizedPath, depth: 0 }];
115
+ while (queue.length > 0) {
116
+ const { page: currentPage, depth: currentDepth } = queue.shift();
117
+ if (currentDepth >= effectiveDepth)
118
+ continue;
119
+ const outbound = (linkType
120
+ ? db.prepare(`SELECT to_page, link_type FROM wiki_links WHERE from_page = ? AND link_type = ?`).all(currentPage, linkType)
121
+ : db.prepare(`SELECT to_page, link_type FROM wiki_links WHERE from_page = ?`).all(currentPage));
122
+ for (const row of outbound) {
123
+ if (!visited.has(row.to_page)) {
124
+ visited.add(row.to_page);
125
+ results.push({ page: row.to_page, link_type: row.link_type, direction: "outbound", depth: currentDepth + 1 });
126
+ if (currentDepth + 1 < effectiveDepth) {
127
+ queue.push({ page: row.to_page, depth: currentDepth + 1 });
128
+ }
129
+ }
130
+ }
131
+ const inbound = (linkType
132
+ ? db.prepare(`SELECT from_page, link_type FROM wiki_links WHERE to_page = ? AND link_type = ?`).all(currentPage, linkType)
133
+ : db.prepare(`SELECT from_page, link_type FROM wiki_links WHERE to_page = ?`).all(currentPage));
134
+ for (const row of inbound) {
135
+ if (!visited.has(row.from_page)) {
136
+ visited.add(row.from_page);
137
+ results.push({ page: row.from_page, link_type: row.link_type, direction: "inbound", depth: currentDepth + 1 });
138
+ if (currentDepth + 1 < effectiveDepth) {
139
+ queue.push({ page: row.from_page, depth: currentDepth + 1 });
140
+ }
141
+ }
142
+ }
143
+ }
144
+ results.sort((a, b) => {
145
+ if (a.depth !== b.depth)
146
+ return a.depth - b.depth;
147
+ return a.page.localeCompare(b.page);
148
+ });
149
+ return results;
150
+ }
151
+ //# sourceMappingURL=links.js.map
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ // Sandbox: every test gets a fresh CHAPTERHOUSE_HOME under .test-work/
6
+ function makeSandbox() {
7
+ const dir = mkdtempSync(join(process.cwd(), ".test-work", "wiki-links-"));
8
+ process.env.CHAPTERHOUSE_HOME = dir;
9
+ return dir;
10
+ }
11
+ async function loadModules(sandbox) {
12
+ void sandbox; // CHAPTERHOUSE_HOME already set via env
13
+ const nonce = `${Date.now()}-${Math.random()}`;
14
+ const links = await import(new URL(`./links.js?c=${nonce}`, import.meta.url).href);
15
+ const indexManager = await import(new URL(`./index-manager.js?c=${nonce}`, import.meta.url).href);
16
+ const wikiFs = await import(new URL(`./fs.js?c=${nonce}`, import.meta.url).href);
17
+ return { links, indexManager, wikiFs };
18
+ }
19
+ test.before(() => {
20
+ mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
21
+ });
22
+ test("extractLinks finds [[Page Name]] wiki links in content", async () => {
23
+ const sandbox = makeSandbox();
24
+ try {
25
+ const { links, wikiFs } = await loadModules(sandbox);
26
+ wikiFs.writePage("pages/topics/rust/index.md", "---\ntitle: Rust\nsummary: Systems language\ntags: [rust]\nupdated: 2026-05-14\n---\n\n# Rust\n\nSee [[Tokio]] for async runtime.\n");
27
+ const extracted = links.extractLinks("pages/topics/rust/index.md");
28
+ assert.ok(extracted.length > 0, "Should find links");
29
+ const tokioLink = extracted.find((l) => l.to_page === "pages/tokio/index.md");
30
+ assert.ok(tokioLink, "Should find link to tokio page");
31
+ assert.equal(tokioLink?.link_type, "references");
32
+ assert.equal(tokioLink?.from_page, "pages/topics/rust/index.md");
33
+ }
34
+ finally {
35
+ rmSync(sandbox, { recursive: true, force: true });
36
+ }
37
+ });
38
+ test("extractLinks reads related from frontmatter", async () => {
39
+ const sandbox = makeSandbox();
40
+ try {
41
+ const { links, wikiFs } = await loadModules(sandbox);
42
+ wikiFs.writePage("pages/topics/rust/index.md", "---\ntitle: Rust\nsummary: Systems language\ntags: [rust]\nrelated: [pages/topics/wasm/index.md, pages/topics/async/index.md]\nupdated: 2026-05-14\n---\n\n# Rust\n\nContent.\n");
43
+ const extracted = links.extractLinks("pages/topics/rust/index.md");
44
+ const paths = extracted.map((l) => l.to_page);
45
+ assert.ok(paths.includes("pages/topics/wasm/index.md"), "Should include wasm from related");
46
+ assert.ok(paths.includes("pages/topics/async/index.md"), "Should include async from related");
47
+ assert.ok(extracted.every((l) => l.link_type === "references"), "Frontmatter related links should be type references");
48
+ }
49
+ finally {
50
+ rmSync(sandbox, { recursive: true, force: true });
51
+ }
52
+ });
53
+ test("extractLinks creates tag links only for existing topic pages", async () => {
54
+ const sandbox = makeSandbox();
55
+ try {
56
+ const { links, wikiFs } = await loadModules(sandbox);
57
+ // Create the rust topic page (the tag target)
58
+ wikiFs.writePage("pages/topics/rust/index.md", "---\ntitle: Rust\nsummary: Systems language\ntags: []\nupdated: 2026-05-14\n---\n\n# Rust\n");
59
+ // Write a page with a tag pointing to rust (exists) and typescript (does not exist)
60
+ wikiFs.writePage("pages/projects/myproject/index.md", "---\ntitle: My Project\nsummary: A project\ntags: [rust, typescript]\nupdated: 2026-05-14\n---\n\n# My Project\n");
61
+ const extracted = links.extractLinks("pages/projects/myproject/index.md");
62
+ const paths = extracted.map((l) => l.to_page);
63
+ assert.ok(paths.includes("pages/topics/rust/index.md"), "Should link to existing rust topic");
64
+ assert.ok(!paths.includes("pages/topics/typescript/index.md"), "Should NOT link to non-existent typescript topic");
65
+ }
66
+ finally {
67
+ rmSync(sandbox, { recursive: true, force: true });
68
+ }
69
+ });
70
+ test("updateLinks inserts links into wiki_links table", async () => {
71
+ const sandbox = makeSandbox();
72
+ try {
73
+ const { links, wikiFs } = await loadModules(sandbox);
74
+ wikiFs.writePage("pages/topics/tokio/index.md", "---\ntitle: Tokio\nsummary: Async runtime for Rust\ntags: [rust, async]\nrelated: [pages/topics/rust/index.md]\nupdated: 2026-05-14\n---\n\n# Tokio\n\nAsync runtime.\n");
75
+ const result = links.updateLinks("pages/topics/tokio/index.md");
76
+ assert.ok(result.added > 0, "Should insert links");
77
+ // Verify via traverse
78
+ const neighbors = links.traverse("pages/topics/tokio/index.md");
79
+ const pages = neighbors.map((n) => n.page);
80
+ assert.ok(pages.includes("pages/topics/rust/index.md"), "Should find rust in neighbors");
81
+ }
82
+ finally {
83
+ rmSync(sandbox, { recursive: true, force: true });
84
+ }
85
+ });
86
+ test("updateLinks removes stale links on re-run", async () => {
87
+ const sandbox = makeSandbox();
88
+ try {
89
+ const { links, wikiFs } = await loadModules(sandbox);
90
+ // Initial page with two related links
91
+ wikiFs.writePage("pages/topics/stale-tokio/index.md", "---\ntitle: Tokio\nsummary: Async runtime\ntags: []\nrelated: [pages/topics/rust/index.md, pages/topics/async/index.md]\nupdated: 2026-05-14\n---\n\n# Tokio\n");
92
+ links.updateLinks("pages/topics/stale-tokio/index.md");
93
+ let neighbors = links.traverse("pages/topics/stale-tokio/index.md");
94
+ assert.ok(neighbors.length === 2, "Should have 2 initial links");
95
+ // Update page to remove one related link
96
+ wikiFs.writePage("pages/topics/stale-tokio/index.md", "---\ntitle: Tokio\nsummary: Async runtime\ntags: []\nrelated: [pages/topics/rust/index.md]\nupdated: 2026-05-14\n---\n\n# Tokio\n");
97
+ links.updateLinks("pages/topics/stale-tokio/index.md");
98
+ neighbors = links.traverse("pages/topics/stale-tokio/index.md");
99
+ assert.ok(neighbors.length === 1, "Should have 1 link after stale removal");
100
+ assert.equal(neighbors[0].page, "pages/topics/rust/index.md");
101
+ }
102
+ finally {
103
+ rmSync(sandbox, { recursive: true, force: true });
104
+ }
105
+ });
106
+ test("traverse returns 1-hop neighbors correctly", async () => {
107
+ const sandbox = makeSandbox();
108
+ try {
109
+ const { links, wikiFs } = await loadModules(sandbox);
110
+ wikiFs.writePage("pages/topics/trav-rust/index.md", "---\ntitle: Rust\nsummary: Lang\ntags: []\nrelated: [pages/topics/trav-tokio/index.md, pages/topics/trav-wasm/index.md]\nupdated: 2026-05-14\n---\n\n# Rust\n");
111
+ links.updateLinks("pages/topics/trav-rust/index.md");
112
+ const results = links.traverse("pages/topics/trav-rust/index.md", undefined, 1);
113
+ assert.ok(results.length === 2, `Should have 2 neighbors, got ${results.length}`);
114
+ assert.ok(results.every((r) => r.depth === 1), "All results should be at depth 1");
115
+ assert.ok(results.every((r) => r.direction === "outbound"), "Should be outbound from rust");
116
+ }
117
+ finally {
118
+ rmSync(sandbox, { recursive: true, force: true });
119
+ }
120
+ });
121
+ test("traverse handles missing page gracefully (no crash)", async () => {
122
+ const sandbox = makeSandbox();
123
+ try {
124
+ const { links } = await loadModules(sandbox);
125
+ // Page doesn't exist — should return empty, not throw
126
+ const results = links.traverse("pages/topics/nonexistent/index.md", undefined, 1);
127
+ assert.ok(Array.isArray(results), "Should return an array");
128
+ assert.equal(results.length, 0, "Should return empty array for missing page");
129
+ }
130
+ finally {
131
+ rmSync(sandbox, { recursive: true, force: true });
132
+ }
133
+ });
134
+ test("wikiSearch results include related neighbors from wiki_links", async () => {
135
+ const sandbox = makeSandbox();
136
+ try {
137
+ const { links, indexManager, wikiFs } = await loadModules(sandbox);
138
+ wikiFs.writePage("pages/topics/search-rust/index.md", "---\ntitle: Rust\nsummary: Systems programming language\ntags: [rust]\nrelated: [pages/topics/search-tokio/index.md]\nupdated: 2026-05-14\n---\n\n# Rust\n\nSystems language.\n");
139
+ wikiFs.writePage("pages/topics/search-tokio/index.md", "---\ntitle: Tokio\nsummary: Async runtime for Rust\ntags: [tokio]\nupdated: 2026-05-14\n---\n\n# Tokio\n\nAsync runtime.\n");
140
+ indexManager.rebuildWikiIndex(); // also calls updateLinks via backfill
141
+ links.updateLinks("pages/topics/search-rust/index.md"); // ensure link exists
142
+ const results = indexManager.wikiSearch("rust systems");
143
+ const rustResult = results.find((r) => r.path === "pages/topics/search-rust/index.md");
144
+ assert.ok(rustResult, "Should find rust page");
145
+ assert.ok(rustResult?.related && rustResult.related.length > 0, "Rust result should have related neighbors");
146
+ const relatedPages = rustResult.related.map((r) => r.page);
147
+ assert.ok(relatedPages.includes("pages/topics/search-tokio/index.md"), "Should include tokio as related");
148
+ }
149
+ finally {
150
+ rmSync(sandbox, { recursive: true, force: true });
151
+ }
152
+ });
153
+ test("rebuildWikiIndex cleans up wiki_links for deleted pages", async () => {
154
+ const sandbox = makeSandbox();
155
+ try {
156
+ const { links, indexManager, wikiFs } = await loadModules(sandbox);
157
+ wikiFs.writePage("pages/topics/del-rust/index.md", "---\ntitle: Rust\nsummary: Lang\ntags: []\nrelated: [pages/topics/del-tokio/index.md]\nupdated: 2026-05-14\n---\n\n# Rust\n");
158
+ wikiFs.writePage("pages/topics/del-tokio/index.md", "---\ntitle: Tokio\nsummary: Async runtime\ntags: []\nupdated: 2026-05-14\n---\n\n# Tokio\n");
159
+ indexManager.rebuildWikiIndex();
160
+ links.updateLinks("pages/topics/del-rust/index.md");
161
+ // Verify link exists
162
+ const before = links.traverse("pages/topics/del-rust/index.md");
163
+ assert.ok(before.some((r) => r.page === "pages/topics/del-tokio/index.md"), "Should have tokio link before deletion");
164
+ // Delete rust page from disk, then rebuild
165
+ wikiFs.deletePage("pages/topics/del-rust/index.md");
166
+ indexManager.rebuildWikiIndex();
167
+ // Links from rust should be cleaned up
168
+ const after = links.traverse("pages/topics/del-tokio/index.md");
169
+ const hasRustInbound = after.some((r) => r.page === "pages/topics/del-rust/index.md");
170
+ assert.ok(!hasRustInbound, "Inbound link from deleted rust page should be gone after rebuild");
171
+ }
172
+ finally {
173
+ rmSync(sandbox, { recursive: true, force: true });
174
+ }
175
+ });
176
+ //# sourceMappingURL=links.test.js.map
@@ -9,17 +9,22 @@ async function loadModules() {
9
9
  const nonce = `${Date.now()}-${Math.random()}`;
10
10
  const migrate = await import(new URL(`./migrate-topics.js?case=${nonce}`, import.meta.url).href);
11
11
  const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
- return { migrate, wikiFs };
12
+ const indexManager = await import(new URL(`./index-manager.js?case=${nonce}`, import.meta.url).href);
13
+ return { migrate, wikiFs, indexManager };
13
14
  }
14
- test.beforeEach(() => {
15
+ test.beforeEach(async () => {
15
16
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
17
+ const dbModule = await import("../store/db.js");
18
+ dbModule.closeDb();
16
19
  rmSync(sandboxRoot, { recursive: true, force: true });
17
20
  });
18
- test.after(() => {
21
+ test.after(async () => {
22
+ const dbModule = await import("../store/db.js");
23
+ dbModule.closeDb();
19
24
  rmSync(sandboxRoot, { recursive: true, force: true });
20
25
  });
21
26
  test("enforceTopicStructure relocates bare entity pages and folds facet pages", async () => {
22
- const { migrate, wikiFs } = await loadModules();
27
+ const { migrate, wikiFs, indexManager } = await loadModules();
23
28
  wikiFs.ensureWikiStructure();
24
29
  wikiFs.writePage("pages/projects/chapterhouse.md", "---\ntitle: Chapterhouse\n---\n\n# Chapterhouse\n\n- Source at ~/projects/chapterhouse\n");
25
30
  wikiFs.writePage("pages/projects/chapterhouse-feature-ideas.md", "---\ntitle: Chapterhouse Feature Ideas\n---\n\n# Feature Ideas\n\n- Add wiki topics\n");
@@ -37,8 +42,13 @@ test("enforceTopicStructure relocates bare entity pages and folds facet pages",
37
42
  assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /Source at ~\/projects\/chapterhouse/);
38
43
  assert.match(wikiFs.readPage("pages/projects/chapterhouse/feature-ideas.md") ?? "", /Add wiki topics/);
39
44
  // Index reflects the new shape.
40
- assert.match(wikiFs.readIndexFile(), /^- \[Chapterhouse\]\(pages\/projects\/chapterhouse\/index\.md\) — /m);
41
- assert.match(wikiFs.readIndexFile(), /^ {2}- \[.+\]\(pages\/projects\/chapterhouse\/feature-ideas\.md\) — /m);
45
+ const indexedPaths = indexManager.parseIndex().map((entry) => entry.path).sort();
46
+ assert.deepEqual(indexedPaths, [
47
+ "pages/conversations/2026-05-09.md",
48
+ "pages/projects/chapterhouse/decisions.md",
49
+ "pages/projects/chapterhouse/feature-ideas.md",
50
+ "pages/projects/chapterhouse/index.md",
51
+ ]);
42
52
  // Idempotent.
43
53
  assert.equal(migrate.enforceTopicStructure(), 0);
44
54
  });
@@ -0,0 +1,118 @@
1
+ import { config } from "../config.js";
2
+ import { getDb } from "../store/db.js";
3
+ import { childLogger } from "../util/logger.js";
4
+ import { runConsolidation } from "./consolidation.js";
5
+ const DEFAULT_PKB_CONSOLIDATION_HOUR = 3;
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ export class WikiConsolidationScheduler {
8
+ env;
9
+ runConsolidationImpl;
10
+ now;
11
+ log;
12
+ setTimeoutImpl;
13
+ clearTimeoutImpl;
14
+ timeoutHandle;
15
+ activeRun;
16
+ started = false;
17
+ running = false;
18
+ constructor(options = {}) {
19
+ this.env = options.env ?? process.env;
20
+ this.runConsolidationImpl = options.runConsolidation ?? (() => runConsolidation(getDb()));
21
+ this.now = options.now ?? (() => new Date());
22
+ this.log = options.log ?? childLogger("wiki.scheduler");
23
+ this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
24
+ this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
25
+ }
26
+ start() {
27
+ if (this.started) {
28
+ return;
29
+ }
30
+ const enabled = parseEnabledEnv(this.env.CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED);
31
+ if (!enabled) {
32
+ this.log.info({ enabled }, "Wiki consolidation scheduler disabled");
33
+ return;
34
+ }
35
+ const hour = parseHourEnv(this.env.CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR);
36
+ this.started = true;
37
+ this.scheduleIn(delayUntilNextHour(this.now(), hour), hour, "initial");
38
+ }
39
+ async stop() {
40
+ if (this.timeoutHandle) {
41
+ this.clearTimeoutImpl(this.timeoutHandle);
42
+ this.timeoutHandle = undefined;
43
+ }
44
+ this.started = false;
45
+ await this.activeRun;
46
+ }
47
+ scheduleIn(delayMs, hour, trigger) {
48
+ this.timeoutHandle = this.setTimeoutImpl(() => {
49
+ this.timeoutHandle = undefined;
50
+ const tracked = this.runScheduledConsolidation(hour, trigger).finally(() => {
51
+ if (this.activeRun === tracked) {
52
+ this.activeRun = undefined;
53
+ }
54
+ });
55
+ this.activeRun = tracked;
56
+ void tracked;
57
+ }, delayMs);
58
+ this.timeoutHandle?.unref?.();
59
+ this.log.info({ delay_ms: delayMs, hour, trigger }, "Wiki consolidation scheduled");
60
+ }
61
+ async runScheduledConsolidation(hour, trigger) {
62
+ if (this.running) {
63
+ this.log.warn({ hour, trigger }, "Wiki consolidation run skipped because a previous run is still active");
64
+ this.scheduleIn(DAY_MS, hour, "daily");
65
+ return;
66
+ }
67
+ this.running = true;
68
+ try {
69
+ const result = await this.runConsolidationImpl();
70
+ this.log.info({ trigger, hour, result }, "Wiki consolidation scheduled run complete");
71
+ }
72
+ catch (error) {
73
+ const err = error instanceof Error ? error.message : String(error);
74
+ if (this.log.error) {
75
+ this.log.error({ trigger, hour, err }, "Wiki consolidation scheduled run failed");
76
+ }
77
+ else {
78
+ this.log.warn({ trigger, hour, err }, "Wiki consolidation scheduled run failed");
79
+ }
80
+ }
81
+ finally {
82
+ this.running = false;
83
+ if (this.started) {
84
+ this.scheduleIn(DAY_MS, hour, "daily");
85
+ }
86
+ }
87
+ }
88
+ }
89
+ function parseEnabledEnv(rawValue) {
90
+ const normalized = rawValue?.trim();
91
+ if (!normalized) {
92
+ return config.pkbConsolidationEnabled;
93
+ }
94
+ if (normalized !== "true" && normalized !== "false") {
95
+ throw new Error(`CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED must be 'true' or 'false', got: "${rawValue}"`);
96
+ }
97
+ return normalized === "true";
98
+ }
99
+ function parseHourEnv(rawValue) {
100
+ const normalized = rawValue?.trim();
101
+ if (!normalized) {
102
+ return config.pkbConsolidationHour ?? DEFAULT_PKB_CONSOLIDATION_HOUR;
103
+ }
104
+ const parsed = Number(normalized);
105
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 23) {
106
+ throw new Error(`CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR must be an integer between 0 and 23, got: "${rawValue}"`);
107
+ }
108
+ return parsed;
109
+ }
110
+ function delayUntilNextHour(now, hour) {
111
+ const next = new Date(now);
112
+ next.setUTCHours(hour, 0, 0, 0);
113
+ if (next.getTime() <= now.getTime()) {
114
+ next.setUTCDate(next.getUTCDate() + 1);
115
+ }
116
+ return next.getTime() - now.getTime();
117
+ }
118
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ function createTimers() {
4
+ let nextId = 1;
5
+ const timeouts = [];
6
+ return {
7
+ timeouts,
8
+ setTimeoutImpl(callback, delayMs) {
9
+ const handle = { id: nextId++, unref() { } };
10
+ timeouts.push({ handle, callback, delayMs, cleared: false });
11
+ return handle;
12
+ },
13
+ clearTimeoutImpl(handle) {
14
+ const entry = timeouts.find((item) => item.handle === handle);
15
+ if (entry)
16
+ entry.cleared = true;
17
+ },
18
+ };
19
+ }
20
+ async function loadSchedulerModule() {
21
+ return await import(new URL(`./scheduler.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
22
+ }
23
+ test("WikiConsolidationScheduler stays disabled when CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED is false", async () => {
24
+ const schedulerModule = await loadSchedulerModule();
25
+ const timers = createTimers();
26
+ let runs = 0;
27
+ const scheduler = new schedulerModule.WikiConsolidationScheduler({
28
+ env: { CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED: "false" },
29
+ runConsolidation: async () => {
30
+ runs += 1;
31
+ return { truthRewrites: 0, fragmentsMerged: 0, linksRepaired: 0, pagesReindexed: 0, sourcesArchived: 0, staleSessionsNotified: 0, llmCallsUsed: 0 };
32
+ },
33
+ now: () => new Date("2026-05-14T22:30:03.086-04:00"),
34
+ setTimeoutImpl: timers.setTimeoutImpl,
35
+ clearTimeoutImpl: timers.clearTimeoutImpl,
36
+ });
37
+ scheduler.start();
38
+ assert.equal(timers.timeouts.length, 0);
39
+ assert.equal(runs, 0);
40
+ });
41
+ test("WikiConsolidationScheduler schedules the next run for the configured hour and reschedules daily", async () => {
42
+ const schedulerModule = await loadSchedulerModule();
43
+ const timers = createTimers();
44
+ let runs = 0;
45
+ const scheduler = new schedulerModule.WikiConsolidationScheduler({
46
+ env: { CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED: "true", CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: "3" },
47
+ runConsolidation: async () => {
48
+ runs += 1;
49
+ return { truthRewrites: 0, fragmentsMerged: 0, linksRepaired: 0, pagesReindexed: 0, sourcesArchived: 0, staleSessionsNotified: 0, llmCallsUsed: 0 };
50
+ },
51
+ now: () => new Date("2026-05-14T22:30:03.086-04:00"),
52
+ setTimeoutImpl: timers.setTimeoutImpl,
53
+ clearTimeoutImpl: timers.clearTimeoutImpl,
54
+ });
55
+ scheduler.start();
56
+ assert.equal(timers.timeouts.length, 1);
57
+ assert.equal(timers.timeouts[0]?.delayMs, 1_796_914);
58
+ timers.timeouts[0]?.callback();
59
+ await Promise.resolve();
60
+ assert.equal(runs, 1);
61
+ assert.equal(timers.timeouts.length, 2);
62
+ assert.equal(timers.timeouts[1]?.delayMs, 86_400_000);
63
+ });
64
+ //# sourceMappingURL=scheduler.test.js.map