chapterhouse 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -33,6 +33,37 @@ test("createTools registers OKR progress tools", async () => {
33
33
  assert.ok(tools.some((tool) => tool.name === "get_my_okrs"));
34
34
  assert.ok(tools.some((tool) => tool.name === "write_team_wiki"));
35
35
  });
36
+ test("get_my_okrs does not fall back to the last authenticated user outside a turn", async () => {
37
+ const toolsModule = await loadToolsModule();
38
+ assert.ok(toolsModule, "tools module should exist");
39
+ const dbModule = await import("../store/db.js");
40
+ dbModule.setState("last_authenticated_user", JSON.stringify({
41
+ id: "u-ada",
42
+ name: "Ada Lovelace",
43
+ email: "ada@example.com",
44
+ role: "engineer",
45
+ }));
46
+ let fetchAttempts = 0;
47
+ const tools = toolsModule.createTools({
48
+ client: { async listModels() { return []; } },
49
+ onAgentTaskComplete: () => { },
50
+ createTeamPushClient: () => ({
51
+ async pushUpdate() {
52
+ throw new Error("not used in this test");
53
+ },
54
+ async fetchOKRs() {
55
+ fetchAttempts += 1;
56
+ return "";
57
+ },
58
+ async writePage(path) {
59
+ return { ok: true, path };
60
+ },
61
+ }),
62
+ });
63
+ const result = await tools.find((tool) => tool.name === "get_my_okrs")?.handler({});
64
+ assert.match(result, /current user identity/i);
65
+ assert.equal(fetchAttempts, 0, "tool must not use persisted last-user identity");
66
+ });
36
67
  test("log_okr_progress suggests a KR when the user did not specify one", async () => {
37
68
  const toolsModule = await loadToolsModule();
38
69
  assert.ok(toolsModule, "tools module should exist");
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
+ import { withWikiWrite } from "../wiki/lock.js";
5
6
  async function loadToolsModule() {
6
7
  return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
7
8
  }
@@ -11,6 +12,16 @@ async function readWikiArtifacts() {
11
12
  const indexManager = await import(new URL(`../wiki/index-manager.js?case=${nonce}`, import.meta.url).href);
12
13
  return { wikiFs, indexManager };
13
14
  }
15
+ async function loadWikiTool(name) {
16
+ const toolsModule = await loadToolsModule();
17
+ const tools = toolsModule.createTools({
18
+ client: { async listModels() { return []; } },
19
+ onAgentTaskComplete: () => { },
20
+ });
21
+ const tool = tools.find((entry) => entry.name === name);
22
+ assert.ok(tool, `Expected tool '${name}' to be registered`);
23
+ return tool;
24
+ }
14
25
  test.before(() => {
15
26
  mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
16
27
  });
@@ -70,9 +81,143 @@ Runtime notes.
70
81
  `,
71
82
  });
72
83
  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/);
84
+ const error = result.error;
85
+ assert.match(error, /invalid 'summary'/i);
86
+ assert.match(error, /unknown tag 'made-up-tag'|tag "made-up-tag" is not in the allowed tag list/i);
87
+ assert.match(error, /Valid tags:/i);
88
+ assert.match(error, /engineering/i);
89
+ assert.match(error, /release/i);
90
+ });
91
+ test("wiki_update accepts an empty tags list", async () => {
92
+ const tool = await loadWikiTool("wiki_update");
93
+ const result = await tool.handler({
94
+ path: "pages/shared/chapterhouse.md",
95
+ title: "Chapterhouse",
96
+ summary: "Runtime notes",
97
+ content: `---
98
+ title: Chapterhouse
99
+ summary: Runtime notes
100
+ tags: []
101
+ ---
102
+
103
+ # Chapterhouse
104
+
105
+ Runtime notes.
106
+ `,
107
+ });
108
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
109
+ });
110
+ test("wiki_update accepts mixed-case valid tags with surrounding whitespace", async () => {
111
+ const tool = await loadWikiTool("wiki_update");
112
+ const { wikiFs } = await readWikiArtifacts();
113
+ wikiFs.writePage("pages/_meta/taxonomy.md", "## Custom\n- workflow\n");
114
+ const result = await tool.handler({
115
+ path: "pages/shared/chapterhouse.md",
116
+ title: "Chapterhouse",
117
+ summary: "Runtime notes",
118
+ content: `---
119
+ title: Chapterhouse
120
+ summary: Runtime notes
121
+ tags: [ Engineering , workflow ]
122
+ ---
123
+
124
+ # Chapterhouse
125
+
126
+ Runtime notes.
127
+ `,
128
+ });
129
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
130
+ });
131
+ test("wiki_update accepts summaries that are exactly 160 characters long", async () => {
132
+ const tool = await loadWikiTool("wiki_update");
133
+ const summary = "x".repeat(160);
134
+ const result = await tool.handler({
135
+ path: "pages/shared/chapterhouse.md",
136
+ title: "Chapterhouse",
137
+ summary,
138
+ content: `---
139
+ title: Chapterhouse
140
+ summary: ${summary}
141
+ tags: [engineering]
142
+ ---
143
+
144
+ # Chapterhouse
145
+
146
+ ${summary}
147
+ `,
148
+ });
149
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
150
+ });
151
+ test("wiki_update rejects summaries longer than 160 characters", async () => {
152
+ const tool = await loadWikiTool("wiki_update");
153
+ const result = await tool.handler({
154
+ path: "pages/shared/chapterhouse.md",
155
+ title: "Chapterhouse",
156
+ summary: "x".repeat(161),
157
+ content: `---
158
+ title: Chapterhouse
159
+ summary: Runtime notes
160
+ tags: [engineering]
161
+ ---
162
+
163
+ # Chapterhouse
164
+
165
+ Runtime notes.
166
+ `,
167
+ });
168
+ assert.deepEqual(result, {
169
+ error: "Summary must be 160 characters or fewer",
170
+ });
171
+ const { wikiFs } = await readWikiArtifacts();
172
+ assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
173
+ });
174
+ test("wiki_update keeps empty-summary behavior unchanged", async () => {
175
+ const tool = await loadWikiTool("wiki_update");
176
+ const result = await tool.handler({
177
+ path: "pages/shared/chapterhouse.md",
178
+ title: "Chapterhouse",
179
+ summary: "",
180
+ content: `---
181
+ title: Chapterhouse
182
+ summary:
183
+ tags: [engineering]
184
+ ---
185
+
186
+ # Chapterhouse
187
+
188
+ Runtime notes.
189
+ `,
190
+ });
191
+ assert.equal(typeof result, "object");
192
+ assert.match(result.error, /missing 'summary'/i);
193
+ });
194
+ test("wiki_ingest_source rejects local file-style inputs with a clear error", async () => {
195
+ const tool = await loadWikiTool("wiki_ingest_source");
196
+ for (const source of [
197
+ "./notes.md",
198
+ "../notes.md",
199
+ "/var/data/notes.md",
200
+ "notes.md",
201
+ "C:\\Users\\brian\\notes.md",
202
+ "file:///home/brian/notes.md",
203
+ ]) {
204
+ const result = await tool.handler({ source });
205
+ assert.equal(typeof result, "object", `Expected '${source}' to be rejected`);
206
+ const error = result.error;
207
+ assert.match(error, /local|file/i, `Expected '${source}' error to mention local-file rejection`);
208
+ assert.match(error, /url|http|https/i, `Expected '${source}' error to mention the remote URL requirement`);
209
+ }
210
+ const { wikiFs } = await readWikiArtifacts();
211
+ assert.deepEqual(wikiFs.listSources(), []);
212
+ });
213
+ test("wiki_ingest_source still passes remote URLs through to ingestSource", async () => {
214
+ const tool = await loadWikiTool("wiki_ingest_source");
215
+ const result = await tool.handler({
216
+ source: "http://127.0.0.1/private",
217
+ });
218
+ assert.deepEqual(result, {
219
+ error: "Cannot fetch internal/private URLs.",
220
+ });
76
221
  });
77
222
  test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
78
223
  const toolsModule = await loadToolsModule();
@@ -116,6 +261,211 @@ Runtime notes.
116
261
  },
117
262
  ]);
118
263
  });
264
+ test("wiki_batch_update creates multiple pages and refreshes the index", async () => {
265
+ const toolsModule = await loadToolsModule();
266
+ const tools = toolsModule.createTools({
267
+ client: { async listModels() { return []; } },
268
+ onAgentTaskComplete: () => { },
269
+ });
270
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
271
+ assert.ok(tool);
272
+ const result = await tool.handler({
273
+ pages: [
274
+ {
275
+ path: "pages/projects/atlas/index.md",
276
+ title: "Atlas",
277
+ summary: "Project atlas overview",
278
+ content: `---
279
+ title: Atlas
280
+ summary: Project atlas overview
281
+ tags: [project]
282
+ ---
283
+
284
+ # Atlas
285
+
286
+ Project atlas overview.
287
+ `,
288
+ },
289
+ {
290
+ path: "pages/people/alice/index.md",
291
+ title: "Alice",
292
+ summary: "Team profile for Alice",
293
+ section: "People",
294
+ content: `---
295
+ title: Alice
296
+ summary: Team profile for Alice
297
+ tags: [people]
298
+ ---
299
+
300
+ # Alice
301
+
302
+ Team profile for Alice.
303
+ `,
304
+ },
305
+ ],
306
+ });
307
+ assert.equal(result, "Created 2 pages successfully.");
308
+ const { wikiFs, indexManager } = await readWikiArtifacts();
309
+ const index = indexManager.parseIndex();
310
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
311
+ assert.match(wikiFs.readPage("pages/people/alice/index.md") ?? "", /summary: Team profile for Alice/);
312
+ assert.ok(index.some((entry) => entry.path === "pages/projects/atlas/index.md" && entry.summary === "Project atlas overview"));
313
+ assert.ok(index.some((entry) => entry.path === "pages/people/alice/index.md" && entry.summary === "Team profile for Alice"));
314
+ });
315
+ test("wiki_batch_update reports per-page path errors and continues other writes", async () => {
316
+ const toolsModule = await loadToolsModule();
317
+ const tools = toolsModule.createTools({
318
+ client: { async listModels() { return []; } },
319
+ onAgentTaskComplete: () => { },
320
+ });
321
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
322
+ assert.ok(tool);
323
+ const result = await tool.handler({
324
+ pages: [
325
+ {
326
+ path: "pages/projects/atlas/index.md",
327
+ title: "Atlas",
328
+ summary: "Project atlas overview",
329
+ content: `---
330
+ title: Atlas
331
+ summary: Project atlas overview
332
+ tags: [project]
333
+ ---
334
+
335
+ # Atlas
336
+
337
+ Project atlas overview.
338
+ `,
339
+ },
340
+ {
341
+ path: "../secrets.md",
342
+ title: "Secrets",
343
+ summary: "Should be rejected",
344
+ content: `---
345
+ title: Secrets
346
+ summary: Should be rejected
347
+ tags: [project]
348
+ ---
349
+
350
+ # Secrets
351
+ `,
352
+ },
353
+ ],
354
+ });
355
+ assert.equal(result, "Created 1 pages successfully.\nErrors (1):\n • ../secrets.md — Refused unsafe wiki path: ../secrets.md");
356
+ const { wikiFs } = await readWikiArtifacts();
357
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
358
+ assert.equal(wikiFs.readPage("../secrets.md"), undefined);
359
+ });
360
+ test("wiki_batch_update reports per-page tag errors and continues other writes", async () => {
361
+ const toolsModule = await loadToolsModule();
362
+ const tools = toolsModule.createTools({
363
+ client: { async listModels() { return []; } },
364
+ onAgentTaskComplete: () => { },
365
+ });
366
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
367
+ assert.ok(tool);
368
+ const result = await tool.handler({
369
+ pages: [
370
+ {
371
+ path: "pages/projects/atlas/index.md",
372
+ title: "Atlas",
373
+ summary: "Project atlas overview",
374
+ content: `---
375
+ title: Atlas
376
+ summary: Project atlas overview
377
+ tags: [project]
378
+ ---
379
+
380
+ # Atlas
381
+
382
+ Project atlas overview.
383
+ `,
384
+ },
385
+ {
386
+ path: "pages/people/alice/index.md",
387
+ title: "Alice",
388
+ summary: "Team profile for Alice",
389
+ content: `---
390
+ title: Alice
391
+ summary: Team profile for Alice
392
+ tags: [personal]
393
+ ---
394
+
395
+ # Alice
396
+
397
+ Team profile for Alice.
398
+ `,
399
+ },
400
+ ],
401
+ });
402
+ assert.equal(typeof result, "string");
403
+ assert.match(result, /^Created 1 pages successfully\.\nErrors \(1\):/);
404
+ assert.match(result, /pages\/people\/alice\/index\.md — Wiki page frontmatter violates the required shape: unknown tag 'personal'/);
405
+ const { wikiFs } = await readWikiArtifacts();
406
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
407
+ assert.equal(wikiFs.readPage("pages/people/alice/index.md"), undefined);
408
+ });
409
+ test("wiki_batch_update fails fast on an empty pages array", async () => {
410
+ const toolsModule = await loadToolsModule();
411
+ const tools = toolsModule.createTools({
412
+ client: { async listModels() { return []; } },
413
+ onAgentTaskComplete: () => { },
414
+ });
415
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
416
+ assert.ok(tool);
417
+ const result = await tool.handler({ pages: [] });
418
+ assert.deepEqual(result, { error: "Too small: expected array to have >=1 items" });
419
+ const { indexManager } = await readWikiArtifacts();
420
+ assert.deepEqual(indexManager.parseIndex(), []);
421
+ });
422
+ test("wiki_batch_update fails fast when more than 50 pages are requested", async () => {
423
+ const toolsModule = await loadToolsModule();
424
+ const tools = toolsModule.createTools({
425
+ client: { async listModels() { return []; } },
426
+ onAgentTaskComplete: () => { },
427
+ });
428
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
429
+ assert.ok(tool);
430
+ const result = await tool.handler({
431
+ pages: Array.from({ length: 51 }, (_, index) => ({
432
+ path: `pages/projects/page-${index + 1}/index.md`,
433
+ title: `Page ${index + 1}`,
434
+ summary: `Summary ${index + 1}`,
435
+ content: `---\ntitle: Page ${index + 1}\nsummary: Summary ${index + 1}\ntags: [project]\n---\n\n# Page ${index + 1}\n`,
436
+ })),
437
+ });
438
+ assert.deepEqual(result, { error: "Too big: expected array to have <=50 items" });
439
+ const { indexManager } = await readWikiArtifacts();
440
+ assert.deepEqual(indexManager.parseIndex(), []);
441
+ });
442
+ test("wiki_batch_update inherits summary length validation from the per-page schema", async () => {
443
+ const toolsModule = await loadToolsModule();
444
+ const tools = toolsModule.createTools({
445
+ client: { async listModels() { return []; } },
446
+ onAgentTaskComplete: () => { },
447
+ });
448
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
449
+ assert.ok(tool);
450
+ const result = await tool.handler({
451
+ pages: [{
452
+ path: "pages/projects/atlas/index.md",
453
+ title: "Atlas",
454
+ summary: "x".repeat(161),
455
+ content: `---
456
+ title: Atlas
457
+ summary: Project atlas overview
458
+ tags: [project]
459
+ ---
460
+
461
+ # Atlas
462
+ `,
463
+ }],
464
+ });
465
+ assert.deepEqual(result, { error: "Summary must be 160 characters or fewer" });
466
+ const { indexManager } = await readWikiArtifacts();
467
+ assert.deepEqual(indexManager.parseIndex(), []);
468
+ });
119
469
  test("retained wiki tools append audit entries to pages/_meta/log.md", async () => {
120
470
  const toolsModule = await loadToolsModule();
121
471
  const tools = toolsModule.createTools({
@@ -164,9 +514,11 @@ updated: 2026-05-12
164
514
 
165
515
  # Rust
166
516
  `);
167
- for (const entry of indexManager.parseIndex()) {
168
- indexManager.removeFromIndex(entry.path);
169
- }
517
+ await withWikiWrite(() => {
518
+ for (const entry of indexManager.parseIndex()) {
519
+ indexManager.removeFromIndex(entry.path);
520
+ }
521
+ });
170
522
  assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
171
523
  const result = await wikiReindex.handler({});
172
524
  assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
@@ -44,12 +44,37 @@ const turnBuffers = new Map();
44
44
  const turnListeners = new Map();
45
45
  /** Per-session ring buffer for SSE reconnect replay (sliding window across turns). */
46
46
  const sessionBuffers = new Map();
47
- /** Per-session live listeners (SSE connections). */
47
+ /** Warn when one session accumulates an unusual number of SSE listeners. */
48
+ export const SESSION_LISTENER_WARN_THRESHOLD = 50;
49
+ /** Hard cap to evict oldest leaked SSE listeners for one session. */
50
+ export const SESSION_LISTENER_MAX = 100;
51
+ /** Per-session live listeners (SSE connections), insertion-ordered for oldest-first eviction. */
48
52
  const sessionListeners = new Map();
49
53
  /** Pending clear-buffer timers keyed by turnId. */
50
54
  const clearTimers = new Map();
51
55
  /** Monotonic global sequence counter — used as SSE `id:` for Last-Event-ID replay. */
52
56
  let globalSeq = 0;
57
+ function warnHighSessionListenerCount(sessionKey, listenerCount) {
58
+ if (listenerCount !== SESSION_LISTENER_WARN_THRESHOLD + 1)
59
+ return;
60
+ log.warn({ sessionKey, listenerCount, threshold: SESSION_LISTENER_WARN_THRESHOLD }, "turn-event-log: high session listener count may indicate leaked SSE subscribers");
61
+ }
62
+ function evictOverflowSessionListeners(sessionKey, listeners) {
63
+ const overflow = listeners.size - SESSION_LISTENER_MAX;
64
+ if (overflow <= 0)
65
+ return;
66
+ let evicted = 0;
67
+ for (const [listener] of listeners) {
68
+ listeners.delete(listener);
69
+ evicted += 1;
70
+ if (evicted >= overflow)
71
+ break;
72
+ }
73
+ if (listeners.size === 0) {
74
+ sessionListeners.delete(sessionKey);
75
+ }
76
+ log.warn({ sessionKey, evicted, listenerCount: listeners.size, maxListeners: SESSION_LISTENER_MAX }, "turn-event-log: evicted oldest session listeners after exceeding the max listener cap");
77
+ }
53
78
  // ---------------------------------------------------------------------------
54
79
  // Emit
55
80
  // ---------------------------------------------------------------------------
@@ -90,7 +115,7 @@ export function emitTurnEvent(sessionKey, event) {
90
115
  // Notify per-session listeners (SSE connections) -------------------------
91
116
  const sListeners = sessionListeners.get(sessionKey);
92
117
  if (sListeners) {
93
- for (const fn of sListeners) {
118
+ for (const fn of sListeners.keys()) {
94
119
  try {
95
120
  fn(indexed);
96
121
  }
@@ -169,10 +194,12 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
169
194
  // Register live listener
170
195
  let ls = sessionListeners.get(sessionKey);
171
196
  if (!ls) {
172
- ls = new Set();
197
+ ls = new Map();
173
198
  sessionListeners.set(sessionKey, ls);
174
199
  }
175
- ls.add(listener);
200
+ ls.set(listener, true);
201
+ warnHighSessionListenerCount(sessionKey, ls.size);
202
+ evictOverflowSessionListeners(sessionKey, ls);
176
203
  return () => {
177
204
  const set = sessionListeners.get(sessionKey);
178
205
  if (set) {
@@ -22,17 +22,24 @@
22
22
  * SQLite-dependent functions (persistTurnEvents, getSessionEventsFromDb) are
23
23
  * tested in the integration suite to avoid needing a real DB here.
24
24
  */
25
- import { describe, it, afterEach } from "node:test";
25
+ import { describe, it, afterEach, after } from "node:test";
26
26
  import assert from "node:assert/strict";
27
+ import { mkdirSync, rmSync } from "node:fs";
28
+ import { join } from "node:path";
27
29
  import { setTimeout as setTimeoutPromise } from "node:timers/promises";
28
- import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
30
+ import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, SESSION_LISTENER_MAX, } from "./turn-event-log.js";
29
31
  import { getDb } from "../store/db.js";
32
+ import { resetSingletons } from "../test/helpers/reset-singletons.js";
30
33
  // ---------------------------------------------------------------------------
31
34
  // Helpers
32
35
  // ---------------------------------------------------------------------------
33
36
  let turnCounter = 0;
34
37
  let sessionCounter = 0;
35
38
  const usedSessionKeys = [];
39
+ const sandboxRoot = join(process.cwd(), ".test-work", `turn-event-log-${process.pid}`);
40
+ mkdirSync(sandboxRoot, { recursive: true });
41
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
42
+ resetSingletons();
36
43
  function freshTurnId() {
37
44
  return `turn-test-${++turnCounter}-${Date.now()}`;
38
45
  }
@@ -76,6 +83,10 @@ afterEach(() => {
76
83
  }
77
84
  }
78
85
  });
86
+ after(() => {
87
+ resetSingletons();
88
+ rmSync(sandboxRoot, { recursive: true, force: true });
89
+ });
79
90
  function trackTurn(turnId) {
80
91
  usedTurnIds.push(turnId);
81
92
  return turnId;
@@ -260,6 +271,17 @@ describe("turn-event-log", () => {
260
271
  emitTurnEvent(session, makeComplete(turnId, session));
261
272
  assert.equal(received.length, 1);
262
273
  });
274
+ it("evicts the oldest session listeners when one session exceeds the max listener cap", () => {
275
+ const session = freshSessionKey();
276
+ const turnId = trackTurn(freshTurnId());
277
+ const deliveries = Array.from({ length: SESSION_LISTENER_MAX + 1 }, () => []);
278
+ for (const received of deliveries) {
279
+ trackUnsub(subscribeSession(session, (event) => received.push(event)));
280
+ }
281
+ emitTurnEvent(session, makeStarted(turnId, session));
282
+ assert.equal(deliveries[0]?.length, 0, "oldest listener should be evicted once the cap is exceeded");
283
+ assert.equal(deliveries.at(-1)?.length, 1, "most recent listener should still receive events");
284
+ });
263
285
  it("two sessions are isolated from each other", () => {
264
286
  const session1 = freshSessionKey();
265
287
  const session2 = freshSessionKey();
@@ -68,12 +68,12 @@ function makeFs(initialJson) {
68
68
  let stored = initialJson ?? null;
69
69
  const written = [];
70
70
  return {
71
- readFile: (path, _enc) => {
71
+ readFile: (_path, _enc) => {
72
72
  if (stored === null)
73
73
  throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
74
74
  return stored;
75
75
  },
76
- writeFile: (path, data, _enc) => {
76
+ writeFile: (_path, data, _enc) => {
77
77
  stored = data;
78
78
  written.push(data);
79
79
  },
@@ -7,6 +7,7 @@ import { execSync, execFileSync } from "child_process";
7
7
  import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
8
8
  import { join, dirname } from "path";
9
9
  import { homedir, platform } from "os";
10
+ import { config } from "./config.js";
10
11
  /** The launchd label / systemd unit name. */
11
12
  export const DAEMON_LABEL = "com.bketelsen.chapterhouse";
12
13
  export const DAEMON_UNIT_NAME = "chapterhouse";
@@ -80,7 +81,7 @@ function composeMacOSPath(binDir, shellPath) {
80
81
  /** Generate the launchd plist XML string. */
81
82
  export function generatePlist(options) {
82
83
  const label = options.label ?? DAEMON_LABEL;
83
- const shellPath = options.shellPath ?? process.env.PATH ?? "";
84
+ const shellPath = options.shellPath ?? config.shellPath;
84
85
  const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
85
86
  return `<?xml version="1.0" encoding="UTF-8"?>
86
87
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -135,7 +136,7 @@ function composeSystemdPath(binDir, shellPath) {
135
136
  /** Generate the systemd user unit file string. */
136
137
  export function generateSystemdUnit(options) {
137
138
  const description = options.description ?? "Chapterhouse AI assistant daemon";
138
- const shellPath = options.shellPath ?? process.env.PATH ?? "";
139
+ const shellPath = options.shellPath ?? config.shellPath;
139
140
  const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
140
141
  return `[Unit]
141
142
  Description=${description}
package/dist/daemon.js CHANGED
@@ -12,6 +12,7 @@ import { checkForUpdate } from "./update.js";
12
12
  import { ensureWikiStructure } from "./wiki/fs.js";
13
13
  import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
14
14
  import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
15
+ import { withWikiWrite } from "./wiki/lock.js";
15
16
  import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
16
17
  import { SESSIONS_DIR } from "./paths.js";
17
18
  import { getDisplayHost } from "./api/server-runtime.js";
@@ -37,15 +38,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
37
38
  * Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
38
39
  * Allows in-flight LLM streams to complete before the process is torn down.
39
40
  */
40
- const SHUTDOWN_TIMEOUT_MS = (() => {
41
- const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
42
- if (env) {
43
- const parsed = parseInt(env, 10);
44
- if (!isNaN(parsed) && parsed > 0)
45
- return parsed;
46
- }
47
- return 60_000;
48
- })();
41
+ const SHUTDOWN_TIMEOUT_MS = config.shutdownTimeoutMs;
49
42
  /** Remove orphaned session folders older than 7 days, preserving the current session. */
50
43
  function pruneOldSessions() {
51
44
  try {
@@ -95,10 +88,9 @@ async function main() {
95
88
  for (const warning of config.modeCompatibilityWarnings) {
96
89
  log.warn({ mode: config.chapterhouseMode }, warning);
97
90
  }
91
+ log.info({ capabilities: modeContext.getCapabilities() }, modeContext.getStartupCapabilitySummary());
98
92
  // Set up message logging to daemon console
99
93
  setMessageLogger((direction, source, text) => {
100
- const arrow = direction === "in" ? "⟶" : "⟵";
101
- const tag = source.padEnd(8);
102
94
  log.debug({ direction, source, text: truncate(text) }, "chat");
103
95
  });
104
96
  // Initialize SQLite
@@ -110,19 +102,19 @@ async function main() {
110
102
  log.info("Created wiki");
111
103
  }
112
104
  if (modeContext.isTeam()) {
113
- const seed = seedTeamWiki();
105
+ const seed = await withWikiWrite(() => seedTeamWiki());
114
106
  if (seed.created.length > 0) {
115
107
  log.info({ pages: seed.created }, "Seeded team wiki pages");
116
108
  }
117
109
  }
118
110
  if (shouldMigrate()) {
119
111
  log.info("Migrating SQLite memories to wiki");
120
- const count = migrateMemoriesToWiki();
112
+ const count = await withWikiWrite(() => migrateMemoriesToWiki());
121
113
  log.info({ count }, "Migrated memories to wiki");
122
114
  }
123
115
  if (shouldReorganize()) {
124
116
  log.info("Reorganizing wiki pages into entity structure");
125
- const count = reorganizeWiki();
117
+ const count = await withWikiWrite(() => reorganizeWiki());
126
118
  log.info({ count }, "Created entity pages during reorganization");
127
119
  }
128
120
  if (shouldEnforceTopics()) {
@@ -144,7 +136,7 @@ async function main() {
144
136
  // Prune orphaned session folders older than 7 days
145
137
  pruneOldSessions();
146
138
  // One-time deprecation note for legacy Telegram users (v1 → v2)
147
- if (process.env.TELEGRAM_BOT_TOKEN) {
139
+ if (config.telegramBotTokenConfigured) {
148
140
  log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
149
141
  }
150
142
  // Auto-install workiq MCP server when Entra is configured
@@ -180,7 +172,7 @@ async function main() {
180
172
  }
181
173
  const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
182
174
  log.info({ url }, "Chapterhouse is fully operational");
183
- if (process.env.CHAPTERHOUSE_OPEN_BROWSER === "1") {
175
+ if (config.openBrowserOnStart) {
184
176
  const opener = process.platform === "darwin" ? "open" :
185
177
  process.platform === "win32" ? "explorer.exe" : "xdg-open";
186
178
  try {
@@ -198,7 +190,7 @@ async function main() {
198
190
  }
199
191
  })
200
192
  .catch(() => { }); // silent — network may be unavailable
201
- if (process.env.CHAPTERHOUSE_RESTARTED === "1") {
193
+ if (config.restarted) {
202
194
  delete process.env.CHAPTERHOUSE_RESTARTED;
203
195
  }
204
196
  }