chapterhouse 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,9 @@ import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
16
16
  import { assertAgentEditAccess } from "./agent-edit-access.js";
17
17
  import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
18
18
  import { createTeamRouter } from "./team.js";
19
- import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
19
+ import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
20
20
  import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
21
+ import { normalizeWikiPath } from "../wiki/path-utils.js";
21
22
  import { loadRegistry, saveRegistry } from "../wiki/project-registry.js";
22
23
  import { getProjectRulesPath, listTopLevelSoftRules, loadProjectRules, loadProjectRuleSummary, renderInitialProjectRulesPage, saveProjectRulesHardFields, saveProjectRulesSoftRules, } from "../wiki/project-rules.js";
23
24
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
@@ -192,6 +193,78 @@ function createProjectDetailPayload(slug, cwd) {
192
193
  softRules: listTopLevelSoftRules(rules.soft),
193
194
  };
194
195
  }
196
+ function coerceWikiPageUpdated(path, updated) {
197
+ const normalized = updated?.trim();
198
+ if (normalized) {
199
+ return normalized;
200
+ }
201
+ try {
202
+ return statSync(join(getWikiDir(), path)).mtime.toISOString();
203
+ }
204
+ catch {
205
+ return "unknown";
206
+ }
207
+ }
208
+ function parseWikiSourcePages(value) {
209
+ if (!value) {
210
+ return [];
211
+ }
212
+ try {
213
+ const parsed = JSON.parse(value);
214
+ return Array.isArray(parsed)
215
+ ? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
216
+ : [];
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ }
222
+ function listWikiSources(page) {
223
+ const rows = getDb().prepare(`
224
+ SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
225
+ FROM wiki_sources
226
+ ORDER BY ingested_at DESC, id ASC
227
+ `).all();
228
+ return rows
229
+ .filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
230
+ .map((row) => ({
231
+ source_url: row.origin,
232
+ path: row.raw_path ?? undefined,
233
+ kind: row.source_type,
234
+ status: row.status,
235
+ session_id: row.session_id,
236
+ ingested_at: row.ingested_at,
237
+ }));
238
+ }
239
+ function wikiPathToSlug(path) {
240
+ const segments = normalizeWikiPath(path).split("/").filter(Boolean);
241
+ const file = segments[segments.length - 1] ?? path;
242
+ const base = file.endsWith(".md") ? file.slice(0, -3) : file;
243
+ if (base === "index" && segments.length >= 2) {
244
+ return segments[segments.length - 2] ?? base;
245
+ }
246
+ return base;
247
+ }
248
+ function listWikiLinks(page) {
249
+ const rows = page
250
+ ? getDb().prepare(`
251
+ SELECT from_page, to_page, link_type
252
+ FROM wiki_links
253
+ WHERE from_page = ? OR to_page = ?
254
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
255
+ `).all(page, page)
256
+ : getDb().prepare(`
257
+ SELECT from_page, to_page, link_type
258
+ FROM wiki_links
259
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
260
+ `).all();
261
+ return rows.map((row) => ({
262
+ source_slug: wikiPathToSlug(row.from_page),
263
+ target_slug: wikiPathToSlug(row.to_page),
264
+ link_type: row.link_type,
265
+ ...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
266
+ }));
267
+ }
195
268
  // Load a configured API token when present; startup validation below enforces auth.
196
269
  let apiToken = null;
197
270
  try {
@@ -312,6 +385,13 @@ function readPathParam(req) {
312
385
  }
313
386
  return raw;
314
387
  }
388
+ function readOptionalPageFilter(req) {
389
+ const raw = req.query.page;
390
+ if (typeof raw !== "string" || !raw.trim()) {
391
+ return undefined;
392
+ }
393
+ return normalizeWikiPath(raw.trim());
394
+ }
315
395
  function assertValidPagePath(path) {
316
396
  try {
317
397
  assertPagePath(path);
@@ -1448,7 +1528,7 @@ app.get("/api/wiki/pages", async (req, res) => {
1448
1528
  summary: e.summary,
1449
1529
  section: e.section,
1450
1530
  tags: e.tags || [],
1451
- updated: e.updated || "",
1531
+ updated: coerceWikiPageUpdated(e.path, e.updated),
1452
1532
  scope: getWikiPageScope(e.path),
1453
1533
  }));
1454
1534
  const orphanResults = listPages()
@@ -1459,11 +1539,19 @@ app.get("/api/wiki/pages", async (req, res) => {
1459
1539
  summary: "",
1460
1540
  section: "Unindexed",
1461
1541
  tags: [],
1462
- updated: "",
1542
+ updated: coerceWikiPageUpdated(p, undefined),
1463
1543
  scope: getWikiPageScope(p),
1464
1544
  }));
1465
1545
  res.json([...indexedResults, ...orphanResults]);
1466
1546
  });
1547
+ app.get("/api/wiki/sources", async (req, res) => {
1548
+ ensureWikiStructure();
1549
+ res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
1550
+ });
1551
+ app.get("/api/wiki/links", async (req, res) => {
1552
+ ensureWikiStructure();
1553
+ res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
1554
+ });
1467
1555
  app.get("/api/wiki/page", async (req, res) => {
1468
1556
  const path = assertValidPagePath(readPathParam(req));
1469
1557
  const authorizationHeader = typeof req.headers.authorization === "string"
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
6
  import Database from "better-sqlite3";
7
+ import { z } from "zod";
7
8
  import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
8
9
  test("supports API_TOKEN env var and personal health route helpers", async () => {
9
10
  const runtime = await import("./server-runtime.js");
@@ -83,6 +84,23 @@ test("formats named SSE status events", async () => {
83
84
  assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
84
85
  });
85
86
  const repoRoot = process.cwd();
87
+ const wikiLinkResponseSchema = z.object({
88
+ links: z.array(z.object({
89
+ source_slug: z.string(),
90
+ target_slug: z.string(),
91
+ link_type: z.string(),
92
+ }).passthrough()),
93
+ });
94
+ const wikiSourcesResponseSchema = z.object({
95
+ sources: z.array(z.object({
96
+ source_url: z.string().optional(),
97
+ path: z.string().optional(),
98
+ kind: z.string(),
99
+ status: z.string(),
100
+ session_id: z.string().nullable().optional(),
101
+ ingested_at: z.string().nullable().optional(),
102
+ }).passthrough()),
103
+ });
86
104
  function getProjectDbPath(testRoot) {
87
105
  return join(testRoot, ".chapterhouse", "chapterhouse.db");
88
106
  }
@@ -873,6 +891,60 @@ test("server wiki route still returns 404 for other missing wiki pages", async (
873
891
  assert.deepEqual(await response.json(), { error: "Page not found" });
874
892
  });
875
893
  });
894
+ test("GET /api/wiki/pages coerces empty updated fields to page mtimes", async () => {
895
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
896
+ const indexedPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated", "index.md");
897
+ const indexedMtime = new Date("2026-05-15T12:00:00.000Z");
898
+ mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "coerced-updated"), { recursive: true });
899
+ writeFileSync(indexedPath, "# Coerced Updated\n", "utf-8");
900
+ utimesSync(indexedPath, indexedMtime, indexedMtime);
901
+ const orphanPath = join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated", "index.md");
902
+ const orphanMtime = new Date("2026-05-15T13:30:00.000Z");
903
+ mkdirSync(join(testRoot, ".chapterhouse", "wiki", "pages", "topics", "orphan-updated"), { recursive: true });
904
+ writeFileSync(orphanPath, "# Orphan Updated\n", "utf-8");
905
+ utimesSync(orphanPath, orphanMtime, orphanMtime);
906
+ const db = new Database(getProjectDbPath(testRoot));
907
+ try {
908
+ db.prepare(`
909
+ INSERT INTO wiki_pages (path, title, tags, summary, last_updated)
910
+ VALUES (?, ?, ?, ?, ?)
911
+ `).run("pages/topics/coerced-updated/index.md", "Coerced Updated", "[]", "Missing updated value", "");
912
+ }
913
+ finally {
914
+ db.close();
915
+ }
916
+ const response = await fetch(`${baseUrl}/api/wiki/pages`, {
917
+ headers: { authorization: authHeader },
918
+ });
919
+ assert.equal(response.status, 200);
920
+ const pages = await response.json();
921
+ assert.equal(pages.find((page) => page.path === "pages/topics/coerced-updated/index.md")?.updated, indexedMtime.toISOString());
922
+ assert.equal(pages.find((page) => page.path === "pages/topics/orphan-updated/index.md")?.updated, orphanMtime.toISOString());
923
+ });
924
+ });
925
+ test("GET /api/wiki/pages returns a non-empty updated value for pages with frontmatter", async () => {
926
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
927
+ const pageDir = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "frontmatter-updated");
928
+ mkdirSync(pageDir, { recursive: true });
929
+ writeFileSync(join(pageDir, "index.md"), `---
930
+ title: Frontmatter Updated
931
+ summary: Has explicit metadata.
932
+ updated: 2026-05-14
933
+ ---
934
+
935
+ # Frontmatter Updated
936
+ `, "utf-8");
937
+ const response = await fetch(`${baseUrl}/api/wiki/pages`, {
938
+ headers: { authorization: authHeader },
939
+ });
940
+ assert.equal(response.status, 200);
941
+ const pages = await response.json();
942
+ const page = pages.find((entry) => entry.path === "pages/projects/frontmatter-updated/index.md");
943
+ assert.ok(page, "expected frontmatter page to be listed");
944
+ assert.equal(typeof page.updated, "string");
945
+ assert.notEqual(page.updated, "");
946
+ });
947
+ });
876
948
  test("server projects route returns an empty list when the registry is missing", async () => {
877
949
  await withStartedServer(async ({ baseUrl, authHeader }) => {
878
950
  const response = await fetch(`${baseUrl}/api/projects`, {
@@ -1476,4 +1548,153 @@ test("GET /api/wiki/korg/sessions returns grouped active research sessions", asy
1476
1548
  });
1477
1549
  });
1478
1550
  });
1551
+ test("GET /api/wiki/sources returns an empty sources payload when no sources exist", async () => {
1552
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1553
+ const response = await fetch(`${baseUrl}/api/wiki/sources`, {
1554
+ headers: { authorization: authHeader },
1555
+ });
1556
+ assert.equal(response.status, 200);
1557
+ const body = await response.json();
1558
+ assert.deepEqual(body, { sources: [] });
1559
+ assert.ok(wikiSourcesResponseSchema.safeParse(body).success, "response should match WikiSourcesResponseSchema");
1560
+ });
1561
+ });
1562
+ test("GET /api/wiki/sources filters sources by page", async () => {
1563
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1564
+ const db = new Database(getProjectDbPath(testRoot));
1565
+ try {
1566
+ db.prepare(`
1567
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1568
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1569
+ `).run("src-filter-match", "pdf", "https://example.com/roadmap.pdf", "Roadmap", "2026-05-15T10:00:00.000Z", "sources/roadmap.pdf", "roadmap body", JSON.stringify(["pages/projects/wiki-api/index.md"]), "complete", "sess-1", "Wiki API");
1570
+ db.prepare(`
1571
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1572
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1573
+ `).run("src-filter-miss", "text", "notes about another page", "Other", "2026-05-15T11:00:00.000Z", "sources/other.md", "other body", JSON.stringify(["pages/projects/other/index.md"]), "active", null, null);
1574
+ }
1575
+ finally {
1576
+ db.close();
1577
+ }
1578
+ const response = await fetch(`${baseUrl}/api/wiki/sources?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1579
+ headers: { authorization: authHeader },
1580
+ });
1581
+ assert.equal(response.status, 200);
1582
+ const body = await response.json();
1583
+ const parsed = wikiSourcesResponseSchema.safeParse(body);
1584
+ if (!parsed.success) {
1585
+ assert.fail(parsed.error.message);
1586
+ }
1587
+ assert.deepEqual(parsed.data, {
1588
+ sources: [
1589
+ {
1590
+ source_url: "https://example.com/roadmap.pdf",
1591
+ path: "sources/roadmap.pdf",
1592
+ kind: "pdf",
1593
+ status: "complete",
1594
+ session_id: "sess-1",
1595
+ ingested_at: "2026-05-15T10:00:00.000Z",
1596
+ },
1597
+ ],
1598
+ });
1599
+ });
1600
+ });
1601
+ test("GET /api/wiki/links returns all links when no page filter is provided", async () => {
1602
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1603
+ const db = new Database(getProjectDbPath(testRoot));
1604
+ try {
1605
+ db.prepare(`
1606
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1607
+ VALUES (?, ?, ?, ?)
1608
+ `).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
1609
+ db.prepare(`
1610
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1611
+ VALUES (?, ?, ?, ?)
1612
+ `).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
1613
+ }
1614
+ finally {
1615
+ db.close();
1616
+ }
1617
+ const response = await fetch(`${baseUrl}/api/wiki/links`, {
1618
+ headers: { authorization: authHeader },
1619
+ });
1620
+ assert.equal(response.status, 200);
1621
+ const body = await response.json();
1622
+ const parsed = wikiLinkResponseSchema.safeParse(body);
1623
+ if (!parsed.success) {
1624
+ assert.fail(parsed.error.message);
1625
+ }
1626
+ assert.deepEqual(parsed.data, {
1627
+ links: [
1628
+ {
1629
+ source_slug: "trinity",
1630
+ target_slug: "wiki-api",
1631
+ link_type: "implements",
1632
+ },
1633
+ {
1634
+ source_slug: "wiki-api",
1635
+ target_slug: "contracts",
1636
+ link_type: "references",
1637
+ },
1638
+ ],
1639
+ });
1640
+ });
1641
+ });
1642
+ test("GET /api/wiki/links returns an empty typed links payload when a page has no graph edges", async () => {
1643
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1644
+ const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1645
+ headers: { authorization: authHeader },
1646
+ });
1647
+ assert.equal(response.status, 200);
1648
+ const body = await response.json();
1649
+ assert.deepEqual(body, { links: [] });
1650
+ assert.ok(wikiLinkResponseSchema.safeParse(body).success, "response should match WikiLinksResponseSchema");
1651
+ });
1652
+ });
1653
+ test("GET /api/wiki/links filters incoming and outgoing links for a page", async () => {
1654
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1655
+ const db = new Database(getProjectDbPath(testRoot));
1656
+ try {
1657
+ db.prepare(`
1658
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1659
+ VALUES (?, ?, ?, ?)
1660
+ `).run("pages/projects/wiki-api/index.md", "pages/topics/contracts/index.md", "references", "2026-05-15T12:00:00.000Z");
1661
+ db.prepare(`
1662
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1663
+ VALUES (?, ?, ?, ?)
1664
+ `).run("pages/people/trinity/index.md", "pages/projects/wiki-api/index.md", "implements", "2026-05-15T12:01:00.000Z");
1665
+ db.prepare(`
1666
+ INSERT INTO wiki_links (from_page, to_page, link_type, extracted_at)
1667
+ VALUES (?, ?, ?, ?)
1668
+ `).run("pages/projects/other/index.md", "pages/topics/unrelated/index.md", "references", "2026-05-15T12:02:00.000Z");
1669
+ }
1670
+ finally {
1671
+ db.close();
1672
+ }
1673
+ const response = await fetch(`${baseUrl}/api/wiki/links?page=${encodeURIComponent("pages/projects/wiki-api/index.md")}`, {
1674
+ headers: { authorization: authHeader },
1675
+ });
1676
+ assert.equal(response.status, 200);
1677
+ const body = await response.json();
1678
+ const parsed = wikiLinkResponseSchema.safeParse(body);
1679
+ if (!parsed.success) {
1680
+ assert.fail(parsed.error.message);
1681
+ }
1682
+ assert.deepEqual(parsed.data, {
1683
+ links: [
1684
+ {
1685
+ source_slug: "trinity",
1686
+ target_slug: "wiki-api",
1687
+ link_type: "implements",
1688
+ direction: "incoming",
1689
+ },
1690
+ {
1691
+ source_slug: "wiki-api",
1692
+ target_slug: "contracts",
1693
+ link_type: "references",
1694
+ direction: "outgoing",
1695
+ },
1696
+ ],
1697
+ });
1698
+ });
1699
+ });
1479
1700
  //# sourceMappingURL=server.test.js.map
@@ -340,7 +340,7 @@ export function buildAgentRoster() {
340
340
  }
341
341
  // The wiki tools that every agent gets regardless of tool config
342
342
  const WIKI_TOOL_NAMES = new Set([
343
- "wiki_search", "wiki_read", "wiki_update", "wiki_append_timeline", "wiki_ingest_source",
343
+ "wiki_search", "wiki_read", "wiki_update", "wiki_reindex", "wiki_append_timeline", "wiki_ingest_source",
344
344
  "memory_recall", "memory_propose", "memory_list_action_items",
345
345
  ]);
346
346
  // Management tools that only @chapterhouse should have
@@ -119,6 +119,7 @@ You can delegate **multiple tasks simultaneously**. Different agents can work in
119
119
  - \`memory_recall\`: Search scoped agent memory for stored facts, decisions, and observations.
120
120
  - \`memory_reflect\`: Synthesize durable patterns from repeated observations in the scoped memory store.
121
121
  - \`wiki_update\`: Create or update wiki pages when knowledge belongs in the shared wiki.
122
+ - \`wiki_reindex\`: Force a filesystem-to-SQLite wiki reindex if existing pages are missing from search.
122
123
 
123
124
  Subagent proposals from \`memory_propose\` are processed automatically at end-of-task, so you do not need to manually review them mid-conversation.
124
125
 
@@ -11,7 +11,7 @@ import { agentEventBus } from "./agent-event-bus.js";
11
11
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
12
12
  import { getRouterConfig, updateRouterConfig } from "./router.js";
13
13
  import { ensureWikiStructure, writePage, assertPagePath } from "../wiki/fs.js";
14
- import { searchIndex, addToIndex, buildIndexEntryForPage, } from "../wiki/index-manager.js";
14
+ import { searchIndex, addToIndex, buildIndexEntryForPage, reindexWikiPages, } from "../wiki/index-manager.js";
15
15
  import { traverse as wikiTraverse } from "../wiki/links.js";
16
16
  import { validateWikiFrontmatter, validateAndBackfillFrontmatter } from "../wiki/frontmatter.js";
17
17
  import { appendTimeline } from "../wiki/timeline.js";
@@ -1558,6 +1558,16 @@ export function createTools(deps) {
1558
1558
  parameters: z.object({}).passthrough(),
1559
1559
  handler: async () => "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.",
1560
1560
  }),
1561
+ defineTool("wiki_reindex", {
1562
+ description: "Force a full wiki filesystem-to-SQLite reindex pass.",
1563
+ parameters: z.object({}),
1564
+ handler: async () => {
1565
+ ensureWikiStructure();
1566
+ const result = reindexWikiPages();
1567
+ appendLog("update", `wiki_reindex: rebuilt ${result.indexedPageCount} page(s)`);
1568
+ return `Reindexed ${result.indexedPageCount} wiki page(s) from disk.`;
1569
+ },
1570
+ }),
1561
1571
  defineTool("wiki_traverse", {
1562
1572
  description: "Walk the wiki entity graph from a starting page. Returns pages connected by typed links. " +
1563
1573
  "Use to discover related knowledge, trace dependencies, find who works on a project, etc. " +
@@ -146,6 +146,33 @@ Runtime notes with enough content to avoid incidental lint noise in the audit-lo
146
146
  const log = wikiFs.readLogFile();
147
147
  assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
148
148
  });
149
+ test("wiki_reindex rebuilds wiki_pages from disk on demand", async () => {
150
+ const toolsModule = await loadToolsModule();
151
+ const tools = toolsModule.createTools({
152
+ client: { async listModels() { return []; } },
153
+ onAgentTaskComplete: () => { },
154
+ });
155
+ const wikiReindex = tools.find((entry) => entry.name === "wiki_reindex");
156
+ assert.ok(wikiReindex);
157
+ const { wikiFs, indexManager } = await readWikiArtifacts();
158
+ wikiFs.ensureWikiStructure();
159
+ wikiFs.writePage("pages/topics/rust/index.md", `---
160
+ title: Rust
161
+ summary: Systems programming
162
+ updated: 2026-05-12
163
+ ---
164
+
165
+ # Rust
166
+ `);
167
+ for (const entry of indexManager.parseIndex()) {
168
+ indexManager.removeFromIndex(entry.path);
169
+ }
170
+ assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
171
+ const result = await wikiReindex.handler({});
172
+ assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
173
+ assert.ok(indexManager.parseIndex().some((entry) => entry.path === "pages/topics/rust/index.md"));
174
+ assert.match(wikiFs.readLogFile(), /update \| wiki_reindex: rebuilt \d+ page\(s\) \| tools-test-agent/);
175
+ });
149
176
  test("removed legacy wiki tools return helpful stub messages without mutating wiki pages", async () => {
150
177
  const toolsModule = await loadToolsModule();
151
178
  const tools = toolsModule.createTools({
@@ -1,19 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- test("SESSION_BUFFER_CAPACITY respects CHAPTERHOUSE_SSE_BUFFER_CAPACITY", async () => {
4
- const previous = process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
5
- process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
6
- try {
7
- const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
8
- assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
9
- }
10
- finally {
11
- if (previous === undefined) {
12
- delete process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
13
- }
14
- else {
15
- process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = previous;
16
- }
17
- }
3
+ test("SESSION_BUFFER_CAPACITY respects config.sseBufferCapacity", async (t) => {
4
+ t.mock.module("../config.js", {
5
+ namedExports: {
6
+ config: {
7
+ sseBufferCapacity: 3,
8
+ sseReplayLimit: 50,
9
+ },
10
+ },
11
+ });
12
+ const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
13
+ assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
18
14
  });
19
15
  //# sourceMappingURL=turn-event-log-env.test.js.map
package/dist/daemon.js CHANGED
@@ -23,6 +23,7 @@ import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/work
23
23
  import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
24
24
  import { runP6Migration } from "./memory/migration.js";
25
25
  import { WikiConsolidationScheduler } from "./wiki/scheduler.js";
26
+ import { ensureWikiIndexPopulated } from "./wiki/index-manager.js";
26
27
  const log = logger.child({ module: "daemon" });
27
28
  const modeContext = new ModeContext(config);
28
29
  let memoryHousekeepingScheduler;
@@ -131,6 +132,15 @@ async function main() {
131
132
  }
132
133
  const p6Migration = await runP6Migration(getDb());
133
134
  log.info({ p6Migration }, "P6 wiki seed migration complete");
135
+ try {
136
+ const wikiReindex = ensureWikiIndexPopulated();
137
+ if (wikiReindex.reindexed) {
138
+ log.info(wikiReindex, "Rebuilt wiki index from disk on startup");
139
+ }
140
+ }
141
+ catch (err) {
142
+ log.error({ err: err instanceof Error ? err.message : err }, "Startup wiki reindex check failed");
143
+ }
134
144
  // Prune orphaned session folders older than 7 days
135
145
  pruneOldSessions();
136
146
  // One-time deprecation note for legacy Telegram users (v1 → v2)
@@ -127,6 +127,16 @@ function isNonEmptyString(value) {
127
127
  function isIsoTimestamp(value) {
128
128
  return !Number.isNaN(Date.parse(value));
129
129
  }
130
+ function validateObservationPayload(payload) {
131
+ const observation = payload;
132
+ if (!isNonEmptyString(observation.content)) {
133
+ return undefined;
134
+ }
135
+ return {
136
+ ...observation,
137
+ content: observation.content.trim(),
138
+ };
139
+ }
130
140
  function validateActionItemPayload(payload) {
131
141
  const actionItem = payload;
132
142
  if (!isNonEmptyString(actionItem.title)) {
@@ -195,15 +205,20 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
195
205
  throw new Error(`Unknown memory scope '${scopeSlug}'.`);
196
206
  }
197
207
  if (kind === "observation") {
198
- const observation = payload;
208
+ const observation = validateObservationPayload(payload);
209
+ if (!observation) {
210
+ log.warn({ scopeSlug, source, sourceAgent }, "Skipping accepted observation proposal with empty content");
211
+ return false;
212
+ }
213
+ const content = observation.content;
199
214
  recordObservation({
200
215
  scope_id: scope.id,
201
216
  entity_id: observation.entity_id,
202
- content: observation.content,
217
+ content,
203
218
  source: observation.source ?? source,
204
219
  confidence,
205
220
  });
206
- return;
221
+ return true;
207
222
  }
208
223
  if (kind === "decision") {
209
224
  const decision = payload;
@@ -213,7 +228,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
213
228
  rationale: decision.rationale ?? decision.title,
214
229
  decided_at: decision.decided_at,
215
230
  });
216
- return;
231
+ return true;
217
232
  }
218
233
  if (kind === "action_item") {
219
234
  const actionItem = validateActionItemPayload(payload);
@@ -233,7 +248,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
233
248
  due_at: actionItem.due_at,
234
249
  source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
235
250
  });
236
- return;
251
+ return true;
237
252
  }
238
253
  const entity = payload;
239
254
  const entityKind = entity.entity_kind ?? entity.kind;
@@ -247,6 +262,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
247
262
  summary: entity.summary,
248
263
  confidence,
249
264
  });
265
+ return true;
250
266
  }
251
267
  export async function runEndOfTaskMemoryHook(input) {
252
268
  const autoAcceptEnabled = isMemoryAutoAcceptEnabled();
@@ -293,7 +309,12 @@ export async function runEndOfTaskMemoryHook(input) {
293
309
  else {
294
310
  const envelope = parseEnvelope(proposal.payload);
295
311
  try {
296
- rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
312
+ const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
313
+ if (!accepted) {
314
+ resolveInboxItem(proposal.id, "accepted", decision.reason);
315
+ summary.accepted++;
316
+ continue;
317
+ }
297
318
  resolveInboxItem(proposal.id, "accepted", decision.reason);
298
319
  summary.accepted++;
299
320
  }
@@ -321,8 +342,9 @@ export async function runEndOfTaskMemoryHook(input) {
321
342
  }
322
343
  if (autoAcceptEnabled) {
323
344
  for (const implicitMemory of review.implicit_memories) {
324
- rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence);
325
- summary.implicit_extracted++;
345
+ if (rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence)) {
346
+ summary.implicit_extracted++;
347
+ }
326
348
  }
327
349
  }
328
350
  log.info(summary, "memory.eot.processed");