chapterhouse 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  107. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  109. package/web/dist/index.html +1 -1
  110. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  111. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -0,0 +1,455 @@
1
+ import { Router } from "express";
2
+ import { statSync } from "fs";
3
+ import { join } from "path";
4
+ import { z } from "zod";
5
+ import { OkResponseSchema, WikiBrowserPageListSchema, WikiDeleteResponseSchema, WikiLinksResponseSchema, WikiPageContentSchema, WikiPageDetailSchema, WikiPageListSchema, WikiPageUpdateResponseSchema, WikiPinResponseSchema, WikiResearchSessionsResponseSchema, WikiSourcesResponseSchema, WikiWriteResponseSchema, } from "../../shared/api-schemas.js";
6
+ import { getDb } from "../../store/db.js";
7
+ import { deletePage, ensureWikiStructure, assertPagePath, getWikiDir, listPages, pageExists, readPage, writePage } from "../../wiki/fs.js";
8
+ import { parseWikiFrontmatter } from "../../wiki/frontmatter.js";
9
+ import { ingestSource } from "../../wiki/ingest.js";
10
+ import { parseIndex } from "../../wiki/index-manager.js";
11
+ import { withWikiWrite } from "../../wiki/lock.js";
12
+ import { normalizeWikiPath } from "../../wiki/path-utils.js";
13
+ import { readWikiPage, teamWikiSync } from "../../wiki/team-sync.js";
14
+ import { asBadRequest, BadRequestError, NotFoundError, parseRequest } from "../errors.js";
15
+ import { listKorgResearchSessions } from "../korg.js";
16
+ import { sendJson } from "../send-json.js";
17
+ const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
18
+ const wikiWriteSchema = z.object({
19
+ content: z.string({ error: "Missing 'content' string in request body" }),
20
+ }).strict();
21
+ function createWikiPagePayload(path, content) {
22
+ const { parsed: frontmatter, body: renderedContent } = parseWikiFrontmatter(content);
23
+ return {
24
+ path,
25
+ content,
26
+ renderedContent,
27
+ frontmatter,
28
+ };
29
+ }
30
+ function coerceWikiPageUpdated(path, updated) {
31
+ const normalized = updated?.trim();
32
+ if (normalized) {
33
+ return normalized;
34
+ }
35
+ try {
36
+ return statSync(join(getWikiDir(), path)).mtime.toISOString();
37
+ }
38
+ catch {
39
+ return "unknown";
40
+ }
41
+ }
42
+ function wikiPathToBrowserSlug(path) {
43
+ return path.replace(/^pages\//, "").replace(/\.md$/, "");
44
+ }
45
+ function entityTypeFromPath(path) {
46
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
47
+ const segs = rest.split("/").filter(Boolean);
48
+ if (segs.length <= 1)
49
+ return (segs[0] || "pages").replace(/\.md$/i, "");
50
+ return segs[0];
51
+ }
52
+ function normalizeOptionalQueryParam(value) {
53
+ if (typeof value !== "string") {
54
+ return undefined;
55
+ }
56
+ const trimmed = value.trim();
57
+ return trimmed ? trimmed : undefined;
58
+ }
59
+ function matchesWikiBrowserFilters(page, filters) {
60
+ if (filters.type && page.type !== filters.type) {
61
+ return false;
62
+ }
63
+ if (!filters.q) {
64
+ return true;
65
+ }
66
+ const query = filters.q.toLowerCase();
67
+ return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
68
+ }
69
+ function mapIndexEntryToBrowserPage(entry) {
70
+ return {
71
+ slug: wikiPathToBrowserSlug(entry.path),
72
+ title: entry.title,
73
+ summary: entry.summary,
74
+ type: entry.section,
75
+ last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
76
+ };
77
+ }
78
+ function listFallbackWikiBrowserPages(filters) {
79
+ const entries = parseIndex();
80
+ const indexed = new Set(entries.map((entry) => entry.path));
81
+ const indexedResults = entries.map(mapIndexEntryToBrowserPage);
82
+ const orphanResults = listPages()
83
+ .filter((path) => !indexed.has(path))
84
+ .map((path) => ({
85
+ slug: wikiPathToBrowserSlug(path),
86
+ title: path,
87
+ summary: "",
88
+ type: "Unindexed",
89
+ last_updated: coerceWikiPageUpdated(path, undefined),
90
+ }));
91
+ return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
92
+ }
93
+ function listDbWikiBrowserPages(filters) {
94
+ const db = getDb();
95
+ const clauses = [];
96
+ const params = [];
97
+ if (filters.type) {
98
+ clauses.push("(entity_type = ? OR (entity_type IS NULL AND path LIKE ?))");
99
+ params.push(filters.type, `pages/${filters.type}/%`);
100
+ }
101
+ if (filters.q) {
102
+ clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
103
+ params.push(`%${filters.q}%`, `%${filters.q}%`);
104
+ }
105
+ const rows = db.prepare(`
106
+ SELECT path, title, summary, entity_type, last_updated, pinned
107
+ FROM wiki_pages
108
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
109
+ ORDER BY COALESCE(last_updated, '') DESC, title ASC
110
+ `).all(...params);
111
+ return rows.map((row) => ({
112
+ slug: wikiPathToBrowserSlug(row.path),
113
+ title: row.title,
114
+ summary: row.summary ?? "",
115
+ type: row.entity_type ?? entityTypeFromPath(row.path),
116
+ last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
117
+ ...(row.pinned ? { pinned: true } : {}),
118
+ }));
119
+ }
120
+ function parseWikiSourcePages(value) {
121
+ if (!value) {
122
+ return [];
123
+ }
124
+ try {
125
+ const parsed = JSON.parse(value);
126
+ return Array.isArray(parsed)
127
+ ? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
128
+ : [];
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ }
134
+ function listWikiSources(page) {
135
+ const rows = getDb().prepare(`
136
+ SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
137
+ FROM wiki_sources
138
+ ORDER BY ingested_at DESC, id ASC
139
+ `).all();
140
+ return rows
141
+ .filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
142
+ .map((row) => ({
143
+ source_url: row.origin,
144
+ path: row.raw_path ?? undefined,
145
+ kind: row.source_type,
146
+ status: row.status,
147
+ session_id: row.session_id,
148
+ ingested_at: row.ingested_at,
149
+ }));
150
+ }
151
+ function wikiPathToSlug(path) {
152
+ const segments = normalizeWikiPath(path).split("/").filter(Boolean);
153
+ const file = segments[segments.length - 1] ?? path;
154
+ const base = file.endsWith(".md") ? file.slice(0, -3) : file;
155
+ if (base === "index" && segments.length >= 2) {
156
+ return segments[segments.length - 2] ?? base;
157
+ }
158
+ return base;
159
+ }
160
+ function listWikiLinks(page) {
161
+ const rows = page
162
+ ? getDb().prepare(`
163
+ SELECT from_page, to_page, link_type
164
+ FROM wiki_links
165
+ WHERE from_page = ? OR to_page = ?
166
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
167
+ `).all(page, page)
168
+ : getDb().prepare(`
169
+ SELECT from_page, to_page, link_type
170
+ FROM wiki_links
171
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
172
+ `).all();
173
+ return rows.map((row) => ({
174
+ source_slug: wikiPathToSlug(row.from_page),
175
+ target_slug: wikiPathToSlug(row.to_page),
176
+ link_type: row.link_type,
177
+ ...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
178
+ }));
179
+ }
180
+ function readPathParam(req) {
181
+ const raw = req.query.path;
182
+ if (typeof raw !== "string" || !raw) {
183
+ throw new BadRequestError("Missing 'path' query param");
184
+ }
185
+ return raw;
186
+ }
187
+ function readOptionalPageFilter(req) {
188
+ const raw = req.query.page;
189
+ if (typeof raw !== "string" || !raw.trim()) {
190
+ return undefined;
191
+ }
192
+ return normalizeWikiPath(raw.trim());
193
+ }
194
+ function assertValidPagePath(path) {
195
+ try {
196
+ assertPagePath(path);
197
+ return path;
198
+ }
199
+ catch (error) {
200
+ asBadRequest(error);
201
+ }
202
+ }
203
+ function getEmptyWikiWelcomeContent(today = new Date()) {
204
+ return `---
205
+ title: Wiki
206
+ summary: Empty wiki — get started.
207
+ updated: ${today.toISOString().slice(0, 10)}
208
+ ---
209
+
210
+ # Wiki
211
+
212
+ Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
213
+
214
+ Create your first page via the wiki UI or by editing files under \`pages/\`.
215
+ `;
216
+ }
217
+ export function createWikiRouter(options) {
218
+ const { authMiddleware, modeContext } = options;
219
+ const router = Router();
220
+ function getWikiPageScope(path) {
221
+ return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
222
+ }
223
+ router.get("/api/wiki/pages", async (req, res) => {
224
+ ensureWikiStructure();
225
+ // Sync team wiki pages if connected, using the caller's auth token
226
+ if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
227
+ const authorizationHeader = typeof req.headers.authorization === "string"
228
+ ? req.headers.authorization
229
+ : undefined;
230
+ try {
231
+ await teamWikiSync.syncAll({ authorizationHeader });
232
+ }
233
+ catch {
234
+ // Non-fatal: list local pages even if team sync fails
235
+ }
236
+ }
237
+ const entries = parseIndex();
238
+ // Index entries first (rich metadata), then any pages on disk that aren't yet indexed.
239
+ const indexed = new Set(entries.map((e) => e.path));
240
+ const indexedResults = entries.map((e) => ({
241
+ path: e.path,
242
+ title: e.title,
243
+ summary: e.summary,
244
+ section: e.section,
245
+ tags: e.tags || [],
246
+ updated: coerceWikiPageUpdated(e.path, e.updated),
247
+ scope: getWikiPageScope(e.path),
248
+ }));
249
+ const orphanResults = listPages()
250
+ .filter((p) => !indexed.has(p))
251
+ .map((p) => ({
252
+ path: p,
253
+ title: p,
254
+ summary: "",
255
+ section: "Unindexed",
256
+ tags: [],
257
+ updated: coerceWikiPageUpdated(p, undefined),
258
+ scope: getWikiPageScope(p),
259
+ }));
260
+ sendJson(res, WikiPageListSchema, [...indexedResults, ...orphanResults]);
261
+ });
262
+ router.get("/api/wiki/browser-pages", async (req, res) => {
263
+ ensureWikiStructure();
264
+ const filters = {
265
+ q: normalizeOptionalQueryParam(req.query.q),
266
+ type: normalizeOptionalQueryParam(req.query.type),
267
+ };
268
+ const db = getDb();
269
+ const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
270
+ const pages = count > 0
271
+ ? listDbWikiBrowserPages(filters)
272
+ : listFallbackWikiBrowserPages(filters);
273
+ sendJson(res, WikiBrowserPageListSchema, { pages });
274
+ });
275
+ router.get("/api/wiki/sources", async (req, res) => {
276
+ ensureWikiStructure();
277
+ sendJson(res, WikiSourcesResponseSchema, { sources: listWikiSources(readOptionalPageFilter(req)) });
278
+ });
279
+ router.get("/api/wiki/links", async (req, res) => {
280
+ ensureWikiStructure();
281
+ sendJson(res, WikiLinksResponseSchema, { links: listWikiLinks(readOptionalPageFilter(req)) });
282
+ });
283
+ router.get("/api/wiki/page", async (req, res) => {
284
+ const slugParam = normalizeOptionalQueryParam(req.query.slug);
285
+ if (slugParam) {
286
+ const path = `pages/${slugParam}.md`;
287
+ assertValidPagePath(path);
288
+ const db = getDb();
289
+ const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
290
+ const authorizationHeader = typeof req.headers.authorization === "string"
291
+ ? req.headers.authorization
292
+ : undefined;
293
+ const rawContent = await readWikiPage(path, { authorizationHeader });
294
+ if (rawContent === undefined) {
295
+ throw new NotFoundError("Page not found");
296
+ }
297
+ const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
298
+ sendJson(res, WikiPageDetailSchema, {
299
+ page: {
300
+ slug: slugParam,
301
+ title: row?.title ?? slugParam,
302
+ type: row?.entity_type ?? "topics",
303
+ last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
304
+ compiled_truth: rawContent,
305
+ pinned: Boolean(row?.pinned),
306
+ frontmatter,
307
+ },
308
+ });
309
+ return;
310
+ }
311
+ const path = assertValidPagePath(readPathParam(req));
312
+ const authorizationHeader = typeof req.headers.authorization === "string"
313
+ ? req.headers.authorization
314
+ : undefined;
315
+ const content = await readWikiPage(path, { authorizationHeader });
316
+ if (content === undefined) {
317
+ if (path === "pages/index.md") {
318
+ sendJson(res, WikiPageContentSchema, createWikiPagePayload(path, getEmptyWikiWelcomeContent()));
319
+ return;
320
+ }
321
+ throw new NotFoundError("Page not found");
322
+ }
323
+ sendJson(res, WikiPageContentSchema, createWikiPagePayload(path, content));
324
+ });
325
+ router.put("/api/wiki/page", async (req, res) => {
326
+ const path = assertValidPagePath(readPathParam(req));
327
+ const { content } = parseRequest(wikiWriteSchema, req.body);
328
+ const created = await withWikiWrite(() => {
329
+ const isCreated = !pageExists(path);
330
+ writePage(path, content);
331
+ return isCreated;
332
+ });
333
+ sendJson(res, WikiWriteResponseSchema, { ok: true, created, path });
334
+ });
335
+ const wikiUpdateSchema = z.object({
336
+ slug: requiredString("Missing 'slug' in request body"),
337
+ compiled_truth: z.string().optional(),
338
+ frontmatter: z.record(z.string(), z.unknown()).optional(),
339
+ });
340
+ router.post("/api/wiki/update", authMiddleware, async (req, res) => {
341
+ const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
342
+ const path = assertValidPagePath(`pages/${slug}.md`);
343
+ let finalContent = compiled_truth;
344
+ if (newFrontmatter !== undefined) {
345
+ const existing = readPage(path);
346
+ const base = finalContent ?? existing ?? "";
347
+ const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
348
+ const merged = { ...existingFrontmatter, ...newFrontmatter };
349
+ const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
350
+ finalContent = `---\n${fmLines}\n---\n${body}`;
351
+ }
352
+ if (finalContent !== undefined) {
353
+ await withWikiWrite(() => writePage(path, finalContent));
354
+ }
355
+ const db = getDb();
356
+ const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
357
+ const content = readPage(path) ?? finalContent ?? "";
358
+ const { parsed: frontmatter } = parseWikiFrontmatter(content);
359
+ sendJson(res, WikiPageUpdateResponseSchema, {
360
+ ok: true,
361
+ page: {
362
+ slug,
363
+ title: row?.title ?? slug,
364
+ type: row?.entity_type ?? "topics",
365
+ last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
366
+ compiled_truth: content,
367
+ pinned: Boolean(row?.pinned),
368
+ frontmatter,
369
+ },
370
+ });
371
+ });
372
+ const wikiPinSchema = z.object({
373
+ slug: requiredString("Missing 'slug' in request body"),
374
+ pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
375
+ });
376
+ router.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
377
+ const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
378
+ const path = assertValidPagePath(`pages/${slug}.md`);
379
+ const db = getDb();
380
+ db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
381
+ sendJson(res, WikiPinResponseSchema, { ok: true, pinned: Boolean(pinned) });
382
+ });
383
+ router.delete("/api/wiki/page", async (req, res) => {
384
+ const path = assertValidPagePath(readPathParam(req));
385
+ const removed = await withWikiWrite(() => deletePage(path));
386
+ sendJson(res, WikiDeleteResponseSchema, { ok: removed, path });
387
+ });
388
+ router.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
389
+ sendJson(res, WikiResearchSessionsResponseSchema, { sessions: listKorgResearchSessions(getDb()) });
390
+ });
391
+ const wikiIngestSchema = z.object({
392
+ source_url: z.string().optional(),
393
+ path: z.string().optional(),
394
+ topic: z.string().optional(),
395
+ });
396
+ router.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
397
+ const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
398
+ const source = source_url ?? ingestPath;
399
+ if (!source) {
400
+ throw new BadRequestError("Missing 'source_url' or 'path' in request body");
401
+ }
402
+ const type = source_url ? "url" : "text";
403
+ await withWikiWrite(() => ingestSource(source, type, topic));
404
+ sendJson(res, OkResponseSchema, { ok: true });
405
+ });
406
+ router.get("/api/wiki/search", authMiddleware, async (req, res) => {
407
+ ensureWikiStructure();
408
+ const q = normalizeOptionalQueryParam(req.query.q);
409
+ const type = normalizeOptionalQueryParam(req.query.type);
410
+ const db = getDb();
411
+ let rows;
412
+ try {
413
+ const clauses = ["wiki_pages_fts MATCH ?"];
414
+ const params = [q ? `${q}*` : "*"];
415
+ if (type) {
416
+ clauses.push("entity_type = ?");
417
+ params.push(type);
418
+ }
419
+ rows = db.prepare(`
420
+ SELECT path, title, entity_type, last_updated
421
+ FROM wiki_pages_fts
422
+ WHERE ${clauses.join(" AND ")}
423
+ ORDER BY rank
424
+ LIMIT 50
425
+ `).all(...params);
426
+ }
427
+ catch {
428
+ const clauses = [];
429
+ const params = [];
430
+ if (q) {
431
+ clauses.push("title LIKE ? COLLATE NOCASE");
432
+ params.push(`%${q}%`);
433
+ }
434
+ if (type) {
435
+ clauses.push("entity_type = ?");
436
+ params.push(type);
437
+ }
438
+ rows = db.prepare(`
439
+ SELECT path, title, entity_type, last_updated
440
+ FROM wiki_pages
441
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
442
+ LIMIT 50
443
+ `).all(...params);
444
+ }
445
+ const pages = rows.map((row) => ({
446
+ slug: wikiPathToBrowserSlug(row.path),
447
+ title: row.title,
448
+ type: row.entity_type ?? "topics",
449
+ last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
450
+ }));
451
+ sendJson(res, WikiBrowserPageListSchema, { pages });
452
+ });
453
+ return router;
454
+ }
455
+ //# sourceMappingURL=wiki.js.map
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import express from "express";
3
+ import test from "node:test";
4
+ import { createWikiRouter } from "./wiki.js";
5
+ import { config } from "../../config.js";
6
+ import { ModeContext } from "../../mode-context.js";
7
+ test("wiki explicit auth routes are guarded when mounted without global auth", async () => {
8
+ const authMiddleware = (_req, res) => {
9
+ res.status(401).json({ error: "missing token" });
10
+ };
11
+ const app = express();
12
+ app.use(express.json());
13
+ app.use(createWikiRouter({
14
+ authMiddleware,
15
+ modeContext: new ModeContext(config),
16
+ }));
17
+ const server = app.listen(0, "127.0.0.1");
18
+ await new Promise((resolve) => server.once("listening", resolve));
19
+ const address = server.address();
20
+ assert.ok(address && typeof address === "object");
21
+ const baseUrl = `http://127.0.0.1:${address.port}`;
22
+ try {
23
+ const responses = await Promise.all([
24
+ fetch(`${baseUrl}/api/wiki/update`, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify({ slug: "topics/auth-required", compiled_truth: "# Auth required" }),
28
+ }),
29
+ fetch(`${baseUrl}/api/wiki/ingest`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify({ path: "raw source" }),
33
+ }),
34
+ fetch(`${baseUrl}/api/wiki/search?q=chapterhouse`),
35
+ fetch(`${baseUrl}/api/wiki/page/pin`, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify({ slug: "topics/auth-required", pinned: true }),
39
+ }),
40
+ ]);
41
+ assert.deepEqual(responses.map((response) => response.status), [401, 401, 401, 401]);
42
+ }
43
+ finally {
44
+ await new Promise((resolve, reject) => {
45
+ server.close((error) => (error ? reject(error) : resolve()));
46
+ });
47
+ }
48
+ });
49
+ //# sourceMappingURL=wiki.test.js.map
@@ -0,0 +1,16 @@
1
+ import { config } from "../config.js";
2
+ import { childLogger } from "../util/logger.js";
3
+ const log = childLogger("api.send-json");
4
+ export function sendJson(res, schema, payload) {
5
+ const parsed = schema.safeParse(payload);
6
+ if (!parsed.success) {
7
+ const message = `Invalid API response payload: ${parsed.error.message}`;
8
+ if (!config.isProduction) {
9
+ throw new Error(message);
10
+ }
11
+ log.warn({ err: parsed.error.message }, "Invalid API response payload");
12
+ return res.json(payload);
13
+ }
14
+ return res.json(parsed.data);
15
+ }
16
+ //# sourceMappingURL=send-json.js.map
@@ -0,0 +1,18 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { z } from "zod";
4
+ test("sendJson validates payloads against shared schemas before responding", async () => {
5
+ const { sendJson } = await import("./send-json.js");
6
+ const writes = [];
7
+ const response = {
8
+ json(payload) {
9
+ writes.push(payload);
10
+ return this;
11
+ },
12
+ };
13
+ const schema = z.object({ ok: z.literal(true) });
14
+ sendJson(response, schema, { ok: true });
15
+ assert.deepEqual(writes, [{ ok: true }]);
16
+ assert.throws(() => sendJson(response, schema, { ok: false }), /Invalid API response payload/);
17
+ });
18
+ //# sourceMappingURL=send-json.test.js.map
@@ -10,12 +10,24 @@ export function resolveApiToken({ envToken, tokenPath, exists = existsSync, read
10
10
  }
11
11
  return null;
12
12
  }
13
+ function isLoopbackBindHost(host) {
14
+ const normalizedHost = host.trim().toLowerCase();
15
+ return normalizedHost === "127.0.0.1" || normalizedHost === "::1" || normalizedHost === "localhost";
16
+ }
13
17
  export function assertAuthenticationConfigured(options) {
14
- void options;
18
+ if (options.entraAuthEnabled || options.apiToken) {
19
+ return;
20
+ }
21
+ if (isLoopbackBindHost(options.apiHost)) {
22
+ return;
23
+ }
24
+ throw new Error(`Refusing to start without authentication on non-loopback bind ${options.apiHost}. `
25
+ + "Set API_TOKEN or configure Entra auth (ENTRA_AUTH_ENABLED=true with ENTRA_TENANT_ID and ENTRA_CLIENT_ID), "
26
+ + "or bind API_HOST to 127.0.0.1, ::1, or localhost for standalone local use.");
15
27
  }
16
- export function createHealthPayload(now = new Date()) {
28
+ export function createHealthPayload(now = new Date(), options = {}) {
17
29
  return {
18
- status: "ok",
30
+ status: options.fts5Ready === false ? "initializing" : "ok",
19
31
  timestamp: now.toISOString(),
20
32
  };
21
33
  }
@@ -46,4 +58,34 @@ export function shouldServeSpaPath(pathname) {
46
58
  export function getDisplayHost(host) {
47
59
  return host === "0.0.0.0" || host === "::" || host === "127.0.0.1" || host === "::1" ? "localhost" : host;
48
60
  }
61
+ export function setupSseCleanup(setup) {
62
+ const cleanups = [];
63
+ let cleaned = false;
64
+ const cleanup = () => {
65
+ if (cleaned)
66
+ return;
67
+ cleaned = true;
68
+ for (let i = cleanups.length - 1; i >= 0; i--) {
69
+ try {
70
+ cleanups[i]?.();
71
+ }
72
+ catch {
73
+ // Best-effort cleanup should never mask the original SSE failure path.
74
+ }
75
+ }
76
+ };
77
+ let setupComplete = false;
78
+ try {
79
+ setup((fn) => {
80
+ if (fn)
81
+ cleanups.push(fn);
82
+ }, cleanup);
83
+ setupComplete = true;
84
+ return cleanup;
85
+ }
86
+ finally {
87
+ if (!setupComplete)
88
+ cleanup();
89
+ }
90
+ }
49
91
  //# sourceMappingURL=server-runtime.js.map