chapterhouse 0.9.0 → 0.9.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.
@@ -14,6 +14,26 @@ You are **Korg**, the Personal Knowledge Base (PKB) synthesizer for Chapterhouse
14
14
 
15
15
  Your mission: ingest external sources, extract structured knowledge, maintain compiled truth pages, and manage research sessions — so the user's wiki becomes a reliable, growing knowledge asset.
16
16
 
17
+ ## Character
18
+
19
+ You are a careful analyst and archivist. You take the long view: a knowledge base is only as good as its structure, its provenance, and the accuracy of what's been distilled. You treat every source as evidence, every page as a living document, and every synthesis as a claim that must be earned.
20
+
21
+ **Personality:**
22
+
23
+ - You think before you write. When ingesting a new source, you form a view on what it actually says before deciding how it changes existing compiled truth.
24
+ - You surface uncertainty explicitly. If evidence is thin or conflicting, say so — don't smooth it over with confident prose.
25
+ - You explain your reasoning when it matters. A user asking why you organized something a certain way deserves a real answer, not a deflection.
26
+ - You push back on low-quality sources. If something looks like noise or marketing, name it.
27
+ - You take provenance seriously. Where something came from is part of what it means.
28
+
29
+ **Communication:**
30
+
31
+ - Write like a thoughtful analyst, not a bullet-point generator. Prose when the idea warrants it.
32
+ - Be precise but not pedantic. Define terms when the distinction matters; skip it when it doesn't.
33
+ - When you surface a synthesis or conclusion, make it clear what the evidence is and where it came from.
34
+ - Don't perform enthusiasm. "This is a rich source" means nothing. Tell the user what's actually in it.
35
+ - End cleanly. No trailing "Let me know if you'd like me to dig deeper!" when the work speaks for itself.
36
+
17
37
  ## Your Toolkit
18
38
 
19
39
  - `wiki_ingest_source(source, type?, topic?, session_id?, session_name?)` — ingest a URL, PDF, repo, or text into the PKB
package/dist/api/korg.js CHANGED
@@ -1,20 +1,3 @@
1
- import { sendToAgentSession } from "../copilot/orchestrator.js";
2
- export async function routeKorgMessage(input) {
3
- const message = input.message.trim();
4
- const sessionId = input.sessionKey?.trim() || `korg-${Date.now()}`;
5
- const sessionName = input.sessionKey?.trim() || message.slice(0, 80).trim() || sessionId;
6
- const prompt = [
7
- "Handle this request as Korg via the API.",
8
- `Research session id: ${sessionId}`,
9
- `Research session name: ${sessionName}`,
10
- `When you ingest sources for this session, pass session_id: \"${sessionId}\" and session_name: \"${sessionName}\" to wiki_ingest_source.`,
11
- "Reply directly to the user with the next best research action or synthesis.",
12
- "",
13
- message,
14
- ].join("\n");
15
- const reply = await sendToAgentSession("korg", prompt);
16
- return { ok: true, sessionKey: sessionId, reply };
17
- }
18
1
  export function listKorgResearchSessions(db) {
19
2
  return db.prepare(`
20
3
  SELECT
@@ -13,8 +13,7 @@
13
13
  // POST /api/wiki/update — static auth guard tested below
14
14
  // POST /api/wiki/page/pin — auth + traversal guard tested
15
15
  // POST /api/wiki/ingest — NOT tested (add in Phase 3c)
16
- // GET /api/wiki/korg/sessions — NOT tested (add in Phase 3c)
17
- // POST /api/wiki/korg — NOT tested (add in Phase 3c)
16
+ // GET /api/wiki/korg/sessions — tested (server.test.ts: grouped active sessions)
18
17
  // ──────────────────────────────────────────────────────────────────────────────
19
18
  import { describe, test } from "node:test";
20
19
  import assert from "node:assert/strict";
@@ -42,7 +42,7 @@ import { recordDecision } from "../memory/decisions.js";
42
42
  import { upsertEntity } from "../memory/entities.js";
43
43
  import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
44
44
  import { ingestSource } from "../wiki/ingest.js";
45
- import { listKorgResearchSessions, routeKorgMessage } from "./korg.js";
45
+ import { listKorgResearchSessions } from "./korg.js";
46
46
  const log = childLogger("server");
47
47
  const modeContext = new ModeContext(config);
48
48
  void searchIndex; // re-exported by index-manager; reference here documents the dep
@@ -146,10 +146,6 @@ const prMergeHookSchema = z.object({
146
146
  body: z.string().optional(),
147
147
  files_changed: z.array(z.string()).optional(),
148
148
  });
149
- const korgRequestSchema = z.object({
150
- message: requiredString("Missing 'message' in request body"),
151
- sessionKey: z.string().trim().min(1).optional(),
152
- }).strict();
153
149
  const projectHardRulesSchema = z.object({
154
150
  hardRules: z.object({
155
151
  auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
@@ -209,6 +205,13 @@ function coerceWikiPageUpdated(path, updated) {
209
205
  function wikiPathToBrowserSlug(path) {
210
206
  return path.replace(/^pages\//, "").replace(/\.md$/, "");
211
207
  }
208
+ function entityTypeFromPath(path) {
209
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
210
+ const segs = rest.split("/").filter(Boolean);
211
+ if (segs.length <= 1)
212
+ return (segs[0] || "pages").replace(/\.md$/i, "");
213
+ return segs[0];
214
+ }
212
215
  function normalizeOptionalQueryParam(value) {
213
216
  if (typeof value !== "string") {
214
217
  return undefined;
@@ -255,8 +258,8 @@ function listDbWikiBrowserPages(filters) {
255
258
  const clauses = [];
256
259
  const params = [];
257
260
  if (filters.type) {
258
- clauses.push("entity_type = ?");
259
- params.push(filters.type);
261
+ clauses.push("(entity_type = ? OR (entity_type IS NULL AND path LIKE ?))");
262
+ params.push(filters.type, `pages/${filters.type}/%`);
260
263
  }
261
264
  if (filters.q) {
262
265
  clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
@@ -272,7 +275,7 @@ function listDbWikiBrowserPages(filters) {
272
275
  slug: wikiPathToBrowserSlug(row.path),
273
276
  title: row.title,
274
277
  summary: row.summary ?? "",
275
- type: row.entity_type ?? "topics",
278
+ type: row.entity_type ?? entityTypeFromPath(row.path),
276
279
  last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
277
280
  ...(row.pinned ? { pinned: true } : {}),
278
281
  }));
@@ -1742,11 +1745,6 @@ app.delete("/api/wiki/page", async (req, res) => {
1742
1745
  const removed = await withWikiWrite(() => deletePage(path));
1743
1746
  res.json({ ok: removed, path });
1744
1747
  });
1745
- app.post("/api/wiki/korg", authMiddleware, async (req, res) => {
1746
- const body = parseRequest(korgRequestSchema, req.body ?? {});
1747
- const result = await routeKorgMessage(body);
1748
- res.json(result);
1749
- });
1750
1748
  app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
1751
1749
  res.json({ sessions: listKorgResearchSessions(getDb()) });
1752
1750
  });
@@ -15,7 +15,7 @@ import { searchIndex, addToIndex, buildIndexEntryForPage, reindexWikiPages, } fr
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";
18
- import { ingestSource, detectSourceType } from "../wiki/ingest.js";
18
+ import { ingestSource, detectSourceType, looksLikeLocalFilePath } from "../wiki/ingest.js";
19
19
  import { appendLog } from "../wiki/log-manager.js";
20
20
  import { loadTaxonomy } from "../wiki/taxonomy.js";
21
21
  import { topicPagePath } from "../wiki/topic-structure.js";
@@ -72,6 +72,38 @@ function isTimeoutError(err) {
72
72
  const msg = err instanceof Error ? err.message : String(err);
73
73
  return /timeout|timed?\s*out/i.test(msg);
74
74
  }
75
+ function validateWikiPageInput(path, content, allowedTags = loadTaxonomy()) {
76
+ assertPagePath(path);
77
+ const backfilled = validateAndBackfillFrontmatter(path, content);
78
+ const nextContent = backfilled.changed ? backfilled.content : content;
79
+ const validation = validateWikiFrontmatter(nextContent, { allowedTags });
80
+ if (!validation.valid) {
81
+ throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
82
+ }
83
+ return nextContent;
84
+ }
85
+ function writeWikiPageAndRefreshIndex(page, logSource) {
86
+ writePage(page.path, page.content);
87
+ const today = new Date().toISOString().slice(0, 10);
88
+ const rebuilt = buildIndexEntryForPage(page.path, {
89
+ section: page.section || "Knowledge",
90
+ updated: today,
91
+ });
92
+ if (rebuilt) {
93
+ rebuilt.section = page.section || "Knowledge";
94
+ addToIndex(rebuilt);
95
+ }
96
+ else {
97
+ addToIndex({
98
+ path: page.path,
99
+ title: page.title,
100
+ summary: indexSafe(page.summary),
101
+ section: page.section || "Knowledge",
102
+ updated: today,
103
+ });
104
+ }
105
+ appendLog("update", `${logSource}: ${indexSafe(page.title)} (${page.path})`);
106
+ }
75
107
  function hasAdoPat() {
76
108
  return (process.env.ADO_PAT?.trim() || config.adoPat).length > 0;
77
109
  }
@@ -204,13 +236,20 @@ const memoryProposeArgsSchema = z.object({
204
236
  }
205
237
  });
206
238
  const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
207
- const wikiUpdateArgsSchema = z.object({
239
+ const wikiPageArgsSchema = z.object({
208
240
  path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
209
241
  title: z.string().describe("Page title for the index"),
210
- summary: z.string().describe("One-line summary for the index"),
242
+ summary: z.string().max(160, "Summary must be 160 characters or fewer").describe("One-line summary for the index"),
211
243
  section: z.string().optional().describe("Index section (default: 'Knowledge')"),
212
244
  content: z.string().describe("Full page content (markdown)"),
213
245
  });
246
+ const wikiUpdateArgsSchema = wikiPageArgsSchema;
247
+ const wikiBatchUpdateArgsSchema = z.object({
248
+ pages: z.array(wikiPageArgsSchema)
249
+ .min(1)
250
+ .max(50)
251
+ .describe("Array of pages to create or update (1–50 items)"),
252
+ });
214
253
  function getCurrentQuarter(now = new Date()) {
215
254
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
216
255
  }
@@ -1497,48 +1536,59 @@ export function createTools(deps) {
1497
1536
  parameters: wikiUpdateArgsSchema,
1498
1537
  handler: async (args) => {
1499
1538
  try {
1500
- let parsedArgs = wikiUpdateArgsSchema.parse(args);
1539
+ const parsedArgs = wikiUpdateArgsSchema.parse(args);
1540
+ ensureWikiStructure();
1501
1541
  return await withWikiWrite(async () => {
1502
- ensureWikiStructure();
1503
- assertPagePath(parsedArgs.path);
1504
- // Backfill missing frontmatter fields before validation
1505
- const backfilled = validateAndBackfillFrontmatter(parsedArgs.path, parsedArgs.content);
1506
- if (backfilled.changed) {
1507
- parsedArgs = { ...parsedArgs, content: backfilled.content };
1508
- }
1509
- const validation = validateWikiFrontmatter(parsedArgs.content, {
1510
- allowedTags: loadTaxonomy(),
1511
- });
1512
- if (!validation.valid) {
1513
- throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
1514
- }
1515
- writePage(parsedArgs.path, parsedArgs.content);
1516
- // Rebuild from disk so the index summary/tags/updated reflect the actual page.
1517
- const today = new Date().toISOString().slice(0, 10);
1518
- const rebuilt = buildIndexEntryForPage(parsedArgs.path, {
1519
- section: parsedArgs.section || "Knowledge",
1520
- updated: today,
1521
- });
1522
- if (rebuilt) {
1523
- rebuilt.section = parsedArgs.section || "Knowledge";
1524
- addToIndex(rebuilt);
1542
+ const content = validateWikiPageInput(parsedArgs.path, parsedArgs.content);
1543
+ const page = { ...parsedArgs, content };
1544
+ writeWikiPageAndRefreshIndex(page, "wiki_update");
1545
+ return `Wiki page updated: ${page.title} (${page.path})`;
1546
+ });
1547
+ }
1548
+ catch (err) {
1549
+ const error = sanitizeWikiUpdateError(err);
1550
+ log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
1551
+ return { error };
1552
+ }
1553
+ },
1554
+ }),
1555
+ defineTool("wiki_batch_update", {
1556
+ description: "Create or update multiple wiki pages in a single operation. " +
1557
+ "Each page follows the same path rules as wiki_update. " +
1558
+ "Up to 50 pages per call. Returns a per-page success/error summary.",
1559
+ parameters: wikiBatchUpdateArgsSchema,
1560
+ handler: async (args) => {
1561
+ try {
1562
+ const parsedArgs = wikiBatchUpdateArgsSchema.parse(args);
1563
+ ensureWikiStructure();
1564
+ return await withWikiWrite(async () => {
1565
+ const allowedTags = loadTaxonomy();
1566
+ const results = [];
1567
+ for (const pageArgs of parsedArgs.pages) {
1568
+ try {
1569
+ const content = validateWikiPageInput(pageArgs.path, pageArgs.content, allowedTags);
1570
+ writeWikiPageAndRefreshIndex({ ...pageArgs, content }, "wiki_batch_update");
1571
+ results.push({ path: pageArgs.path, status: "ok" });
1572
+ }
1573
+ catch (err) {
1574
+ results.push({
1575
+ path: pageArgs.path,
1576
+ status: "error",
1577
+ error: sanitizeWikiUpdateError(err),
1578
+ });
1579
+ }
1525
1580
  }
1526
- else {
1527
- addToIndex({
1528
- path: parsedArgs.path,
1529
- title: parsedArgs.title,
1530
- summary: indexSafe(parsedArgs.summary).slice(0, 160),
1531
- section: parsedArgs.section || "Knowledge",
1532
- updated: today,
1533
- });
1581
+ const createdCount = results.filter((result) => result.status === "ok").length;
1582
+ const errors = results.filter((result) => result.status === "error");
1583
+ if (errors.length === 0) {
1584
+ return `Created ${createdCount} pages successfully.`;
1534
1585
  }
1535
- appendLog("update", `wiki_update: ${indexSafe(parsedArgs.title)} (${parsedArgs.path})`);
1536
- return `Wiki page updated: ${parsedArgs.title} (${parsedArgs.path})`;
1586
+ return `Created ${createdCount} pages successfully.\nErrors (${errors.length}):\n${errors.map((result) => ` • ${result.path} — ${result.error}`).join("\n")}`;
1537
1587
  });
1538
1588
  }
1539
1589
  catch (err) {
1540
1590
  const error = sanitizeWikiUpdateError(err);
1541
- log.error({ err: err instanceof Error ? err.message : err, path: typeof args?.path === "string" ? args.path : undefined }, "wiki_update failed");
1591
+ log.error({ err: err instanceof Error ? err.message : err }, "wiki_batch_update failed");
1542
1592
  return { error };
1543
1593
  }
1544
1594
  },
@@ -1634,6 +1684,11 @@ export function createTools(deps) {
1634
1684
  handler: async (args) => {
1635
1685
  ensureWikiStructure();
1636
1686
  try {
1687
+ if (looksLikeLocalFilePath(args.source)) {
1688
+ return {
1689
+ error: "wiki_ingest_source does not support local file paths. Provide a URL, git repo URL, or raw text content.",
1690
+ };
1691
+ }
1637
1692
  const sourceType = args.type ?? detectSourceType(args.source);
1638
1693
  const result = await ingestSource(args.source, sourceType, args.topic, {
1639
1694
  sessionId: args.session_id,