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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +238 -2
- package/dist/api/server.test.js +199 -0
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agents.js +3 -4
- package/dist/copilot/agents.test.js +12 -1
- package/dist/copilot/orchestrator.js +12 -1
- package/dist/copilot/orchestrator.test.js +3 -7
- package/dist/copilot/system-message.js +12 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +193 -375
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +80 -59
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +19 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +108 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/paths.js +31 -11
- package/dist/store/db.js +187 -4
- package/dist/store/db.test.js +66 -2
- package/dist/test/helpers/reset-singletons.js +8 -0
- package/dist/test/helpers/reset-singletons.test.js +37 -0
- package/dist/test/setup-env.js +9 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +143 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +305 -330
- package/dist/wiki/index-manager.test.js +265 -144
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +1 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
- package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- 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
|
package/dist/wiki/log-manager.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
29
|
+
appendFileSync(getLogPath(), entry, "utf-8");
|
|
28
30
|
}
|
|
29
31
|
function rotateLogIfNeeded() {
|
|
30
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
assert.
|
|
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
|
|
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