chapterhouse 0.8.1 → 0.9.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.
@@ -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 { readPage, 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";
@@ -29,7 +30,7 @@ import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../st
29
30
  import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
30
31
  import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
31
32
  import { getStatus, onStatusChange } from "../status.js";
32
- import { formatSseData, formatSseEvent } from "./sse.js";
33
+ import { formatSseData } from "./sse.js";
33
34
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
34
35
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
35
36
  import { childLogger } from "../util/logger.js";
@@ -40,6 +41,7 @@ import { recordObservation } from "../memory/observations.js";
40
41
  import { recordDecision } from "../memory/decisions.js";
41
42
  import { upsertEntity } from "../memory/entities.js";
42
43
  import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
44
+ import { ingestSource } from "../wiki/ingest.js";
43
45
  import { listKorgResearchSessions, routeKorgMessage } from "./korg.js";
44
46
  const log = childLogger("server");
45
47
  const modeContext = new ModeContext(config);
@@ -146,7 +148,7 @@ const prMergeHookSchema = z.object({
146
148
  });
147
149
  const korgRequestSchema = z.object({
148
150
  message: requiredString("Missing 'message' in request body"),
149
- session_id: z.string().trim().min(1).optional(),
151
+ sessionKey: z.string().trim().min(1).optional(),
150
152
  }).strict();
151
153
  const projectHardRulesSchema = z.object({
152
154
  hardRules: z.object({
@@ -192,6 +194,149 @@ function createProjectDetailPayload(slug, cwd) {
192
194
  softRules: listTopLevelSoftRules(rules.soft),
193
195
  };
194
196
  }
197
+ function coerceWikiPageUpdated(path, updated) {
198
+ const normalized = updated?.trim();
199
+ if (normalized) {
200
+ return normalized;
201
+ }
202
+ try {
203
+ return statSync(join(getWikiDir(), path)).mtime.toISOString();
204
+ }
205
+ catch {
206
+ return "unknown";
207
+ }
208
+ }
209
+ function wikiPathToBrowserSlug(path) {
210
+ return path.replace(/^pages\//, "").replace(/\.md$/, "");
211
+ }
212
+ function normalizeOptionalQueryParam(value) {
213
+ if (typeof value !== "string") {
214
+ return undefined;
215
+ }
216
+ const trimmed = value.trim();
217
+ return trimmed ? trimmed : undefined;
218
+ }
219
+ function matchesWikiBrowserFilters(page, filters) {
220
+ if (filters.type && page.type !== filters.type) {
221
+ return false;
222
+ }
223
+ if (!filters.q) {
224
+ return true;
225
+ }
226
+ const query = filters.q.toLowerCase();
227
+ return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
228
+ }
229
+ function mapIndexEntryToBrowserPage(entry) {
230
+ return {
231
+ slug: wikiPathToBrowserSlug(entry.path),
232
+ title: entry.title,
233
+ summary: entry.summary,
234
+ type: entry.section,
235
+ last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
236
+ };
237
+ }
238
+ function listFallbackWikiBrowserPages(filters) {
239
+ const entries = parseIndex();
240
+ const indexed = new Set(entries.map((entry) => entry.path));
241
+ const indexedResults = entries.map(mapIndexEntryToBrowserPage);
242
+ const orphanResults = listPages()
243
+ .filter((path) => !indexed.has(path))
244
+ .map((path) => ({
245
+ slug: wikiPathToBrowserSlug(path),
246
+ title: path,
247
+ summary: "",
248
+ type: "Unindexed",
249
+ last_updated: coerceWikiPageUpdated(path, undefined),
250
+ }));
251
+ return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
252
+ }
253
+ function listDbWikiBrowserPages(filters) {
254
+ const db = getDb();
255
+ const clauses = [];
256
+ const params = [];
257
+ if (filters.type) {
258
+ clauses.push("entity_type = ?");
259
+ params.push(filters.type);
260
+ }
261
+ if (filters.q) {
262
+ clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
263
+ params.push(`%${filters.q}%`, `%${filters.q}%`);
264
+ }
265
+ const rows = db.prepare(`
266
+ SELECT path, title, summary, entity_type, last_updated, pinned
267
+ FROM wiki_pages
268
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
269
+ ORDER BY COALESCE(last_updated, '') DESC, title ASC
270
+ `).all(...params);
271
+ return rows.map((row) => ({
272
+ slug: wikiPathToBrowserSlug(row.path),
273
+ title: row.title,
274
+ summary: row.summary ?? "",
275
+ type: row.entity_type ?? "topics",
276
+ last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
277
+ ...(row.pinned ? { pinned: true } : {}),
278
+ }));
279
+ }
280
+ function parseWikiSourcePages(value) {
281
+ if (!value) {
282
+ return [];
283
+ }
284
+ try {
285
+ const parsed = JSON.parse(value);
286
+ return Array.isArray(parsed)
287
+ ? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
288
+ : [];
289
+ }
290
+ catch {
291
+ return [];
292
+ }
293
+ }
294
+ function listWikiSources(page) {
295
+ const rows = getDb().prepare(`
296
+ SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
297
+ FROM wiki_sources
298
+ ORDER BY ingested_at DESC, id ASC
299
+ `).all();
300
+ return rows
301
+ .filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
302
+ .map((row) => ({
303
+ source_url: row.origin,
304
+ path: row.raw_path ?? undefined,
305
+ kind: row.source_type,
306
+ status: row.status,
307
+ session_id: row.session_id,
308
+ ingested_at: row.ingested_at,
309
+ }));
310
+ }
311
+ function wikiPathToSlug(path) {
312
+ const segments = normalizeWikiPath(path).split("/").filter(Boolean);
313
+ const file = segments[segments.length - 1] ?? path;
314
+ const base = file.endsWith(".md") ? file.slice(0, -3) : file;
315
+ if (base === "index" && segments.length >= 2) {
316
+ return segments[segments.length - 2] ?? base;
317
+ }
318
+ return base;
319
+ }
320
+ function listWikiLinks(page) {
321
+ const rows = page
322
+ ? getDb().prepare(`
323
+ SELECT from_page, to_page, link_type
324
+ FROM wiki_links
325
+ WHERE from_page = ? OR to_page = ?
326
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
327
+ `).all(page, page)
328
+ : getDb().prepare(`
329
+ SELECT from_page, to_page, link_type
330
+ FROM wiki_links
331
+ ORDER BY from_page ASC, to_page ASC, link_type ASC
332
+ `).all();
333
+ return rows.map((row) => ({
334
+ source_slug: wikiPathToSlug(row.from_page),
335
+ target_slug: wikiPathToSlug(row.to_page),
336
+ link_type: row.link_type,
337
+ ...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
338
+ }));
339
+ }
195
340
  // Load a configured API token when present; startup validation below enforces auth.
196
341
  let apiToken = null;
197
342
  try {
@@ -312,6 +457,13 @@ function readPathParam(req) {
312
457
  }
313
458
  return raw;
314
459
  }
460
+ function readOptionalPageFilter(req) {
461
+ const raw = req.query.page;
462
+ if (typeof raw !== "string" || !raw.trim()) {
463
+ return undefined;
464
+ }
465
+ return normalizeWikiPath(raw.trim());
466
+ }
315
467
  function assertValidPagePath(path) {
316
468
  try {
317
469
  assertPagePath(path);
@@ -758,11 +910,11 @@ app.get("/stream", (req, res) => {
758
910
  }
759
911
  sseClients.set(connectionId, res);
760
912
  const unsubscribeStatus = onStatusChange((status, message) => {
761
- res.write(formatSseEvent("status", { status, message }));
913
+ res.write(formatSseData({ type: "status", status, message }));
762
914
  });
763
915
  const currentStatus = getStatus();
764
916
  if (currentStatus.status !== "idle") {
765
- res.write(formatSseEvent("status", currentStatus));
917
+ res.write(formatSseData({ type: "status", ...currentStatus }));
766
918
  }
767
919
  const heartbeat = setInterval(() => {
768
920
  res.write(`:ping\n\n`);
@@ -1448,7 +1600,7 @@ app.get("/api/wiki/pages", async (req, res) => {
1448
1600
  summary: e.summary,
1449
1601
  section: e.section,
1450
1602
  tags: e.tags || [],
1451
- updated: e.updated || "",
1603
+ updated: coerceWikiPageUpdated(e.path, e.updated),
1452
1604
  scope: getWikiPageScope(e.path),
1453
1605
  }));
1454
1606
  const orphanResults = listPages()
@@ -1459,12 +1611,60 @@ app.get("/api/wiki/pages", async (req, res) => {
1459
1611
  summary: "",
1460
1612
  section: "Unindexed",
1461
1613
  tags: [],
1462
- updated: "",
1614
+ updated: coerceWikiPageUpdated(p, undefined),
1463
1615
  scope: getWikiPageScope(p),
1464
1616
  }));
1465
1617
  res.json([...indexedResults, ...orphanResults]);
1466
1618
  });
1619
+ app.get("/api/wiki/browser-pages", async (req, res) => {
1620
+ ensureWikiStructure();
1621
+ const filters = {
1622
+ q: normalizeOptionalQueryParam(req.query.q),
1623
+ type: normalizeOptionalQueryParam(req.query.type),
1624
+ };
1625
+ const db = getDb();
1626
+ const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
1627
+ const pages = count > 0
1628
+ ? listDbWikiBrowserPages(filters)
1629
+ : listFallbackWikiBrowserPages(filters);
1630
+ res.json({ pages });
1631
+ });
1632
+ app.get("/api/wiki/sources", async (req, res) => {
1633
+ ensureWikiStructure();
1634
+ res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
1635
+ });
1636
+ app.get("/api/wiki/links", async (req, res) => {
1637
+ ensureWikiStructure();
1638
+ res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
1639
+ });
1467
1640
  app.get("/api/wiki/page", async (req, res) => {
1641
+ const slugParam = normalizeOptionalQueryParam(req.query.slug);
1642
+ if (slugParam) {
1643
+ const path = `pages/${slugParam}.md`;
1644
+ assertValidPagePath(path);
1645
+ const db = getDb();
1646
+ const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
1647
+ const authorizationHeader = typeof req.headers.authorization === "string"
1648
+ ? req.headers.authorization
1649
+ : undefined;
1650
+ const rawContent = await readWikiPage(path, { authorizationHeader });
1651
+ if (rawContent === undefined) {
1652
+ throw new NotFoundError("Page not found");
1653
+ }
1654
+ const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
1655
+ res.json({
1656
+ page: {
1657
+ slug: slugParam,
1658
+ title: row?.title ?? slugParam,
1659
+ type: row?.entity_type ?? "topics",
1660
+ last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
1661
+ compiled_truth: rawContent,
1662
+ pinned: Boolean(row?.pinned),
1663
+ frontmatter,
1664
+ },
1665
+ });
1666
+ return;
1667
+ }
1468
1668
  const path = assertValidPagePath(readPathParam(req));
1469
1669
  const authorizationHeader = typeof req.headers.authorization === "string"
1470
1670
  ? req.headers.authorization
@@ -1489,6 +1689,54 @@ app.put("/api/wiki/page", async (req, res) => {
1489
1689
  });
1490
1690
  res.json({ ok: true, created, path });
1491
1691
  });
1692
+ const wikiUpdateSchema = z.object({
1693
+ slug: requiredString("Missing 'slug' in request body"),
1694
+ compiled_truth: z.string().optional(),
1695
+ frontmatter: z.record(z.string(), z.unknown()).optional(),
1696
+ });
1697
+ app.post("/api/wiki/update", authMiddleware, async (req, res) => {
1698
+ const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
1699
+ const path = assertValidPagePath(`pages/${slug}.md`);
1700
+ let finalContent = compiled_truth;
1701
+ if (newFrontmatter !== undefined) {
1702
+ const existing = readPage(path);
1703
+ const base = finalContent ?? existing ?? "";
1704
+ const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
1705
+ const merged = { ...existingFrontmatter, ...newFrontmatter };
1706
+ const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
1707
+ finalContent = `---\n${fmLines}\n---\n${body}`;
1708
+ }
1709
+ if (finalContent !== undefined) {
1710
+ await withWikiWrite(() => writePage(path, finalContent));
1711
+ }
1712
+ const db = getDb();
1713
+ const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
1714
+ const content = readPage(path) ?? finalContent ?? "";
1715
+ const { parsed: frontmatter } = parseWikiFrontmatter(content);
1716
+ res.json({
1717
+ ok: true,
1718
+ page: {
1719
+ slug,
1720
+ title: row?.title ?? slug,
1721
+ type: row?.entity_type ?? "topics",
1722
+ last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
1723
+ compiled_truth: content,
1724
+ pinned: Boolean(row?.pinned),
1725
+ frontmatter,
1726
+ },
1727
+ });
1728
+ });
1729
+ const wikiPinSchema = z.object({
1730
+ slug: requiredString("Missing 'slug' in request body"),
1731
+ pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
1732
+ });
1733
+ app.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
1734
+ const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
1735
+ const path = assertValidPagePath(`pages/${slug}.md`);
1736
+ const db = getDb();
1737
+ db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
1738
+ res.json({ ok: true, pinned: Boolean(pinned) });
1739
+ });
1492
1740
  app.delete("/api/wiki/page", async (req, res) => {
1493
1741
  const path = assertValidPagePath(readPathParam(req));
1494
1742
  const removed = await withWikiWrite(() => deletePage(path));
@@ -1502,6 +1750,72 @@ app.post("/api/wiki/korg", authMiddleware, async (req, res) => {
1502
1750
  app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
1503
1751
  res.json({ sessions: listKorgResearchSessions(getDb()) });
1504
1752
  });
1753
+ const wikiIngestSchema = z.object({
1754
+ source_url: z.string().optional(),
1755
+ path: z.string().optional(),
1756
+ topic: z.string().optional(),
1757
+ });
1758
+ app.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
1759
+ const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
1760
+ const source = source_url ?? ingestPath;
1761
+ if (!source) {
1762
+ throw new BadRequestError("Missing 'source_url' or 'path' in request body");
1763
+ }
1764
+ const type = source_url ? "url" : "text";
1765
+ await withWikiWrite(() => ingestSource(source, type, topic));
1766
+ res.json({ ok: true });
1767
+ });
1768
+ const wikiSearchSchema = z.object({
1769
+ q: z.string().optional(),
1770
+ type: z.string().optional(),
1771
+ });
1772
+ app.get("/api/wiki/search", authMiddleware, async (req, res) => {
1773
+ ensureWikiStructure();
1774
+ const q = normalizeOptionalQueryParam(req.query.q);
1775
+ const type = normalizeOptionalQueryParam(req.query.type);
1776
+ const db = getDb();
1777
+ let rows;
1778
+ try {
1779
+ const clauses = ["wiki_pages_fts MATCH ?"];
1780
+ const params = [q ? `${q}*` : "*"];
1781
+ if (type) {
1782
+ clauses.push("entity_type = ?");
1783
+ params.push(type);
1784
+ }
1785
+ rows = db.prepare(`
1786
+ SELECT path, title, entity_type, last_updated
1787
+ FROM wiki_pages_fts
1788
+ WHERE ${clauses.join(" AND ")}
1789
+ ORDER BY rank
1790
+ LIMIT 50
1791
+ `).all(...params);
1792
+ }
1793
+ catch {
1794
+ const clauses = [];
1795
+ const params = [];
1796
+ if (q) {
1797
+ clauses.push("title LIKE ? COLLATE NOCASE");
1798
+ params.push(`%${q}%`);
1799
+ }
1800
+ if (type) {
1801
+ clauses.push("entity_type = ?");
1802
+ params.push(type);
1803
+ }
1804
+ rows = db.prepare(`
1805
+ SELECT path, title, entity_type, last_updated
1806
+ FROM wiki_pages
1807
+ ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
1808
+ LIMIT 50
1809
+ `).all(...params);
1810
+ }
1811
+ const pages = rows.map((row) => ({
1812
+ slug: wikiPathToBrowserSlug(row.path),
1813
+ title: row.title,
1814
+ type: row.entity_type ?? "topics",
1815
+ last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
1816
+ }));
1817
+ res.json({ pages });
1818
+ });
1505
1819
  // ---------------------------------------------------------------------------
1506
1820
  // Skills
1507
1821
  // ---------------------------------------------------------------------------