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.
- package/README.md +1 -1
- package/agents/korg.agent.md +20 -0
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +194 -89
- package/dist/memory/eot.test.js +186 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +17 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- 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
|
-
|
|
74
|
-
assert.match(
|
|
75
|
-
assert.match(
|
|
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
|
-
|
|
168
|
-
indexManager.
|
|
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
|
-
/**
|
|
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
|
|
197
|
+
ls = new Map();
|
|
173
198
|
sessionListeners.set(sessionKey, ls);
|
|
174
199
|
}
|
|
175
|
-
ls.
|
|
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: (
|
|
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: (
|
|
76
|
+
writeFile: (_path, data, _enc) => {
|
|
77
77
|
stored = data;
|
|
78
78
|
written.push(data);
|
|
79
79
|
},
|
package/dist/daemon-install.js
CHANGED
|
@@ -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 ??
|
|
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 ??
|
|
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 (
|
|
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 (
|
|
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 (
|
|
193
|
+
if (config.restarted) {
|
|
202
194
|
delete process.env.CHAPTERHOUSE_RESTARTED;
|
|
203
195
|
}
|
|
204
196
|
}
|