chapterhouse 0.6.0 → 0.8.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 (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -124,6 +124,22 @@ function readProjectRegistryRows(testRoot) {
124
124
  db.close();
125
125
  }
126
126
  }
127
+ function getAgentFilePath(testRoot, slug) {
128
+ return join(testRoot, ".chapterhouse", "agents", `${slug}.agent.md`);
129
+ }
130
+ function writeAgentFile(testRoot, slug, content) {
131
+ mkdirSync(join(testRoot, ".chapterhouse", "agents"), { recursive: true });
132
+ writeFileSync(getAgentFilePath(testRoot, slug), content, "utf-8");
133
+ }
134
+ function markBundledAgent(testRoot, slug, hash = "bundled-hash") {
135
+ const db = new Database(getProjectDbPath(testRoot));
136
+ try {
137
+ db.prepare(`INSERT OR REPLACE INTO max_state (key, value) VALUES (?, ?)`).run(`bundled_agent_hash:${slug}.agent.md`, hash);
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ }
127
143
  function setMemoryActiveScope(testRoot, slug) {
128
144
  const db = new Database(getProjectDbPath(testRoot));
129
145
  try {
@@ -192,6 +208,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SER
192
208
  rmSync(testRoot, { recursive: true, force: true });
193
209
  }
194
210
  }
211
+ test("agent edits require a team lead when Entra auth is enabled", async () => {
212
+ const accessModule = await import("./agent-edit-access.js");
213
+ assert.equal(typeof accessModule.assertAgentEditAccess, "function", "assertAgentEditAccess should be exported");
214
+ const assertAgentEditAccess = accessModule.assertAgentEditAccess;
215
+ assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: false }, { role: "engineer" }));
216
+ assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "engineer" }), /Admin access required/);
217
+ assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }), /Admin access required/);
218
+ assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "team-lead" }));
219
+ });
195
220
  test("server routes expose bootstrap and public config without auth", async () => {
196
221
  await withStartedServer(async ({ baseUrl }) => {
197
222
  const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
@@ -216,10 +241,319 @@ test("server channels route returns chapterhouse plus persistent agents in chann
216
241
  const channels = await response.json();
217
242
  assert.deepEqual(channels.map((channel) => channel.key), [
218
243
  "default",
244
+ "agent:korg",
219
245
  ]);
220
246
  assert.deepEqual(channels.map((channel) => channel.label), [
221
247
  "# chapterhouse",
248
+ "# korg",
222
249
  ]);
250
+ assert.equal(channels[1]?.scope, "pkb");
251
+ });
252
+ });
253
+ test("server agents routes return source-aware charter summaries and details", async () => {
254
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
255
+ writeAgentFile(testRoot, "alpha-custom", [
256
+ "---",
257
+ "name: Alpha Custom",
258
+ "description: First custom charter",
259
+ "model: gpt-5.4",
260
+ "scope: frontend",
261
+ "skills:",
262
+ " - ui-copy",
263
+ " - wireframes",
264
+ "---",
265
+ "",
266
+ "You are Alpha Custom.",
267
+ ].join("\n"));
268
+ writeAgentFile(testRoot, "zulu-custom", [
269
+ "---",
270
+ "name: Zulu Custom",
271
+ "description: Last custom charter",
272
+ "model: claude-sonnet-4.6",
273
+ "persistent: true",
274
+ "---",
275
+ "",
276
+ "You are Zulu Custom.",
277
+ ].join("\n"));
278
+ markBundledAgent(testRoot, "designer");
279
+ const listResponse = await fetch(`${baseUrl}/api/agents`, {
280
+ headers: { authorization: authHeader },
281
+ });
282
+ assert.equal(listResponse.status, 200);
283
+ const agents = await listResponse.json();
284
+ const firstCustomIndex = agents.findIndex((agent) => agent.type === "custom");
285
+ assert.notEqual(firstCustomIndex, -1);
286
+ assert.ok(agents.slice(0, firstCustomIndex).every((agent) => agent.type === "builtin"));
287
+ const customAgents = agents.filter((agent) => agent.type === "custom");
288
+ assert.deepEqual(customAgents.map((agent) => agent.name), ["Alpha Custom", "Zulu Custom"]);
289
+ const designer = agents.find((agent) => agent.slug === "designer");
290
+ assert.ok(designer);
291
+ assert.equal(designer.type, "builtin");
292
+ assert.equal(typeof designer.lastEdited, "string");
293
+ const alpha = agents.find((agent) => agent.slug === "alpha-custom");
294
+ assert.deepEqual(alpha, {
295
+ name: "Alpha Custom",
296
+ slug: "alpha-custom",
297
+ description: "First custom charter",
298
+ model: "gpt-5.4",
299
+ scope: "frontend",
300
+ type: "custom",
301
+ lastEdited: alpha?.lastEdited ?? null,
302
+ });
303
+ assert.equal(typeof alpha?.lastEdited, "string");
304
+ const detailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("alpha-custom")}`, {
305
+ headers: { authorization: authHeader },
306
+ });
307
+ assert.equal(detailResponse.status, 200);
308
+ assert.deepEqual(await detailResponse.json(), {
309
+ name: "Alpha Custom",
310
+ slug: "alpha-custom",
311
+ description: "First custom charter",
312
+ model: "gpt-5.4",
313
+ scope: "frontend",
314
+ persistent: false,
315
+ skills: ["ui-copy", "wireframes"],
316
+ type: "custom",
317
+ editable: true,
318
+ systemPrompt: "You are Alpha Custom.",
319
+ });
320
+ const builtinDetailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("designer")}`, {
321
+ headers: { authorization: authHeader },
322
+ });
323
+ assert.equal(builtinDetailResponse.status, 200);
324
+ const builtinDetail = await builtinDetailResponse.json();
325
+ assert.equal(builtinDetail.slug, "designer");
326
+ assert.equal(builtinDetail.type, "builtin");
327
+ assert.equal(builtinDetail.editable, false);
328
+ assert.equal(typeof builtinDetail.systemPrompt, "string");
329
+ assert.ok(builtinDetail.systemPrompt.length > 0);
330
+ const missingResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("missing-agent")}`, {
331
+ headers: { authorization: authHeader },
332
+ });
333
+ assert.equal(missingResponse.status, 404);
334
+ assert.deepEqual(await missingResponse.json(), { error: "Agent 'missing-agent' not found" });
335
+ });
336
+ });
337
+ test("server rejects invalid agent slugs before reading agent files", async () => {
338
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
339
+ writeFileSync(join(testRoot, "some-path.agent.md"), [
340
+ "---",
341
+ "name: Escaped Agent",
342
+ "description: Should never be readable",
343
+ "model: gpt-5.4",
344
+ "---",
345
+ "",
346
+ "You should not see this.",
347
+ ].join("\n"), "utf-8");
348
+ const response = await fetch(`${baseUrl}/api/agents/..%2F..%2Fsome-path`, {
349
+ headers: { authorization: authHeader },
350
+ });
351
+ assert.equal(response.status, 400);
352
+ assert.deepEqual(await response.json(), { error: "Invalid slug" });
353
+ });
354
+ });
355
+ test("server patches a custom agent file and reloads the persistent agent registry", async () => {
356
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
357
+ writeAgentFile(testRoot, "scribe", [
358
+ "---",
359
+ "name: Scribe",
360
+ "description: Drafts release notes",
361
+ "model: claude-sonnet-4.6",
362
+ "persistent: true",
363
+ "scope: docs",
364
+ "skills:",
365
+ " - writing",
366
+ "tools:",
367
+ " - bash",
368
+ "---",
369
+ "",
370
+ "Original system prompt.",
371
+ ].join("\n"));
372
+ const before = await fetch(`${baseUrl}/api/channels`, {
373
+ headers: { authorization: authHeader },
374
+ });
375
+ assert.equal(before.status, 200);
376
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
377
+ method: "PATCH",
378
+ headers: {
379
+ authorization: authHeader,
380
+ "content-type": "application/json",
381
+ },
382
+ body: JSON.stringify({
383
+ name: "Scribe Prime",
384
+ description: "Publishes saved agent edits",
385
+ systemPrompt: "# Updated Prompt\n\nShip it.",
386
+ }),
387
+ });
388
+ assert.equal(response.status, 200);
389
+ assert.deepEqual(await response.json(), {
390
+ name: "Scribe Prime",
391
+ slug: "scribe",
392
+ description: "Publishes saved agent edits",
393
+ model: "claude-sonnet-4.6",
394
+ scope: "docs",
395
+ persistent: true,
396
+ skills: ["writing"],
397
+ type: "custom",
398
+ editable: true,
399
+ systemPrompt: "# Updated Prompt\n\nShip it.",
400
+ });
401
+ const saved = readFileSync(getAgentFilePath(testRoot, "scribe"), "utf-8");
402
+ assert.match(saved, /^---\n[\s\S]*\n---\n\n# Updated Prompt\n\nShip it\.\n?$/);
403
+ assert.match(saved, /name: Scribe Prime/);
404
+ assert.match(saved, /description: Publishes saved agent edits/);
405
+ assert.match(saved, /model: claude-sonnet-4.6/);
406
+ assert.match(saved, /scope: docs/);
407
+ assert.match(saved, /tools:\n - bash/);
408
+ assert.match(saved, /skills:\n - writing/);
409
+ const after = await fetch(`${baseUrl}/api/channels`, {
410
+ headers: { authorization: authHeader },
411
+ });
412
+ assert.equal(after.status, 200);
413
+ const channels = await after.json();
414
+ assert.deepEqual(channels.find((channel) => channel.slug === "scribe"), {
415
+ key: "agent:scribe",
416
+ label: "# scribe",
417
+ slug: "scribe",
418
+ name: "Scribe Prime",
419
+ description: "Publishes saved agent edits",
420
+ scope: "docs",
421
+ });
422
+ });
423
+ });
424
+ test("server rejects PATCH /api/agents/:slug when the request changes skills", async () => {
425
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
426
+ writeAgentFile(testRoot, "scribe", [
427
+ "---",
428
+ "name: Scribe",
429
+ "description: Drafts release notes",
430
+ "model: claude-sonnet-4.6",
431
+ "skills:",
432
+ " - writing",
433
+ "---",
434
+ "",
435
+ "Original system prompt.",
436
+ ].join("\n"));
437
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
438
+ method: "PATCH",
439
+ headers: {
440
+ authorization: authHeader,
441
+ "content-type": "application/json",
442
+ },
443
+ body: JSON.stringify({ skills: ["editing", "review"] }),
444
+ });
445
+ assert.equal(response.status, 400);
446
+ assert.match((await response.json()).error, /skills/i);
447
+ });
448
+ });
449
+ test("server rejects PATCH /api/agents/:slug when the request changes read-only fields", async () => {
450
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
451
+ writeAgentFile(testRoot, "scribe", [
452
+ "---",
453
+ "name: Scribe",
454
+ "description: Drafts release notes",
455
+ "model: claude-sonnet-4.6",
456
+ "---",
457
+ "",
458
+ "Original system prompt.",
459
+ ].join("\n"));
460
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
461
+ method: "PATCH",
462
+ headers: {
463
+ authorization: authHeader,
464
+ "content-type": "application/json",
465
+ },
466
+ body: JSON.stringify({ slug: "other-scribe" }),
467
+ });
468
+ assert.equal(response.status, 400);
469
+ assert.match((await response.json()).error, /slug/i);
470
+ });
471
+ });
472
+ test("server rejects PATCH /api/agents/:slug for bundled agents", async () => {
473
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
474
+ const response = await fetch(`${baseUrl}/api/agents/designer`, {
475
+ method: "PATCH",
476
+ headers: {
477
+ authorization: authHeader,
478
+ "content-type": "application/json",
479
+ },
480
+ body: JSON.stringify({ description: "Should not save" }),
481
+ });
482
+ assert.equal(response.status, 403);
483
+ assert.deepEqual(await response.json(), { error: "Built-in agents are read-only" });
484
+ });
485
+ });
486
+ test("server rejects PATCH /api/agents/:slug for non-team-leads in team mode", async () => {
487
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
488
+ writeAgentFile(testRoot, "scribe", [
489
+ "---",
490
+ "name: Scribe",
491
+ "description: Drafts release notes",
492
+ "model: claude-sonnet-4.6",
493
+ "---",
494
+ "",
495
+ "Original system prompt.",
496
+ ].join("\n"));
497
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
498
+ method: "PATCH",
499
+ headers: {
500
+ authorization: authHeader,
501
+ "content-type": "application/json",
502
+ },
503
+ body: JSON.stringify({ description: "Should not save" }),
504
+ });
505
+ assert.equal(response.status, 403);
506
+ assert.deepEqual(await response.json(), { error: "Forbidden" });
507
+ }, {
508
+ CHAPTERHOUSE_MODE: "team",
509
+ });
510
+ });
511
+ test("server returns 404 when PATCH /api/agents/:slug targets a missing file", async () => {
512
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
513
+ const response = await fetch(`${baseUrl}/api/agents/missing-agent`, {
514
+ method: "PATCH",
515
+ headers: {
516
+ authorization: authHeader,
517
+ "content-type": "application/json",
518
+ },
519
+ body: JSON.stringify({ description: "Still missing" }),
520
+ });
521
+ assert.equal(response.status, 404);
522
+ assert.deepEqual(await response.json(), { error: "Agent not found" });
523
+ });
524
+ });
525
+ test("server returns 400 when PATCH /api/agents/:slug cannot parse malformed agent content", async () => {
526
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
527
+ writeAgentFile(testRoot, "scribe", [
528
+ "---",
529
+ "name: Scribe",
530
+ "description: Drafts release notes",
531
+ "model: claude-sonnet-4.6",
532
+ "skills: [writing",
533
+ "---",
534
+ "",
535
+ "Original system prompt.",
536
+ ].join("\n"));
537
+ const response = await fetch(`${baseUrl}/api/agents/scribe`, {
538
+ method: "PATCH",
539
+ headers: {
540
+ authorization: authHeader,
541
+ "content-type": "application/json",
542
+ },
543
+ body: JSON.stringify({ description: "Broken input stays broken" }),
544
+ });
545
+ assert.equal(response.status, 400);
546
+ assert.match((await response.json()).error, /^Invalid content:/);
547
+ });
548
+ });
549
+ test("server returns 404 when confirming reload for an unknown agent", async () => {
550
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
551
+ const response = await fetch(`${baseUrl}/api/agents/missing/reload-confirm`, {
552
+ method: "POST",
553
+ headers: { authorization: authHeader },
554
+ });
555
+ assert.equal(response.status, 404);
556
+ assert.match(await response.text(), /Agent not found/i);
223
557
  });
224
558
  });
225
559
  test("server runs in standalone mode without auth", async () => {
@@ -916,17 +1250,17 @@ test("server caps concurrent SSE connections per IP", async () => {
916
1250
  let secondResponse;
917
1251
  try {
918
1252
  firstResponse = await fetch(`${baseUrl}/stream`, {
919
- headers: { authorization: authHeader },
1253
+ headers: { authorization: authHeader, connection: "close" },
920
1254
  signal: firstController.signal,
921
1255
  });
922
1256
  secondResponse = await fetch(`${baseUrl}/stream`, {
923
- headers: { authorization: authHeader },
1257
+ headers: { authorization: authHeader, connection: "close" },
924
1258
  signal: secondController.signal,
925
1259
  });
926
1260
  assert.equal(firstResponse.status, 200);
927
1261
  assert.equal(secondResponse.status, 200);
928
1262
  const rejected = await fetch(`${baseUrl}/stream`, {
929
- headers: { authorization: authHeader },
1263
+ headers: { authorization: authHeader, connection: "close" },
930
1264
  });
931
1265
  assert.equal(rejected.status, 429);
932
1266
  assert.equal(rejected.headers.get("retry-after"), "60");
@@ -946,4 +1280,200 @@ test("server caps concurrent SSE connections per IP", async () => {
946
1280
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: "2",
947
1281
  });
948
1282
  });
1283
+ test("POST /api/memory/active-scope sets and clears the active scope", async () => {
1284
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1285
+ const unauthorized = await fetch(`${baseUrl}/api/memory/active-scope`, {
1286
+ method: "POST",
1287
+ headers: { "content-type": "application/json" },
1288
+ body: JSON.stringify({ scope: "chapterhouse" }),
1289
+ });
1290
+ assert.equal(unauthorized.status, 401);
1291
+ const set = await fetch(`${baseUrl}/api/memory/active-scope`, {
1292
+ method: "POST",
1293
+ headers: { authorization: authHeader, "content-type": "application/json" },
1294
+ body: JSON.stringify({ scope: "chapterhouse" }),
1295
+ });
1296
+ assert.equal(set.status, 200);
1297
+ assert.deepEqual(await set.json(), { ok: true, scope: "chapterhouse" });
1298
+ const verify = await fetch(`${baseUrl}/api/memory/active-scope`, {
1299
+ headers: { authorization: authHeader },
1300
+ });
1301
+ assert.deepEqual(await verify.json(), { slug: "chapterhouse", title: "Chapterhouse" });
1302
+ const clear = await fetch(`${baseUrl}/api/memory/active-scope`, {
1303
+ method: "POST",
1304
+ headers: { authorization: authHeader, "content-type": "application/json" },
1305
+ body: JSON.stringify({ scope: null }),
1306
+ });
1307
+ assert.equal(clear.status, 200);
1308
+ assert.deepEqual(await clear.json(), { ok: true, scope: null });
1309
+ const unknownScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
1310
+ method: "POST",
1311
+ headers: { authorization: authHeader, "content-type": "application/json" },
1312
+ body: JSON.stringify({ scope: "nonexistent-scope" }),
1313
+ });
1314
+ assert.equal(unknownScope.status, 404);
1315
+ });
1316
+ });
1317
+ test("GET /api/memory/scopes lists all scopes with entry counts", async () => {
1318
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1319
+ const unauthorized = await fetch(`${baseUrl}/api/memory/scopes`);
1320
+ assert.equal(unauthorized.status, 401);
1321
+ const res = await fetch(`${baseUrl}/api/memory/scopes`, {
1322
+ headers: { authorization: authHeader },
1323
+ });
1324
+ assert.equal(res.status, 200);
1325
+ const body = await res.json();
1326
+ assert.ok(Array.isArray(body.scopes));
1327
+ assert.ok(body.scopes.length >= 2, "expected at least 2 seeded scopes");
1328
+ const chapterhouse = body.scopes.find((s) => s.slug === "chapterhouse");
1329
+ assert.ok(chapterhouse, "expected chapterhouse scope");
1330
+ assert.ok(typeof chapterhouse.counts.observations === "number");
1331
+ assert.ok(typeof chapterhouse.counts.decisions === "number");
1332
+ });
1333
+ });
1334
+ test("GET /api/memory/:scope returns entries for a scope and 404 for unknown", async () => {
1335
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1336
+ const unauthorized = await fetch(`${baseUrl}/api/memory/chapterhouse`);
1337
+ assert.equal(unauthorized.status, 401);
1338
+ const res = await fetch(`${baseUrl}/api/memory/chapterhouse`, {
1339
+ headers: { authorization: authHeader },
1340
+ });
1341
+ assert.equal(res.status, 200);
1342
+ const body = await res.json();
1343
+ assert.ok(Array.isArray(body.entries));
1344
+ assert.ok(typeof body.total === "number");
1345
+ const withStore = await fetch(`${baseUrl}/api/memory/chapterhouse?store=decisions`, {
1346
+ headers: { authorization: authHeader },
1347
+ });
1348
+ assert.equal(withStore.status, 200);
1349
+ const withTier = await fetch(`${baseUrl}/api/memory/chapterhouse?store=observations&tier=hot`, {
1350
+ headers: { authorization: authHeader },
1351
+ });
1352
+ assert.equal(withTier.status, 200);
1353
+ const notFound = await fetch(`${baseUrl}/api/memory/no-such-scope`, {
1354
+ headers: { authorization: authHeader },
1355
+ });
1356
+ assert.equal(notFound.status, 404);
1357
+ assert.deepEqual(await notFound.json(), { error: "Memory scope 'no-such-scope' not found" });
1358
+ });
1359
+ });
1360
+ test("POST /api/memory/:scope/remember writes an observation or decision", async () => {
1361
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1362
+ const unauthorized = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1363
+ method: "POST",
1364
+ headers: { "content-type": "application/json" },
1365
+ body: JSON.stringify({ content: "Test observation" }),
1366
+ });
1367
+ assert.equal(unauthorized.status, 401);
1368
+ const obs = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1369
+ method: "POST",
1370
+ headers: { authorization: authHeader, "content-type": "application/json" },
1371
+ body: JSON.stringify({ content: "Chapterhouse uses SQLite for memory." }),
1372
+ });
1373
+ assert.equal(obs.status, 200);
1374
+ const obsBody = await obs.json();
1375
+ assert.equal(obsBody.ok, true);
1376
+ assert.ok(typeof obsBody.id === "string");
1377
+ const dec = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1378
+ method: "POST",
1379
+ headers: { authorization: authHeader, "content-type": "application/json" },
1380
+ body: JSON.stringify({ kind: "decision", title: "Use SQLite", content: "SQLite chosen for persistence." }),
1381
+ });
1382
+ assert.equal(dec.status, 200);
1383
+ const decBody = await dec.json();
1384
+ assert.equal(decBody.ok, true);
1385
+ const noTitle = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1386
+ method: "POST",
1387
+ headers: { authorization: authHeader, "content-type": "application/json" },
1388
+ body: JSON.stringify({ kind: "decision", content: "Missing title." }),
1389
+ });
1390
+ assert.equal(noTitle.status, 400);
1391
+ const notFound = await fetch(`${baseUrl}/api/memory/missing-scope/remember`, {
1392
+ method: "POST",
1393
+ headers: { authorization: authHeader, "content-type": "application/json" },
1394
+ body: JSON.stringify({ content: "Will not land." }),
1395
+ });
1396
+ assert.equal(notFound.status, 404);
1397
+ });
1398
+ });
1399
+ test("GET /api/memory/inbox lists pending proposals", async () => {
1400
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1401
+ const unauthorized = await fetch(`${baseUrl}/api/memory/inbox`);
1402
+ assert.equal(unauthorized.status, 401);
1403
+ const empty = await fetch(`${baseUrl}/api/memory/inbox`, {
1404
+ headers: { authorization: authHeader },
1405
+ });
1406
+ assert.equal(empty.status, 200);
1407
+ const body = await empty.json();
1408
+ assert.ok(Array.isArray(body.items));
1409
+ assert.ok(typeof body.total === "number");
1410
+ assert.equal(body.items.length, body.total);
1411
+ });
1412
+ });
1413
+ test("POST /api/memory/inbox/:id/route accepts and rejects proposals", async () => {
1414
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1415
+ // First queue a proposal via the remember endpoint so we have something to route
1416
+ const rememberRes = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1417
+ method: "POST",
1418
+ headers: { authorization: authHeader, "content-type": "application/json" },
1419
+ body: JSON.stringify({ content: "Proposal for inbox routing test." }),
1420
+ });
1421
+ assert.equal(rememberRes.status, 200);
1422
+ const notFound = await fetch(`${baseUrl}/api/memory/inbox/99999/route`, {
1423
+ method: "POST",
1424
+ headers: { authorization: authHeader, "content-type": "application/json" },
1425
+ body: JSON.stringify({ action: "accept" }),
1426
+ });
1427
+ assert.equal(notFound.status, 404);
1428
+ const badId = await fetch(`${baseUrl}/api/memory/inbox/not-a-number/route`, {
1429
+ method: "POST",
1430
+ headers: { authorization: authHeader, "content-type": "application/json" },
1431
+ body: JSON.stringify({ action: "accept" }),
1432
+ });
1433
+ assert.equal(badId.status, 400);
1434
+ const unauthorized = await fetch(`${baseUrl}/api/memory/inbox/1/route`, {
1435
+ method: "POST",
1436
+ headers: { "content-type": "application/json" },
1437
+ body: JSON.stringify({ action: "accept" }),
1438
+ });
1439
+ assert.equal(unauthorized.status, 401);
1440
+ });
1441
+ });
1442
+ test("GET /api/wiki/korg/sessions returns grouped active research sessions", async () => {
1443
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1444
+ const db = new Database(getProjectDbPath(testRoot));
1445
+ try {
1446
+ db.prepare(`
1447
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1448
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1449
+ `).run("src-1", "text", "session-one", "First source", "2026-05-14T21:00:00.000Z", "sources/src-1.md", "open questions remain", "[]", "active", "compiler-research", "Compiler research");
1450
+ db.prepare(`
1451
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1453
+ `).run("src-2", "text", "session-one-b", "Second source", "2026-05-14T22:00:00.000Z", "sources/src-2.md", "follow-up questions", "[]", "active", "compiler-research", "Compiler research");
1454
+ db.prepare(`
1455
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1456
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1457
+ `).run("src-3", "text", "archived-session", "Archived source", "2026-05-13T20:00:00.000Z", "sources/src-3.md", "done", "[]", "archived", "old-session", "Old session");
1458
+ }
1459
+ finally {
1460
+ db.close();
1461
+ }
1462
+ const response = await fetch(`${baseUrl}/api/wiki/korg/sessions`, {
1463
+ headers: { authorization: authHeader },
1464
+ });
1465
+ assert.equal(response.status, 200);
1466
+ assert.deepEqual(await response.json(), {
1467
+ sessions: [
1468
+ {
1469
+ id: "compiler-research",
1470
+ name: "Compiler research",
1471
+ source_count: 2,
1472
+ open_questions: 0,
1473
+ last_activity: "2026-05-14T22:00:00.000Z",
1474
+ },
1475
+ ],
1476
+ });
1477
+ });
1478
+ });
949
1479
  //# sourceMappingURL=server.test.js.map
package/dist/config.js CHANGED
@@ -58,6 +58,7 @@ const configSchema = z.object({
58
58
  CHAPTERHOUSE_MEMORY_INJECT: z.string().optional(),
59
59
  CHAPTERHOUSE_MEMORY_AUTO_ACCEPT: z.string().optional(),
60
60
  CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED: z.string().optional(),
61
+ CHAPTERHOUSE_MEMORY_HOOKS_ENABLED: z.string().optional(),
61
62
  CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED: z.string().optional(),
62
63
  CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS: z.string().optional(),
63
64
  CHAPTERHOUSE_MEMORY_DECAY_DAYS: z.string().optional(),
@@ -65,6 +66,8 @@ const configSchema = z.object({
65
66
  CHAPTERHOUSE_MEMORY_TIERING_ENABLED: z.string().optional(),
66
67
  CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST: z.string().optional(),
67
68
  CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS: z.string().optional(),
69
+ CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED: z.string().optional(),
70
+ CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: z.string().optional(),
68
71
  });
69
72
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
70
73
  export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
@@ -140,6 +143,17 @@ function parsePositiveNumberEnv(name, rawValue, defaultValue) {
140
143
  }
141
144
  return parsed;
142
145
  }
146
+ function parseHourEnv(name, rawValue, defaultValue) {
147
+ const normalized = rawValue?.trim();
148
+ if (!normalized) {
149
+ return defaultValue;
150
+ }
151
+ const parsed = Number(normalized);
152
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 23) {
153
+ throw new Error(`${name} must be an integer between 0 and 23, got: "${rawValue}"`);
154
+ }
155
+ return parsed;
156
+ }
143
157
  function parseCorsAllowedOrigins(rawValue) {
144
158
  const normalized = rawValue?.trim();
145
159
  if (!normalized) {
@@ -298,6 +312,8 @@ export function parseRuntimeConfig(env, options = {}) {
298
312
  const memoryInboxRetentionDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS", raw.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS, 7);
299
313
  const memoryHotRecallBoost = parsePositiveNumberEnv("CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST", raw.CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST, 1.5);
300
314
  const memoryHotAgeDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS", raw.CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS, 30);
315
+ const pkbConsolidationEnabled = parseBooleanEnv("CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED", raw.CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED, true);
316
+ const pkbConsolidationHour = parseHourEnv("CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR", raw.CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR, 3);
301
317
  if (effectiveEntraAuthEnabled && (!effectiveEntraTenantId || !effectiveEntraClientId)) {
302
318
  throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
303
319
  }
@@ -348,6 +364,7 @@ export function parseRuntimeConfig(env, options = {}) {
348
364
  memoryInjectEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_INJECT", raw.CHAPTERHOUSE_MEMORY_INJECT, true),
349
365
  memoryAutoAcceptEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_AUTO_ACCEPT", raw.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT, true),
350
366
  memoryEndOfTaskHookEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED", raw.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED, true),
367
+ memoryHooksEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_HOOKS_ENABLED", raw.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED, true),
351
368
  memoryHousekeepingEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED", raw.CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED, true),
352
369
  memoryHousekeepingTurns,
353
370
  memoryDecayDays,
@@ -355,6 +372,8 @@ export function parseRuntimeConfig(env, options = {}) {
355
372
  memoryTieringEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_TIERING_ENABLED", raw.CHAPTERHOUSE_MEMORY_TIERING_ENABLED, true),
356
373
  memoryHotRecallBoost,
357
374
  memoryHotAgeDays,
375
+ pkbConsolidationEnabled,
376
+ pkbConsolidationHour,
358
377
  modeCompatibilityWarnings,
359
378
  };
360
379
  }
@@ -406,6 +425,7 @@ export const config = {
406
425
  memoryInjectEnabled: runtimeConfig.memoryInjectEnabled,
407
426
  memoryAutoAcceptEnabled: runtimeConfig.memoryAutoAcceptEnabled,
408
427
  memoryEndOfTaskHookEnabled: runtimeConfig.memoryEndOfTaskHookEnabled,
428
+ memoryHooksEnabled: runtimeConfig.memoryHooksEnabled,
409
429
  memoryHousekeepingEnabled: runtimeConfig.memoryHousekeepingEnabled,
410
430
  memoryHousekeepingTurns: runtimeConfig.memoryHousekeepingTurns,
411
431
  memoryDecayDays: runtimeConfig.memoryDecayDays,
@@ -413,6 +433,14 @@ export const config = {
413
433
  memoryTieringEnabled: runtimeConfig.memoryTieringEnabled,
414
434
  memoryHotRecallBoost: runtimeConfig.memoryHotRecallBoost,
415
435
  memoryHotAgeDays: runtimeConfig.memoryHotAgeDays,
436
+ pkbConsolidationEnabled: runtimeConfig.pkbConsolidationEnabled,
437
+ pkbConsolidationHour: runtimeConfig.pkbConsolidationHour,
438
+ get PKB_CONSOLIDATION_ENABLED() {
439
+ return runtimeConfig.pkbConsolidationEnabled;
440
+ },
441
+ get PKB_CONSOLIDATION_HOUR() {
442
+ return runtimeConfig.pkbConsolidationHour;
443
+ },
416
444
  modeCompatibilityWarnings: runtimeConfig.modeCompatibilityWarnings,
417
445
  copilotAuthToken: runtimeConfig.copilotAuthToken,
418
446
  get copilotModel() {