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.
- package/dist/api/server.js +91 -3
- package/dist/api/server.test.js +222 -1
- package/dist/copilot/agents.js +1 -1
- package/dist/copilot/system-message.js +1 -0
- package/dist/copilot/tools.js +11 -1
- package/dist/copilot/tools.wiki.test.js +27 -0
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +10 -0
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/migration.test.js +10 -2
- package/dist/paths.js +31 -11
- package/dist/store/db.js +68 -0
- package/dist/store/db.test.js +47 -1
- 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 +8 -1
- package/dist/wiki/consolidation.test.js +3 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +82 -23
- package/dist/wiki/index-manager.test.js +129 -1
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/package.json +1 -1
package/dist/api/server.js
CHANGED
|
@@ -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"
|
package/dist/api/server.test.js
CHANGED
|
@@ -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
|
package/dist/copilot/agents.js
CHANGED
|
@@ -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
|
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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)
|
package/dist/memory/eot.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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");
|