chapterhouse 0.9.1 → 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
@@ -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,
@@ -11,6 +11,16 @@ async function readWikiArtifacts() {
11
11
  const indexManager = await import(new URL(`../wiki/index-manager.js?case=${nonce}`, import.meta.url).href);
12
12
  return { wikiFs, indexManager };
13
13
  }
14
+ async function loadWikiTool(name) {
15
+ const toolsModule = await loadToolsModule();
16
+ const tools = toolsModule.createTools({
17
+ client: { async listModels() { return []; } },
18
+ onAgentTaskComplete: () => { },
19
+ });
20
+ const tool = tools.find((entry) => entry.name === name);
21
+ assert.ok(tool, `Expected tool '${name}' to be registered`);
22
+ return tool;
23
+ }
14
24
  test.before(() => {
15
25
  mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
16
26
  });
@@ -70,9 +80,143 @@ Runtime notes.
70
80
  `,
71
81
  });
72
82
  assert.equal(typeof result, "object");
73
- assert.match(result.error, /invalid 'summary'/i);
74
- assert.match(result.error, /unknown tag 'made-up-tag'/i);
75
- assert.match(result.error, /pages\/_meta\/taxonomy\.md/);
83
+ const error = result.error;
84
+ assert.match(error, /invalid 'summary'/i);
85
+ assert.match(error, /unknown tag 'made-up-tag'|tag "made-up-tag" is not in the allowed tag list/i);
86
+ assert.match(error, /Valid tags:/i);
87
+ assert.match(error, /engineering/i);
88
+ assert.match(error, /release/i);
89
+ });
90
+ test("wiki_update accepts an empty tags list", async () => {
91
+ const tool = await loadWikiTool("wiki_update");
92
+ const result = await tool.handler({
93
+ path: "pages/shared/chapterhouse.md",
94
+ title: "Chapterhouse",
95
+ summary: "Runtime notes",
96
+ content: `---
97
+ title: Chapterhouse
98
+ summary: Runtime notes
99
+ tags: []
100
+ ---
101
+
102
+ # Chapterhouse
103
+
104
+ Runtime notes.
105
+ `,
106
+ });
107
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
108
+ });
109
+ test("wiki_update accepts mixed-case valid tags with surrounding whitespace", async () => {
110
+ const tool = await loadWikiTool("wiki_update");
111
+ const { wikiFs } = await readWikiArtifacts();
112
+ wikiFs.writePage("pages/_meta/taxonomy.md", "## Custom\n- workflow\n");
113
+ const result = await tool.handler({
114
+ path: "pages/shared/chapterhouse.md",
115
+ title: "Chapterhouse",
116
+ summary: "Runtime notes",
117
+ content: `---
118
+ title: Chapterhouse
119
+ summary: Runtime notes
120
+ tags: [ Engineering , workflow ]
121
+ ---
122
+
123
+ # Chapterhouse
124
+
125
+ Runtime notes.
126
+ `,
127
+ });
128
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
129
+ });
130
+ test("wiki_update accepts summaries that are exactly 160 characters long", async () => {
131
+ const tool = await loadWikiTool("wiki_update");
132
+ const summary = "x".repeat(160);
133
+ const result = await tool.handler({
134
+ path: "pages/shared/chapterhouse.md",
135
+ title: "Chapterhouse",
136
+ summary,
137
+ content: `---
138
+ title: Chapterhouse
139
+ summary: ${summary}
140
+ tags: [engineering]
141
+ ---
142
+
143
+ # Chapterhouse
144
+
145
+ ${summary}
146
+ `,
147
+ });
148
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
149
+ });
150
+ test("wiki_update rejects summaries longer than 160 characters", async () => {
151
+ const tool = await loadWikiTool("wiki_update");
152
+ const result = await tool.handler({
153
+ path: "pages/shared/chapterhouse.md",
154
+ title: "Chapterhouse",
155
+ summary: "x".repeat(161),
156
+ content: `---
157
+ title: Chapterhouse
158
+ summary: Runtime notes
159
+ tags: [engineering]
160
+ ---
161
+
162
+ # Chapterhouse
163
+
164
+ Runtime notes.
165
+ `,
166
+ });
167
+ assert.deepEqual(result, {
168
+ error: "Summary must be 160 characters or fewer",
169
+ });
170
+ const { wikiFs } = await readWikiArtifacts();
171
+ assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
172
+ });
173
+ test("wiki_update keeps empty-summary behavior unchanged", async () => {
174
+ const tool = await loadWikiTool("wiki_update");
175
+ const result = await tool.handler({
176
+ path: "pages/shared/chapterhouse.md",
177
+ title: "Chapterhouse",
178
+ summary: "",
179
+ content: `---
180
+ title: Chapterhouse
181
+ summary:
182
+ tags: [engineering]
183
+ ---
184
+
185
+ # Chapterhouse
186
+
187
+ Runtime notes.
188
+ `,
189
+ });
190
+ assert.equal(typeof result, "object");
191
+ assert.match(result.error, /missing 'summary'/i);
192
+ });
193
+ test("wiki_ingest_source rejects local file-style inputs with a clear error", async () => {
194
+ const tool = await loadWikiTool("wiki_ingest_source");
195
+ for (const source of [
196
+ "./notes.md",
197
+ "../notes.md",
198
+ "/var/data/notes.md",
199
+ "notes.md",
200
+ "C:\\Users\\brian\\notes.md",
201
+ "file:///home/brian/notes.md",
202
+ ]) {
203
+ const result = await tool.handler({ source });
204
+ assert.equal(typeof result, "object", `Expected '${source}' to be rejected`);
205
+ const error = result.error;
206
+ assert.match(error, /local|file/i, `Expected '${source}' error to mention local-file rejection`);
207
+ assert.match(error, /url|http|https/i, `Expected '${source}' error to mention the remote URL requirement`);
208
+ }
209
+ const { wikiFs } = await readWikiArtifacts();
210
+ assert.deepEqual(wikiFs.listSources(), []);
211
+ });
212
+ test("wiki_ingest_source still passes remote URLs through to ingestSource", async () => {
213
+ const tool = await loadWikiTool("wiki_ingest_source");
214
+ const result = await tool.handler({
215
+ source: "http://127.0.0.1/private",
216
+ });
217
+ assert.deepEqual(result, {
218
+ error: "Cannot fetch internal/private URLs.",
219
+ });
76
220
  });
77
221
  test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
78
222
  const toolsModule = await loadToolsModule();
@@ -116,6 +260,211 @@ Runtime notes.
116
260
  },
117
261
  ]);
118
262
  });
263
+ test("wiki_batch_update creates multiple pages and refreshes the index", async () => {
264
+ const toolsModule = await loadToolsModule();
265
+ const tools = toolsModule.createTools({
266
+ client: { async listModels() { return []; } },
267
+ onAgentTaskComplete: () => { },
268
+ });
269
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
270
+ assert.ok(tool);
271
+ const result = await tool.handler({
272
+ pages: [
273
+ {
274
+ path: "pages/projects/atlas/index.md",
275
+ title: "Atlas",
276
+ summary: "Project atlas overview",
277
+ content: `---
278
+ title: Atlas
279
+ summary: Project atlas overview
280
+ tags: [project]
281
+ ---
282
+
283
+ # Atlas
284
+
285
+ Project atlas overview.
286
+ `,
287
+ },
288
+ {
289
+ path: "pages/people/alice/index.md",
290
+ title: "Alice",
291
+ summary: "Team profile for Alice",
292
+ section: "People",
293
+ content: `---
294
+ title: Alice
295
+ summary: Team profile for Alice
296
+ tags: [people]
297
+ ---
298
+
299
+ # Alice
300
+
301
+ Team profile for Alice.
302
+ `,
303
+ },
304
+ ],
305
+ });
306
+ assert.equal(result, "Created 2 pages successfully.");
307
+ const { wikiFs, indexManager } = await readWikiArtifacts();
308
+ const index = indexManager.parseIndex();
309
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
310
+ assert.match(wikiFs.readPage("pages/people/alice/index.md") ?? "", /summary: Team profile for Alice/);
311
+ assert.ok(index.some((entry) => entry.path === "pages/projects/atlas/index.md" && entry.summary === "Project atlas overview"));
312
+ assert.ok(index.some((entry) => entry.path === "pages/people/alice/index.md" && entry.summary === "Team profile for Alice"));
313
+ });
314
+ test("wiki_batch_update reports per-page path errors and continues other writes", async () => {
315
+ const toolsModule = await loadToolsModule();
316
+ const tools = toolsModule.createTools({
317
+ client: { async listModels() { return []; } },
318
+ onAgentTaskComplete: () => { },
319
+ });
320
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
321
+ assert.ok(tool);
322
+ const result = await tool.handler({
323
+ pages: [
324
+ {
325
+ path: "pages/projects/atlas/index.md",
326
+ title: "Atlas",
327
+ summary: "Project atlas overview",
328
+ content: `---
329
+ title: Atlas
330
+ summary: Project atlas overview
331
+ tags: [project]
332
+ ---
333
+
334
+ # Atlas
335
+
336
+ Project atlas overview.
337
+ `,
338
+ },
339
+ {
340
+ path: "../secrets.md",
341
+ title: "Secrets",
342
+ summary: "Should be rejected",
343
+ content: `---
344
+ title: Secrets
345
+ summary: Should be rejected
346
+ tags: [project]
347
+ ---
348
+
349
+ # Secrets
350
+ `,
351
+ },
352
+ ],
353
+ });
354
+ assert.equal(result, "Created 1 pages successfully.\nErrors (1):\n • ../secrets.md — Refused unsafe wiki path: ../secrets.md");
355
+ const { wikiFs } = await readWikiArtifacts();
356
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
357
+ assert.equal(wikiFs.readPage("../secrets.md"), undefined);
358
+ });
359
+ test("wiki_batch_update reports per-page tag errors and continues other writes", async () => {
360
+ const toolsModule = await loadToolsModule();
361
+ const tools = toolsModule.createTools({
362
+ client: { async listModels() { return []; } },
363
+ onAgentTaskComplete: () => { },
364
+ });
365
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
366
+ assert.ok(tool);
367
+ const result = await tool.handler({
368
+ pages: [
369
+ {
370
+ path: "pages/projects/atlas/index.md",
371
+ title: "Atlas",
372
+ summary: "Project atlas overview",
373
+ content: `---
374
+ title: Atlas
375
+ summary: Project atlas overview
376
+ tags: [project]
377
+ ---
378
+
379
+ # Atlas
380
+
381
+ Project atlas overview.
382
+ `,
383
+ },
384
+ {
385
+ path: "pages/people/alice/index.md",
386
+ title: "Alice",
387
+ summary: "Team profile for Alice",
388
+ content: `---
389
+ title: Alice
390
+ summary: Team profile for Alice
391
+ tags: [personal]
392
+ ---
393
+
394
+ # Alice
395
+
396
+ Team profile for Alice.
397
+ `,
398
+ },
399
+ ],
400
+ });
401
+ assert.equal(typeof result, "string");
402
+ assert.match(result, /^Created 1 pages successfully\.\nErrors \(1\):/);
403
+ assert.match(result, /pages\/people\/alice\/index\.md — Wiki page frontmatter violates the required shape: unknown tag 'personal'/);
404
+ const { wikiFs } = await readWikiArtifacts();
405
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
406
+ assert.equal(wikiFs.readPage("pages/people/alice/index.md"), undefined);
407
+ });
408
+ test("wiki_batch_update fails fast on an empty pages array", async () => {
409
+ const toolsModule = await loadToolsModule();
410
+ const tools = toolsModule.createTools({
411
+ client: { async listModels() { return []; } },
412
+ onAgentTaskComplete: () => { },
413
+ });
414
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
415
+ assert.ok(tool);
416
+ const result = await tool.handler({ pages: [] });
417
+ assert.deepEqual(result, { error: "Too small: expected array to have >=1 items" });
418
+ const { indexManager } = await readWikiArtifacts();
419
+ assert.deepEqual(indexManager.parseIndex(), []);
420
+ });
421
+ test("wiki_batch_update fails fast when more than 50 pages are requested", async () => {
422
+ const toolsModule = await loadToolsModule();
423
+ const tools = toolsModule.createTools({
424
+ client: { async listModels() { return []; } },
425
+ onAgentTaskComplete: () => { },
426
+ });
427
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
428
+ assert.ok(tool);
429
+ const result = await tool.handler({
430
+ pages: Array.from({ length: 51 }, (_, index) => ({
431
+ path: `pages/projects/page-${index + 1}/index.md`,
432
+ title: `Page ${index + 1}`,
433
+ summary: `Summary ${index + 1}`,
434
+ content: `---\ntitle: Page ${index + 1}\nsummary: Summary ${index + 1}\ntags: [project]\n---\n\n# Page ${index + 1}\n`,
435
+ })),
436
+ });
437
+ assert.deepEqual(result, { error: "Too big: expected array to have <=50 items" });
438
+ const { indexManager } = await readWikiArtifacts();
439
+ assert.deepEqual(indexManager.parseIndex(), []);
440
+ });
441
+ test("wiki_batch_update inherits summary length validation from the per-page schema", async () => {
442
+ const toolsModule = await loadToolsModule();
443
+ const tools = toolsModule.createTools({
444
+ client: { async listModels() { return []; } },
445
+ onAgentTaskComplete: () => { },
446
+ });
447
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
448
+ assert.ok(tool);
449
+ const result = await tool.handler({
450
+ pages: [{
451
+ path: "pages/projects/atlas/index.md",
452
+ title: "Atlas",
453
+ summary: "x".repeat(161),
454
+ content: `---
455
+ title: Atlas
456
+ summary: Project atlas overview
457
+ tags: [project]
458
+ ---
459
+
460
+ # Atlas
461
+ `,
462
+ }],
463
+ });
464
+ assert.deepEqual(result, { error: "Summary must be 160 characters or fewer" });
465
+ const { indexManager } = await readWikiArtifacts();
466
+ assert.deepEqual(indexManager.parseIndex(), []);
467
+ });
119
468
  test("retained wiki tools append audit entries to pages/_meta/log.md", async () => {
120
469
  const toolsModule = await loadToolsModule();
121
470
  const tools = toolsModule.createTools({
@@ -26,6 +26,14 @@ function isMemoryAutoAcceptEnabled() {
26
26
  return true;
27
27
  return true;
28
28
  }
29
+ function isFrictionHookEnabled() {
30
+ const raw = process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED?.trim();
31
+ if (raw === "0")
32
+ return false;
33
+ if (raw === "1")
34
+ return true;
35
+ return false;
36
+ }
29
37
  function buildReviewerSystemPrompt() {
30
38
  return [
31
39
  "You review subagent memory proposals at end-of-task.",
@@ -48,6 +56,20 @@ function buildReviewerUserPrompt(finalResult, proposals) {
48
56
  })),
49
57
  }, null, 2);
50
58
  }
59
+ function buildFrictionSystemPrompt() {
60
+ return [
61
+ "You review a completed agent task for tool friction.",
62
+ "Tool friction is: missing validation feedback, missing batch capability, silent failures,",
63
+ "overly strict input constraints, or tool gaps that caused the agent to work around limitations.",
64
+ "If you identify friction, return a JSON array of action items.",
65
+ "Each item must have: title (string), detail (string), source (always 'eot:friction').",
66
+ "If no friction was found, return an empty array [].",
67
+ "Return JSON only. No prose, no wrapping.",
68
+ ].join("\n");
69
+ }
70
+ function buildFrictionUserPrompt(finalResult) {
71
+ return JSON.stringify({ final_result: finalResult }, null, 2);
72
+ }
51
73
  function parseEnvelope(raw) {
52
74
  const parsed = JSON.parse(raw);
53
75
  if (!parsed || typeof parsed !== "object") {
@@ -121,6 +143,34 @@ function parseReviewerResponse(raw) {
121
143
  : [],
122
144
  };
123
145
  }
146
+ function parseFrictionResponse(raw) {
147
+ try {
148
+ const parsed = JSON.parse(raw);
149
+ if (!Array.isArray(parsed)) {
150
+ return [];
151
+ }
152
+ return parsed.flatMap((entry) => {
153
+ if (!entry || typeof entry !== "object") {
154
+ return [];
155
+ }
156
+ const candidate = entry;
157
+ if (!isNonEmptyString(candidate.title) || typeof candidate.detail !== "string") {
158
+ return [];
159
+ }
160
+ if (candidate.source !== "eot:friction") {
161
+ return [];
162
+ }
163
+ return [{
164
+ title: candidate.title.trim(),
165
+ detail: candidate.detail,
166
+ source: "eot:friction",
167
+ }];
168
+ }).slice(0, 3);
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ }
124
174
  function isNonEmptyString(value) {
125
175
  return typeof value === "string" && value.trim().length > 0;
126
176
  }
@@ -199,6 +249,25 @@ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
199
249
  }
200
250
  throw new Error("No memory scope could be resolved for this proposal.");
201
251
  }
252
+ function resolveActiveScopeSlug() {
253
+ const activeScope = getActiveScope();
254
+ if (activeScope) {
255
+ return activeScope.slug;
256
+ }
257
+ return getScope("chapterhouse")?.slug;
258
+ }
259
+ function resolveCallLLM(input) {
260
+ return input.callLLM ?? (async ({ system, user, model }) => {
261
+ const result = await runOneShotPrompt({
262
+ client: input.copilotClient,
263
+ model,
264
+ system,
265
+ user,
266
+ expectJson: true,
267
+ });
268
+ return result.content;
269
+ });
270
+ }
202
271
  function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
203
272
  const scope = getScope(scopeSlug);
204
273
  if (!scope) {
@@ -279,16 +348,7 @@ export async function runEndOfTaskMemoryHook(input) {
279
348
  }
280
349
  const proposals = listPendingMemoryProposalsForTask(input.taskId);
281
350
  summary.proposals_total = proposals.length;
282
- const callLLM = input.callLLM ?? (async ({ system, user, model }) => {
283
- const result = await runOneShotPrompt({
284
- client: input.copilotClient,
285
- model,
286
- system,
287
- user,
288
- expectJson: true,
289
- });
290
- return result.content;
291
- });
351
+ const callLLM = resolveCallLLM(input);
292
352
  const review = parseReviewerResponse(await callLLM({
293
353
  system: buildReviewerSystemPrompt(),
294
354
  user: buildReviewerUserPrompt(input.finalResult, proposals),
@@ -347,8 +407,51 @@ export async function runEndOfTaskMemoryHook(input) {
347
407
  }
348
408
  }
349
409
  }
410
+ await runFrictionHook({
411
+ taskId: input.taskId,
412
+ finalResult: input.finalResult,
413
+ copilotClient: input.copilotClient,
414
+ callLLM,
415
+ model: input.model,
416
+ });
350
417
  log.info(summary, "memory.eot.processed");
351
418
  input.onProcessed?.(summary);
352
419
  return summary;
353
420
  }
421
+ export async function runFrictionHook(input) {
422
+ try {
423
+ if (!isFrictionHookEnabled()) {
424
+ return;
425
+ }
426
+ if (input.finalResult.trim().length <= 100) {
427
+ return;
428
+ }
429
+ const scopeSlug = resolveActiveScopeSlug();
430
+ if (!scopeSlug) {
431
+ return;
432
+ }
433
+ const callLLM = resolveCallLLM(input);
434
+ const raw = await callLLM({
435
+ system: buildFrictionSystemPrompt(),
436
+ user: buildFrictionUserPrompt(input.finalResult),
437
+ model: input.model ?? config.copilotModel,
438
+ });
439
+ const frictionItems = parseFrictionResponse(raw);
440
+ for (const item of frictionItems) {
441
+ try {
442
+ rememberAcceptedMemory("action_item", scopeSlug, {
443
+ title: item.title,
444
+ detail: item.detail,
445
+ source: item.source,
446
+ }, "eot:friction");
447
+ }
448
+ catch (err) {
449
+ log.warn({ err, taskId: input.taskId, title: item.title }, "friction hook: failed to record action item");
450
+ }
451
+ }
452
+ }
453
+ catch (err) {
454
+ log.warn({ err, taskId: input.taskId }, "friction hook failed");
455
+ }
456
+ }
354
457
  //# sourceMappingURL=eot.js.map
@@ -763,4 +763,119 @@ test("runEndOfTaskMemoryHook persists implicit observation memories with valid c
763
763
  assert.equal(summary.implicit_extracted, 1);
764
764
  assert.equal(warnings.length, 0);
765
765
  });
766
+ test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
767
+ const { eotModule } = await loadModules("friction-disabled-default");
768
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
769
+ let llmCalls = 0;
770
+ await runFrictionHook({
771
+ taskId: "task-friction-disabled-default",
772
+ finalResult: "A substantive final result that is definitely longer than one hundred characters to prove the friction hook still stays off by default.",
773
+ copilotClient: {},
774
+ callLLM: async () => {
775
+ llmCalls++;
776
+ return "[]";
777
+ },
778
+ });
779
+ assert.equal(llmCalls, 0);
780
+ });
781
+ test("runFrictionHook skips short final results even when enabled", async () => {
782
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
783
+ const { eotModule } = await loadModules("friction-short-result");
784
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
785
+ let llmCalls = 0;
786
+ await runFrictionHook({
787
+ taskId: "task-friction-short-result",
788
+ finalResult: "too short",
789
+ copilotClient: {},
790
+ callLLM: async () => {
791
+ llmCalls++;
792
+ return "[]";
793
+ },
794
+ });
795
+ assert.equal(llmCalls, 0);
796
+ });
797
+ test("runFrictionHook records action items when enabled and the task result is substantive", async () => {
798
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
799
+ const { memoryModule, eotModule } = await loadModules("friction-records-action-items");
800
+ const getScope = getFunction(memoryModule, "getScope");
801
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
802
+ const listActionItems = getFunction(memoryModule, "listActionItems");
803
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
804
+ const chapterhouse = getScope("chapterhouse");
805
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
806
+ setActiveScope("chapterhouse");
807
+ await runFrictionHook({
808
+ taskId: "task-friction-records-action-items",
809
+ finalResult: "The agent had to retry the same command several times because the tool returned a generic error with no validation detail, which made the task materially slower and harder to finish cleanly.",
810
+ copilotClient: {},
811
+ callLLM: async () => JSON.stringify([
812
+ {
813
+ title: "Improve validation feedback for memory tools",
814
+ detail: "Return the rejected field name and allowed values instead of a generic failure.",
815
+ source: "eot:friction",
816
+ },
817
+ ]),
818
+ });
819
+ const actionItems = listActionItems({ scope_id: chapterhouse.id });
820
+ assert.equal(actionItems.length, 1);
821
+ assert.equal(actionItems[0]?.title, "Improve validation feedback for memory tools");
822
+ assert.equal(actionItems[0]?.detail, "Return the rejected field name and allowed values instead of a generic failure.");
823
+ assert.equal(actionItems[0]?.source, "eot:friction");
824
+ assert.equal(actionItems[0]?.status, "open");
825
+ });
826
+ test("runFrictionHook caps parsed friction items at 3", async () => {
827
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
828
+ const { memoryModule, eotModule } = await loadModules("friction-cap");
829
+ const getScope = getFunction(memoryModule, "getScope");
830
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
831
+ const listActionItems = getFunction(memoryModule, "listActionItems");
832
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
833
+ const chapterhouse = getScope("chapterhouse");
834
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
835
+ setActiveScope("chapterhouse");
836
+ await runFrictionHook({
837
+ taskId: "task-friction-cap",
838
+ finalResult: "The toolchain created several distinct sources of friction across the task, and the result is long enough that the friction hook should inspect it and write only the first three issues back into memory.",
839
+ copilotClient: {},
840
+ callLLM: async () => JSON.stringify([
841
+ { title: "Item 1", detail: "detail 1", source: "eot:friction" },
842
+ { title: "Item 2", detail: "detail 2", source: "eot:friction" },
843
+ { title: "Item 3", detail: "detail 3", source: "eot:friction" },
844
+ { title: "Item 4", detail: "detail 4", source: "eot:friction" },
845
+ ]),
846
+ });
847
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.title).sort(), ["Item 1", "Item 2", "Item 3"]);
848
+ });
849
+ test("runFrictionHook treats malformed JSON as no friction items", async () => {
850
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
851
+ const { memoryModule, eotModule } = await loadModules("friction-malformed-json");
852
+ const getScope = getFunction(memoryModule, "getScope");
853
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
854
+ const listActionItems = getFunction(memoryModule, "listActionItems");
855
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
856
+ const chapterhouse = getScope("chapterhouse");
857
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
858
+ setActiveScope("chapterhouse");
859
+ await runFrictionHook({
860
+ taskId: "task-friction-malformed-json",
861
+ finalResult: "The agent hit confusing tool friction repeatedly, but the friction reviewer returned malformed JSON and the hook should safely ignore it without writing any action items.",
862
+ copilotClient: {},
863
+ callLLM: async () => "{not valid json",
864
+ });
865
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
866
+ });
867
+ test("runFrictionHook never propagates errors from the LLM call", async (t) => {
868
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
869
+ const { eotModule, warnings } = await loadModulesWithWarnSpy(t, "friction-no-throw");
870
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
871
+ await assert.doesNotReject(() => runFrictionHook({
872
+ taskId: "task-friction-no-throw",
873
+ finalResult: "The agent encountered enough friction to trigger the hook, but the LLM call itself crashed and the hook must still fail closed without breaking end-of-task processing.",
874
+ copilotClient: {},
875
+ callLLM: async () => {
876
+ throw new Error("boom");
877
+ },
878
+ }));
879
+ assert.equal(warnings.length, 1);
880
+ });
766
881
  //# sourceMappingURL=eot.test.js.map
@@ -175,12 +175,13 @@ export function validateWikiFrontmatter(content, options = {}) {
175
175
  }
176
176
  if (options.allowedTags && parsed.tags) {
177
177
  const allowed = new Set(options.allowedTags.map((tag) => tag.toLowerCase()));
178
+ const validTags = options.allowedTags.join(", ");
178
179
  for (const tag of parsed.tags) {
179
180
  if (!allowed.has(tag.toLowerCase())) {
180
181
  errors.push({
181
182
  rule: "unknown-tag",
182
183
  field: "tags",
183
- message: formatFrontmatterMessage(`unknown tag '${tag}'. Add it to \`pages/_meta/taxonomy.md\` first.`),
184
+ message: formatFrontmatterMessage(`unknown tag '${tag}'. Tag "${tag}" is not in the allowed tag list. Valid tags: ${validTags}. Add it to \`pages/_meta/taxonomy.md\` first.`),
184
185
  });
185
186
  }
186
187
  }
@@ -104,7 +104,8 @@ tags: [engineering, made-up-tag]
104
104
  `, { allowedTags: ["engineering", "release"] });
105
105
  assert.equal(result.valid, false);
106
106
  assert.deepEqual(result.errors.map((error) => error.rule), ["unknown-tag"]);
107
- assert.match(result.errors[0]?.message ?? "", /Add it to `pages\/_meta\/taxonomy\.md` first\./);
107
+ assert.match(result.errors[0]?.message ?? "", /made-up-tag/);
108
+ assert.match(result.errors[0]?.message ?? "", /Valid tags: engineering, release/);
108
109
  });
109
110
  test("parseProjectRulesFrontmatter parses typed hard-rule fields and flags unknown keys", async () => {
110
111
  const { parseProjectRulesFrontmatter } = await loadFrontmatterModule();
@@ -331,6 +331,22 @@ export async function ingestSource(source, type, topic, session) {
331
331
  // ---------------------------------------------------------------------------
332
332
  // Type auto-detection
333
333
  // ---------------------------------------------------------------------------
334
+ const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
335
+ export function looksLikeLocalFilePath(source) {
336
+ const trimmed = source.trim();
337
+ if (!trimmed)
338
+ return false;
339
+ if (/^[a-z]:[\\/]/i.test(trimmed))
340
+ return true;
341
+ if (trimmed.startsWith("file://") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/") || trimmed.startsWith("~/")) {
342
+ return true;
343
+ }
344
+ if (URL_SCHEME_RE.test(trimmed))
345
+ return false;
346
+ if (/\s/.test(trimmed))
347
+ return false;
348
+ return trimmed.includes("/") || trimmed.includes("\\") || /\.[a-z0-9]+$/i.test(trimmed);
349
+ }
334
350
  export function detectSourceType(source) {
335
351
  const trimmed = source.trim();
336
352
  if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"