chapterhouse 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  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/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -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
@@ -6,7 +6,9 @@ import { join } from "path";
6
6
  import { WIKI_PAGES_DIR } from "../paths.js";
7
7
  import { ensureWikiStructure, writeFileAtomic } from "./fs.js";
8
8
  export const ACTION_LOG_PATH = "pages/_meta/log.md";
9
- const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
9
+ function getLogPath() {
10
+ return join(WIKI_PAGES_DIR, "_meta", "log.md");
11
+ }
10
12
  const MAX_LOG_ENTRIES = 500;
11
13
  const LOG_ENTRY_RE = /^## \[/gm;
12
14
  const INITIAL_LOG = `# Wiki Action Log
@@ -24,17 +26,18 @@ export function appendLog(type, subject, agent = resolveAgentName()) {
24
26
  const now = new Date();
25
27
  const ts = now.toISOString().slice(0, 16).replace("T", " ");
26
28
  const entry = `## [${ts}] ${type} | ${subject} | ${agent}\n\n`;
27
- appendFileSync(LOG_PATH, entry, "utf-8");
29
+ appendFileSync(getLogPath(), entry, "utf-8");
28
30
  }
29
31
  function rotateLogIfNeeded() {
30
- const currentLog = readFileSync(LOG_PATH, "utf-8");
32
+ const logPath = getLogPath();
33
+ const currentLog = readFileSync(logPath, "utf-8");
31
34
  const entryCount = (currentLog.match(LOG_ENTRY_RE) ?? []).length;
32
35
  if (entryCount < MAX_LOG_ENTRIES) {
33
36
  return;
34
37
  }
35
38
  const archivePath = join(WIKI_PAGES_DIR, "_meta", `log-${new Date().getFullYear()}.md`);
36
39
  if (!existsSync(archivePath)) {
37
- renameSync(LOG_PATH, archivePath);
40
+ renameSync(logPath, archivePath);
38
41
  }
39
42
  else {
40
43
  const archivedEntries = extractEntries(currentLog);
@@ -43,7 +46,7 @@ function rotateLogIfNeeded() {
43
46
  appendFileSync(archivePath, `${separator}${archivedEntries.join("\n\n")}\n\n`, "utf-8");
44
47
  }
45
48
  }
46
- writeFileAtomic(LOG_PATH, INITIAL_LOG);
49
+ writeFileAtomic(logPath, INITIAL_LOG);
47
50
  }
48
51
  function extractEntries(content) {
49
52
  return content
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { existsSync, 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-log-${process.pid}`);
7
8
  const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
@@ -18,9 +19,12 @@ function resetSandbox() {
18
19
  rmSync(sandboxRoot, { recursive: true, force: true });
19
20
  }
20
21
  test.beforeEach(() => {
22
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
21
23
  resetSandbox();
24
+ resetSingletons();
22
25
  });
23
26
  test.after(() => {
27
+ resetSingletons();
24
28
  rmSync(sandboxRoot, { recursive: true, force: true });
25
29
  });
26
30
  test("appendLog writes action entries to pages/_meta/log.md with the resolved agent name", async () => {
@@ -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
@@ -0,0 +1,51 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wiki timeline — append-only timeline section management
3
+ // ---------------------------------------------------------------------------
4
+ import { readPage, writePage } from "./fs.js";
5
+ import { parseWikiFrontmatter } from "./frontmatter.js";
6
+ import { upsertWikiPage } from "./index-manager.js";
7
+ import { normalizeWikiPath } from "./path-utils.js";
8
+ const TIMELINE_HEADING = "## Timeline";
9
+ /**
10
+ * Append an entry to the `## Timeline` section of a wiki page.
11
+ * Creates the section (and the page itself) if absent.
12
+ * Calls upsertWikiPage to keep SQLite in sync after writing.
13
+ */
14
+ export function appendTimeline(pagePath, entry, timestamp) {
15
+ const normalized = normalizeWikiPath(pagePath);
16
+ const ts = timestamp ?? new Date().toISOString();
17
+ const block = `\n### ${ts}\n\n${entry.trim()}\n`;
18
+ let content = readPage(normalized);
19
+ if (!content) {
20
+ // Create a minimal page with frontmatter + empty timeline
21
+ const title = deriveTitleFromPath(normalized);
22
+ content = `---\ntitle: ${title}\nsummary: ${title}\nupdated: ${ts.slice(0, 10)}\ntags: []\n---\n\n# ${title}\n\n${TIMELINE_HEADING}\n${block}`;
23
+ writePage(normalized, content);
24
+ }
25
+ else if (content.includes(TIMELINE_HEADING)) {
26
+ // Append after the last content in the timeline section
27
+ const idx = content.lastIndexOf(TIMELINE_HEADING);
28
+ const before = content.slice(0, idx + TIMELINE_HEADING.length);
29
+ const after = content.slice(idx + TIMELINE_HEADING.length);
30
+ content = before + after + block;
31
+ writePage(normalized, content);
32
+ }
33
+ else {
34
+ // Add timeline section at end of page
35
+ const trimmed = content.trimEnd();
36
+ content = trimmed + `\n\n${TIMELINE_HEADING}\n${block}`;
37
+ writePage(normalized, content);
38
+ }
39
+ // Keep SQLite in sync
40
+ const { parsed: fm } = parseWikiFrontmatter(readPage(normalized) ?? content);
41
+ const summary = fm.summary ?? deriveTitleFromPath(normalized);
42
+ upsertWikiPage(normalized, fm, summary);
43
+ }
44
+ function deriveTitleFromPath(path) {
45
+ const segs = path.split("/").filter(Boolean);
46
+ const file = segs[segs.length - 1] || path;
47
+ const base = file.replace(/\.md$/, "");
48
+ const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
49
+ return titleBase.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
50
+ }
51
+ //# sourceMappingURL=timeline.js.map
@@ -0,0 +1,65 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Timeline tests — appendTimeline
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
+ let SANDBOX;
10
+ let mods;
11
+ test.before(async () => {
12
+ mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
13
+ SANDBOX = mkdtempSync(join(process.cwd(), ".test-work", "timeline-"));
14
+ process.env.CHAPTERHOUSE_HOME = SANDBOX;
15
+ const nonce = `${Date.now()}-${Math.random()}`;
16
+ const timeline = await import(new URL(`./timeline.js?c=${nonce}`, import.meta.url).href);
17
+ const wikiFs = await import(new URL(`./fs.js?c=${nonce}`, import.meta.url).href);
18
+ const indexManager = await import(new URL(`./index-manager.js?c=${nonce}`, import.meta.url).href);
19
+ mods = { timeline, wikiFs, indexManager };
20
+ mods.wikiFs.ensureWikiStructure();
21
+ });
22
+ test.after(() => {
23
+ try {
24
+ rmSync(SANDBOX, { recursive: true, force: true });
25
+ }
26
+ catch { /* best-effort */ }
27
+ });
28
+ test("appendTimeline creates page if it doesn't exist", () => {
29
+ mods.timeline.appendTimeline("pages/topics/rust/index.md", "First mention of Rust");
30
+ const content = mods.wikiFs.readPage("pages/topics/rust/index.md");
31
+ assert.ok(content, "Page should be created");
32
+ assert.ok(content.includes("## Timeline"), "Should have Timeline section");
33
+ assert.ok(content.includes("First mention of Rust"), "Should contain entry text");
34
+ });
35
+ test("appendTimeline creates Timeline section if absent", () => {
36
+ mods.wikiFs.writePage("pages/topics/go/index.md", "---\ntitle: Go\nsummary: Go programming language\nupdated: 2026-01-01\ntags: []\n---\n\n# Go\n\nSimple language.\n");
37
+ mods.timeline.appendTimeline("pages/topics/go/index.md", "Go 1.22 released");
38
+ const content = mods.wikiFs.readPage("pages/topics/go/index.md");
39
+ assert.ok(content.includes("## Timeline"), "Should have added Timeline section");
40
+ assert.ok(content.includes("Go 1.22 released"), "Should contain the entry");
41
+ assert.ok(content.includes("# Go"), "Should preserve original content");
42
+ });
43
+ test("appendTimeline is append-only: existing entries are unchanged", () => {
44
+ mods.timeline.appendTimeline("pages/topics/node/index.md", "First entry");
45
+ const after1 = mods.wikiFs.readPage("pages/topics/node/index.md");
46
+ assert.ok(after1.includes("First entry"));
47
+ mods.timeline.appendTimeline("pages/topics/node/index.md", "Second entry");
48
+ const after2 = mods.wikiFs.readPage("pages/topics/node/index.md");
49
+ assert.ok(after2.includes("First entry"), "First entry should still be present");
50
+ assert.ok(after2.includes("Second entry"), "Second entry should be appended");
51
+ assert.ok(after2.indexOf("First entry") < after2.indexOf("Second entry"), "Order should be preserved");
52
+ });
53
+ test("appendTimeline calls upsertWikiPage to sync SQLite", () => {
54
+ mods.timeline.appendTimeline("pages/topics/typescript/index.md", "TypeScript 5.0 notes");
55
+ const results = mods.indexManager.wikiSearch("typescript");
56
+ assert.ok(results.some((r) => r.path === "pages/topics/typescript/index.md"), "Page should be in SQLite after appendTimeline");
57
+ });
58
+ test("appendTimeline adds timestamp heading to each entry", () => {
59
+ const ts = "2026-05-14T20:45:55.900Z";
60
+ mods.timeline.appendTimeline("pages/topics/deno/index.md", "Deno 2.0 released", ts);
61
+ const content = mods.wikiFs.readPage("pages/topics/deno/index.md");
62
+ assert.ok(content.includes(`### ${ts}`), "Should have timestamp heading");
63
+ assert.ok(content.includes("Deno 2.0 released"), "Should have entry text");
64
+ });
65
+ //# sourceMappingURL=timeline.test.js.map
@@ -31,7 +31,7 @@ export const EXEMPT_PREFIXES = [
31
31
  export const EXEMPT_PAGES = [
32
32
  "pages/index.md",
33
33
  ];
34
- /** Map a `remember`-style category name to its directory under pages/. */
34
+ /** Map a legacy category name to its directory under pages/. */
35
35
  const CATEGORY_DIR_MAP = {
36
36
  person: "people",
37
37
  people: "people",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"