chapterhouse 0.3.25 → 0.4.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 (57) hide show
  1. package/dist/api/server-runtime.js +1 -1
  2. package/dist/api/server.js +13 -1
  3. package/dist/api/server.test.js +68 -54
  4. package/dist/api/sse.integration.test.js +4 -46
  5. package/dist/api/turn-sse.integration.test.js +20 -47
  6. package/dist/config.js +81 -1
  7. package/dist/config.test.js +123 -0
  8. package/dist/copilot/agents.js +27 -4
  9. package/dist/copilot/agents.test.js +7 -0
  10. package/dist/copilot/oneshot.js +54 -0
  11. package/dist/copilot/orchestrator.js +228 -4
  12. package/dist/copilot/orchestrator.test.js +373 -1
  13. package/dist/copilot/system-message.js +4 -0
  14. package/dist/copilot/system-message.test.js +24 -0
  15. package/dist/copilot/tools.agent.test.js +23 -0
  16. package/dist/copilot/tools.js +350 -4
  17. package/dist/copilot/tools.memory.test.js +248 -0
  18. package/dist/copilot/turn-event-log-env.test.js +19 -0
  19. package/dist/copilot/turn-event-log.js +22 -23
  20. package/dist/copilot/turn-event-log.test.js +61 -2
  21. package/dist/memory/active-scope.js +69 -0
  22. package/dist/memory/active-scope.test.js +76 -0
  23. package/dist/memory/checkpoint-prompt.js +71 -0
  24. package/dist/memory/checkpoint.js +257 -0
  25. package/dist/memory/checkpoint.test.js +255 -0
  26. package/dist/memory/decisions.js +53 -0
  27. package/dist/memory/decisions.test.js +92 -0
  28. package/dist/memory/entities.js +59 -0
  29. package/dist/memory/entities.test.js +65 -0
  30. package/dist/memory/eot.js +219 -0
  31. package/dist/memory/eot.test.js +263 -0
  32. package/dist/memory/hot-tier.js +187 -0
  33. package/dist/memory/hot-tier.test.js +197 -0
  34. package/dist/memory/housekeeping.js +352 -0
  35. package/dist/memory/housekeeping.test.js +280 -0
  36. package/dist/memory/inbox.js +73 -0
  37. package/dist/memory/index.js +11 -0
  38. package/dist/memory/observations.js +46 -0
  39. package/dist/memory/observations.test.js +86 -0
  40. package/dist/memory/recall.js +197 -0
  41. package/dist/memory/recall.test.js +196 -0
  42. package/dist/memory/scopes.js +89 -0
  43. package/dist/memory/scopes.test.js +201 -0
  44. package/dist/memory/tiering.js +193 -0
  45. package/dist/memory/types.js +2 -0
  46. package/dist/paths.js +7 -1
  47. package/dist/store/db.js +423 -17
  48. package/dist/store/db.test.js +94 -7
  49. package/dist/test/api-server.js +50 -0
  50. package/dist/test/api-server.test.js +57 -0
  51. package/dist/test/setup-env.js +25 -0
  52. package/dist/test/setup-env.test.js +38 -0
  53. package/package.json +1 -1
  54. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  55. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  56. package/web/dist/index.html +1 -1
  57. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -20,7 +20,7 @@ export function createHealthPayload(now = new Date()) {
20
20
  };
21
21
  }
22
22
  export function createPublicConfigPayload(config) {
23
- const chatSseEnabled = config.chatSseEnabled ?? false;
23
+ const chatSseEnabled = config.chatSseEnabled ?? true;
24
24
  if (!config.entraAuthEnabled) {
25
25
  return {
26
26
  appName: "Chapterhouse",
@@ -31,6 +31,7 @@ import { formatSseData, formatSseEvent } from "./sse.js";
31
31
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
32
32
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
33
33
  import { childLogger } from "../util/logger.js";
34
+ import { getActiveScope } from "../memory/active-scope.js";
34
35
  const log = childLogger("server");
35
36
  void searchIndex; // re-exported by index-manager; reference here documents the dep
36
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -668,7 +669,7 @@ app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
668
669
  res.json({ status: "interrupting" });
669
670
  });
670
671
  // ---------------------------------------------------------------------------
671
- // Chat POST→SSE endpoints — gated by CHAPTERHOUSE_CHAT_SSE=1 (#130)
672
+ // Chat POST→SSE endpoints — enabled by default, overridable via CHAPTERHOUSE_CHAT_SSE (#130)
672
673
  // ---------------------------------------------------------------------------
673
674
  const turnRequestSchema = z.object({
674
675
  prompt: z.string().trim().min(1, "Missing prompt"),
@@ -838,6 +839,17 @@ app.get("/api/auto", (_req, res) => {
838
839
  lastRoute: lastRoute || null,
839
840
  });
840
841
  });
842
+ app.get("/api/memory/active-scope", (_req, res) => {
843
+ const activeScope = getActiveScope();
844
+ if (!activeScope) {
845
+ res.json(null);
846
+ return;
847
+ }
848
+ res.json({
849
+ slug: activeScope.slug,
850
+ title: activeScope.title,
851
+ });
852
+ });
841
853
  app.post("/api/auto", (req, res) => {
842
854
  const body = parseRequest(autoRequestSchema, req.body ?? {});
843
855
  const updated = updateRouterConfig(body);
@@ -1,10 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
7
6
  import Database from "better-sqlite3";
7
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
8
8
  test("supports API_TOKEN env var and personal health route helpers", async () => {
9
9
  const runtime = await import("./server-runtime.js");
10
10
  assert.equal(typeof runtime.resolveApiToken, "function", "resolveApiToken should be exported");
@@ -29,25 +29,25 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
29
29
  standaloneMode: false,
30
30
  entraClientId: "client-id",
31
31
  entraTenantId: "tenant-id",
32
- chatSseEnabled: false,
32
+ chatSseEnabled: true,
33
33
  }), {
34
34
  appName: "Chapterhouse",
35
35
  entraAuthEnabled: true,
36
36
  entraClientId: "client-id",
37
37
  entraTenantId: "tenant-id",
38
- chatSseEnabled: false,
38
+ chatSseEnabled: true,
39
39
  });
40
40
  assert.deepEqual(runtime.createPublicConfigPayload({
41
41
  entraAuthEnabled: false,
42
42
  standaloneMode: false,
43
43
  entraClientId: "client-id",
44
44
  entraTenantId: "tenant-id",
45
- chatSseEnabled: false,
45
+ chatSseEnabled: true,
46
46
  }), {
47
47
  appName: "Chapterhouse",
48
48
  entraAuthEnabled: false,
49
49
  standalone: false,
50
- chatSseEnabled: false,
50
+ chatSseEnabled: true,
51
51
  });
52
52
  assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
53
53
  entraAuthEnabled: false,
@@ -62,6 +62,21 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
62
62
  apiToken: null,
63
63
  }));
64
64
  });
65
+ test("createPublicConfigPayload defaults chat SSE on when not explicitly provided", async () => {
66
+ const runtime = await import("./server-runtime.js");
67
+ assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
68
+ assert.deepEqual(runtime.createPublicConfigPayload({
69
+ entraAuthEnabled: false,
70
+ standaloneMode: true,
71
+ entraClientId: "client-id",
72
+ entraTenantId: "tenant-id",
73
+ }), {
74
+ appName: "Chapterhouse",
75
+ entraAuthEnabled: false,
76
+ standalone: true,
77
+ chatSseEnabled: true,
78
+ });
79
+ });
65
80
  test("formats named SSE status events", async () => {
66
81
  const sse = await import("./sse.js");
67
82
  assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
@@ -109,42 +124,37 @@ function readProjectRegistryRows(testRoot) {
109
124
  db.close();
110
125
  }
111
126
  }
112
- async function getFreePort() {
113
- const server = createServer();
114
- await new Promise((resolve) => {
115
- server.listen(0, "127.0.0.1", () => resolve());
116
- });
117
- const address = server.address();
118
- assert.ok(address && typeof address === "object", "server should expose a bound address");
119
- await new Promise((resolve, reject) => {
120
- server.close((err) => (err ? reject(err) : resolve()));
121
- });
122
- return address.port;
123
- }
124
- async function stopChild(child) {
125
- if (child.exitCode !== null) {
126
- return;
127
+ function setMemoryActiveScope(testRoot, slug) {
128
+ const db = new Database(getProjectDbPath(testRoot));
129
+ try {
130
+ if (slug === null) {
131
+ db.prepare(`DELETE FROM mem_settings WHERE key = ?`).run("current_scope_slug");
132
+ return;
133
+ }
134
+ const scope = db.prepare(`SELECT slug FROM mem_scopes WHERE slug = ?`).get(slug);
135
+ assert.ok(scope, `expected seeded memory scope '${slug}' to exist`);
136
+ db.prepare(`
137
+ INSERT INTO mem_settings (key, value)
138
+ VALUES (?, ?)
139
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
140
+ `).run("current_scope_slug", slug);
141
+ }
142
+ finally {
143
+ db.close();
127
144
  }
128
- child.kill("SIGTERM");
129
- await new Promise((resolve) => {
130
- child.once("exit", () => resolve());
131
- setTimeout(() => {
132
- if (child.exitCode === null) {
133
- child.kill("SIGKILL");
134
- }
135
- }, 2_000);
136
- });
137
145
  }
138
146
  // Standalone mode triggers a full daemon init via the server→daemon circular import
139
147
  // (server.ts imports restartDaemon from daemon.ts, which has module-level main()).
140
148
  // The Copilot SDK's client.start() then blocks the event loop while authenticating,
141
- // so the HTTP server cannot respond until SDK init completes (~10-20 s depending on
142
- // session state). Other modes complete in ~330 ms because the SDK yields quickly.
149
+ // so the HTTP server cannot respond until SDK init completes (~10-30 s depending on
150
+ // session state and runner load). Other modes usually complete much faster, but cold
151
+ // CI starts can still exceed a 10 s budget.
143
152
  // Root cause tracked in decisions notes (backend fix: lazy-import or
144
- // guard env var in daemon.ts). Interim fix: 30 s timeout for standalone, 10 s elsewhere.
153
+ // guard env var in daemon.ts). Interim fix: use a more generous startup timeout for
154
+ // integration tests while keeping the same /status readiness probe.
145
155
  // Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
146
156
  // piggy-back on the running agent session, which worsens the blocking behaviour.
147
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
157
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
148
158
  const testRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
149
159
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
150
160
  rmSync(testRoot, { recursive: true, force: true });
@@ -174,24 +184,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
174
184
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
175
185
  const baseUrl = `http://127.0.0.1:${port}`;
176
186
  try {
177
- const deadline = Date.now() + timeoutMs;
178
- while (Date.now() < deadline) {
179
- if (child.exitCode !== null) {
180
- throw new Error(`API server exited early:\n${logs.join("")}`);
181
- }
182
- try {
183
- const response = await fetch(`${baseUrl}/status`);
184
- if (response.ok) {
185
- await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
186
- return;
187
- }
188
- }
189
- catch {
190
- // Server still starting.
191
- }
192
- await new Promise((resolve) => setTimeout(resolve, 100));
193
- }
194
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
187
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
188
+ await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
195
189
  }
196
190
  finally {
197
191
  await stopChild(child);
@@ -209,7 +203,7 @@ test("server routes expose bootstrap and public config without auth", async () =
209
203
  appName: "Chapterhouse",
210
204
  entraAuthEnabled: false,
211
205
  standalone: false,
212
- chatSseEnabled: false,
206
+ chatSseEnabled: true,
213
207
  });
214
208
  });
215
209
  });
@@ -224,14 +218,34 @@ test("server runs in standalone mode without auth", async () => {
224
218
  appName: "Chapterhouse",
225
219
  entraAuthEnabled: false,
226
220
  standalone: true,
227
- chatSseEnabled: false,
221
+ chatSseEnabled: true,
228
222
  });
229
223
  const model = await fetch(`${baseUrl}/api/model`);
230
224
  assert.equal(model.status, 200);
231
225
  assert.deepEqual(await model.json(), { model: "claude-sonnet-4.6" });
232
226
  }, {
233
227
  API_TOKEN: "",
234
- }, 30_000);
228
+ }, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS);
229
+ });
230
+ test("server exposes the active memory scope API and requires auth", async () => {
231
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
232
+ const unauthorized = await fetch(`${baseUrl}/api/memory/active-scope`);
233
+ assert.equal(unauthorized.status, 401);
234
+ const noScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
235
+ headers: { authorization: authHeader },
236
+ });
237
+ assert.equal(noScope.status, 200);
238
+ assert.equal(await noScope.json(), null);
239
+ setMemoryActiveScope(testRoot, "chapterhouse");
240
+ const activeScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
241
+ headers: { authorization: authHeader },
242
+ });
243
+ assert.equal(activeScope.status, 200);
244
+ assert.deepEqual(await activeScope.json(), {
245
+ slug: "chapterhouse",
246
+ title: "Chapterhouse",
247
+ });
248
+ });
235
249
  });
236
250
  test("server bootstrap rejects non-loopback origins", async () => {
237
251
  await withStartedServer(async ({ baseUrl }) => {
@@ -1,42 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { mkdirSync, rmSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
6
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Server harness (copied from server.test.ts; distinct root path suffix to
9
9
  // avoid collision when both test files run in parallel in the same process).
10
10
  // ---------------------------------------------------------------------------
11
11
  const repoRoot = process.cwd();
12
12
  const sseTestRoot = join(repoRoot, ".test-work", `sse-integration-${process.pid}`);
13
- async function getFreePort() {
14
- const server = createServer();
15
- await new Promise((resolve) => {
16
- server.listen(0, "127.0.0.1", () => resolve());
17
- });
18
- const address = server.address();
19
- assert.ok(address && typeof address === "object", "server should expose a bound address");
20
- await new Promise((resolve, reject) => {
21
- server.close((err) => (err ? reject(err) : resolve()));
22
- });
23
- return address.port;
24
- }
25
- async function stopChild(child) {
26
- if (child.exitCode !== null) {
27
- return;
28
- }
29
- child.kill("SIGTERM");
30
- await new Promise((resolve) => {
31
- child.once("exit", () => resolve());
32
- setTimeout(() => {
33
- if (child.exitCode === null) {
34
- child.kill("SIGKILL");
35
- }
36
- }, 2_000);
37
- });
38
- }
39
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
13
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
40
14
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
41
15
  rmSync(sseTestRoot, { recursive: true, force: true });
42
16
  mkdirSync(sseTestRoot, { recursive: true });
@@ -65,24 +39,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
65
39
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
66
40
  const baseUrl = `http://127.0.0.1:${port}`;
67
41
  try {
68
- const deadline = Date.now() + timeoutMs;
69
- while (Date.now() < deadline) {
70
- if (child.exitCode !== null) {
71
- throw new Error(`API server exited early:\n${logs.join("")}`);
72
- }
73
- try {
74
- const response = await fetch(`${baseUrl}/status`);
75
- if (response.ok) {
76
- await run({ baseUrl, authHeader: "Bearer route-token" });
77
- return;
78
- }
79
- }
80
- catch {
81
- // Server still starting.
82
- }
83
- await new Promise((resolve) => setTimeout(resolve, 100));
84
- }
85
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
42
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
43
+ await run({ baseUrl, authHeader: "Bearer route-token" });
86
44
  }
87
45
  finally {
88
46
  await stopChild(child);
@@ -2,8 +2,8 @@
2
2
  * Integration tests for the POST→SSE chat endpoints (#130).
3
3
  *
4
4
  * Tests:
5
- * 1. POST /api/sessions/:key/turn returns 404 when flag is off
6
- * 2. GET /api/sessions/:key/stream returns 404 when flag is off
5
+ * 1. POST /api/sessions/:key/turn returns 404 when flag is explicitly off
6
+ * 2. GET /api/sessions/:key/stream returns 404 when flag is explicitly off
7
7
  * 3. POST /api/sessions/:key/turn returns { turnId } when flag is on
8
8
  * 4. GET /api/sessions/:key/stream delivers turn events (SSE)
9
9
  * 5. SSE reconnect with Last-Event-ID replays missed events
@@ -11,8 +11,8 @@
11
11
  * 7. Legacy POST /api/message path unaffected when flag is off
12
12
  *
13
13
  * The tests that require the feature flag spawn a separate server process with
14
- * CHAPTERHOUSE_CHAT_SSE=1. Tests that verify flag-off behavior use a server
15
- * without the flag.
14
+ * CHAPTERHOUSE_CHAT_SSE defaults on. Tests that verify flag-off behavior use a
15
+ * server with CHAPTERHOUSE_CHAT_SSE=0.
16
16
  *
17
17
  * Because the Copilot SDK is not available in test env (no valid token), tests
18
18
  * that would trigger a real turn instead verify the API shape and event
@@ -23,34 +23,16 @@
23
23
  import assert from "node:assert/strict";
24
24
  import { spawn } from "node:child_process";
25
25
  import { mkdirSync, rmSync } from "node:fs";
26
- import { createServer } from "node:http";
27
26
  import { join } from "node:path";
28
27
  import test from "node:test";
28
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
29
29
  // ---------------------------------------------------------------------------
30
30
  // Helpers — copied/adapted from sse.integration.test.ts
31
31
  // ---------------------------------------------------------------------------
32
32
  const repoRoot = process.cwd();
33
33
  const testWorkRoot = join(repoRoot, ".test-work", `turn-sse-${process.pid}`);
34
34
  let _instanceCounter = 0;
35
- async function getFreePort() {
36
- const server = createServer();
37
- await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
38
- const addr = server.address();
39
- assert.ok(addr && typeof addr === "object");
40
- await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
41
- return addr.port;
42
- }
43
- async function stopChild(child) {
44
- if (child.exitCode !== null)
45
- return;
46
- child.kill("SIGTERM");
47
- await new Promise((resolve) => {
48
- child.once("exit", () => resolve());
49
- setTimeout(() => { if (child.exitCode === null)
50
- child.kill("SIGKILL"); }, 2_000);
51
- });
52
- }
53
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
35
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
54
36
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
55
37
  const instanceRoot = `${testWorkRoot}-${Date.now()}-${++_instanceCounter}`;
56
38
  rmSync(instanceRoot, { recursive: true, force: true });
@@ -78,24 +60,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
78
60
  child.stderr?.on("data", (c) => logs.push(String(c)));
79
61
  const baseUrl = `http://127.0.0.1:${port}`;
80
62
  try {
81
- const deadline = Date.now() + timeoutMs;
82
- while (Date.now() < deadline) {
83
- if (child.exitCode !== null)
84
- throw new Error(`Server exited early:\n${logs.join("")}`);
85
- let serverReady = false;
86
- try {
87
- const r = await fetch(`${baseUrl}/status`);
88
- serverReady = r.ok;
89
- }
90
- catch { /* not ready yet */ }
91
- if (serverReady) {
92
- // Run outside the fetch try/catch so assertion errors propagate correctly.
93
- await run({ baseUrl, authHeader: "Bearer test-token" });
94
- return;
95
- }
96
- await new Promise((r) => setTimeout(r, 100));
97
- }
98
- throw new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`);
63
+ await waitForApiServerReady({
64
+ child,
65
+ baseUrl,
66
+ logs,
67
+ timeoutMs,
68
+ timeoutMessage: `Server did not start in ${timeoutMs}ms`,
69
+ exitMessage: "Server exited early",
70
+ });
71
+ await run({ baseUrl, authHeader: "Bearer test-token" });
99
72
  }
100
73
  finally {
101
74
  await stopChild(child);
@@ -162,18 +135,18 @@ test("turn-sse: POST /api/sessions/:key/turn returns 404 when flag is off", asyn
162
135
  headers: { Authorization: authHeader, "Content-Type": "application/json" },
163
136
  body: JSON.stringify({ prompt: "hello" }),
164
137
  });
165
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
138
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
166
139
  const body = await res.json();
167
140
  assert.ok(typeof body.error === "string" && body.error.includes("Chat SSE"), `Expected error about Chat SSE, got: ${body.error}`);
168
- });
141
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
169
142
  });
170
143
  test("turn-sse: GET /api/sessions/:key/stream returns 404 when flag is off", async () => {
171
144
  await withStartedServer(async ({ baseUrl, authHeader }) => {
172
145
  const res = await fetch(`${baseUrl}/api/sessions/default/stream`, {
173
146
  headers: { Authorization: authHeader },
174
147
  });
175
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
176
- });
148
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
149
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
177
150
  });
178
151
  test("turn-sse: legacy POST /api/message is unaffected when flag is off", async () => {
179
152
  await withStartedServer(async ({ baseUrl, authHeader }) => {
@@ -186,7 +159,7 @@ test("turn-sse: legacy POST /api/message is unaffected when flag is off", async
186
159
  });
187
160
  // 400 = route exists and validated the request (connectionId not found in sseClients)
188
161
  assert.ok(res.status === 400 || res.status === 401 || res.status === 422, `Expected 4xx from /api/message, got ${res.status}`);
189
- });
162
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
190
163
  });
191
164
  // ---------------------------------------------------------------------------
192
165
  // Tests: feature flag ON
package/dist/config.js CHANGED
@@ -46,8 +46,24 @@ const configSchema = z.object({
46
46
  API_RATE_LIMIT_GENERAL_MAX: z.string().optional(),
47
47
  API_RATE_LIMIT_AUTH_MAX: z.string().optional(),
48
48
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
49
+ CHAPTERHOUSE_SSE_BUFFER_CAPACITY: z.string().optional(),
50
+ CHAPTERHOUSE_SSE_REPLAY_LIMIT: z.string().optional(),
49
51
  CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
50
52
  CHAPTERHOUSE_CHAT_SSE: z.string().optional(),
53
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED: z.string().optional(),
54
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS: z.string().optional(),
55
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE: z.string().optional(),
56
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE: z.string().optional(),
57
+ CHAPTERHOUSE_MEMORY_INJECT: z.string().optional(),
58
+ CHAPTERHOUSE_MEMORY_AUTO_ACCEPT: z.string().optional(),
59
+ CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED: z.string().optional(),
60
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED: z.string().optional(),
61
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS: z.string().optional(),
62
+ CHAPTERHOUSE_MEMORY_DECAY_DAYS: z.string().optional(),
63
+ CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS: z.string().optional(),
64
+ CHAPTERHOUSE_MEMORY_TIERING_ENABLED: z.string().optional(),
65
+ CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST: z.string().optional(),
66
+ CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS: z.string().optional(),
51
67
  });
52
68
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
53
69
  export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
@@ -60,6 +76,18 @@ export const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 60_000;
60
76
  export const DEFAULT_API_RATE_LIMIT_GENERAL_MAX = 100;
61
77
  export const DEFAULT_API_RATE_LIMIT_AUTH_MAX = 10;
62
78
  export const DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS = 5;
79
+ export const DEFAULT_SSE_BUFFER_CAPACITY = 2_000;
80
+ export const DEFAULT_SSE_REPLAY_LIMIT = 10_000;
81
+ function parseZeroOneEnv(name, rawValue, defaultValue) {
82
+ const normalized = rawValue?.trim();
83
+ if (!normalized) {
84
+ return defaultValue;
85
+ }
86
+ if (normalized !== "0" && normalized !== "1") {
87
+ throw new Error(`${name} must be '0' or '1', got: "${rawValue}"`);
88
+ }
89
+ return normalized === "1";
90
+ }
63
91
  function parseBooleanEnv(name, rawValue, defaultValue) {
64
92
  const normalized = rawValue?.trim();
65
93
  if (!normalized) {
@@ -100,6 +128,17 @@ function parsePositiveIntegerEnv(name, rawValue, defaultValue) {
100
128
  }
101
129
  return parsed;
102
130
  }
131
+ function parsePositiveNumberEnv(name, rawValue, defaultValue) {
132
+ const normalized = rawValue?.trim();
133
+ if (!normalized) {
134
+ return defaultValue;
135
+ }
136
+ const parsed = Number(normalized);
137
+ if (!Number.isFinite(parsed) || parsed <= 0) {
138
+ throw new Error(`${name} must be a positive number, got: "${rawValue}"`);
139
+ }
140
+ return parsed;
141
+ }
103
142
  function parseCorsAllowedOrigins(rawValue) {
104
143
  const normalized = rawValue?.trim();
105
144
  if (!normalized) {
@@ -190,6 +229,15 @@ export function parseRuntimeConfig(env, options = {}) {
190
229
  const apiRateLimitGeneralMax = parsePositiveIntegerEnv("API_RATE_LIMIT_GENERAL_MAX", raw.API_RATE_LIMIT_GENERAL_MAX, DEFAULT_API_RATE_LIMIT_GENERAL_MAX);
191
230
  const apiRateLimitAuthMax = parsePositiveIntegerEnv("API_RATE_LIMIT_AUTH_MAX", raw.API_RATE_LIMIT_AUTH_MAX, DEFAULT_API_RATE_LIMIT_AUTH_MAX);
192
231
  const apiRateLimitSseMaxConnections = parsePositiveIntegerEnv("API_RATE_LIMIT_SSE_MAX_CONNECTIONS", raw.API_RATE_LIMIT_SSE_MAX_CONNECTIONS, DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS);
232
+ const sseBufferCapacity = parsePositiveIntegerEnv("CHAPTERHOUSE_SSE_BUFFER_CAPACITY", raw.CHAPTERHOUSE_SSE_BUFFER_CAPACITY, DEFAULT_SSE_BUFFER_CAPACITY);
233
+ const sseReplayLimit = parsePositiveIntegerEnv("CHAPTERHOUSE_SSE_REPLAY_LIMIT", raw.CHAPTERHOUSE_SSE_REPLAY_LIMIT, DEFAULT_SSE_REPLAY_LIMIT);
234
+ const memoryCheckpointTurns = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS, 5);
235
+ const memoryCheckpointMinTurnsForScopeFire = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE, 2);
236
+ const memoryHousekeepingTurns = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS", raw.CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS, 50);
237
+ const memoryDecayDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_DECAY_DAYS", raw.CHAPTERHOUSE_MEMORY_DECAY_DAYS, 30);
238
+ const memoryInboxRetentionDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS", raw.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS, 7);
239
+ const memoryHotRecallBoost = parsePositiveNumberEnv("CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST", raw.CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST, 1.5);
240
+ const memoryHotAgeDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS", raw.CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS, 30);
193
241
  if (entraAuthEnabled && (!entraTenantId || !entraClientId)) {
194
242
  throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
195
243
  }
@@ -229,8 +277,24 @@ export function parseRuntimeConfig(env, options = {}) {
229
277
  apiRateLimitGeneralMax,
230
278
  apiRateLimitAuthMax,
231
279
  apiRateLimitSseMaxConnections,
280
+ sseBufferCapacity,
281
+ sseReplayLimit,
232
282
  workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
233
- chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE === "1",
283
+ chatSseEnabled: parseZeroOneEnv("CHAPTERHOUSE_CHAT_SSE", raw.CHAPTERHOUSE_CHAT_SSE, true),
284
+ memoryCheckpointEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED, true),
285
+ memoryCheckpointTurns,
286
+ memoryCheckpointOnScopeChange: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE, true),
287
+ memoryCheckpointMinTurnsForScopeFire,
288
+ memoryInjectEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_INJECT", raw.CHAPTERHOUSE_MEMORY_INJECT, true),
289
+ memoryAutoAcceptEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_AUTO_ACCEPT", raw.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT, true),
290
+ memoryEndOfTaskHookEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED", raw.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED, true),
291
+ memoryHousekeepingEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED", raw.CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED, true),
292
+ memoryHousekeepingTurns,
293
+ memoryDecayDays,
294
+ memoryInboxRetentionDays,
295
+ memoryTieringEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_TIERING_ENABLED", raw.CHAPTERHOUSE_MEMORY_TIERING_ENABLED, true),
296
+ memoryHotRecallBoost,
297
+ memoryHotAgeDays,
234
298
  };
235
299
  }
236
300
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -270,8 +334,24 @@ export const config = {
270
334
  apiRateLimitGeneralMax: runtimeConfig.apiRateLimitGeneralMax,
271
335
  apiRateLimitAuthMax: runtimeConfig.apiRateLimitAuthMax,
272
336
  apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
337
+ sseBufferCapacity: runtimeConfig.sseBufferCapacity,
338
+ sseReplayLimit: runtimeConfig.sseReplayLimit,
273
339
  workiqAutoInstall: runtimeConfig.workiqAutoInstall,
274
340
  chatSseEnabled: runtimeConfig.chatSseEnabled,
341
+ memoryCheckpointEnabled: runtimeConfig.memoryCheckpointEnabled,
342
+ memoryCheckpointTurns: runtimeConfig.memoryCheckpointTurns,
343
+ memoryCheckpointOnScopeChange: runtimeConfig.memoryCheckpointOnScopeChange,
344
+ memoryCheckpointMinTurnsForScopeFire: runtimeConfig.memoryCheckpointMinTurnsForScopeFire,
345
+ memoryInjectEnabled: runtimeConfig.memoryInjectEnabled,
346
+ memoryAutoAcceptEnabled: runtimeConfig.memoryAutoAcceptEnabled,
347
+ memoryEndOfTaskHookEnabled: runtimeConfig.memoryEndOfTaskHookEnabled,
348
+ memoryHousekeepingEnabled: runtimeConfig.memoryHousekeepingEnabled,
349
+ memoryHousekeepingTurns: runtimeConfig.memoryHousekeepingTurns,
350
+ memoryDecayDays: runtimeConfig.memoryDecayDays,
351
+ memoryInboxRetentionDays: runtimeConfig.memoryInboxRetentionDays,
352
+ memoryTieringEnabled: runtimeConfig.memoryTieringEnabled,
353
+ memoryHotRecallBoost: runtimeConfig.memoryHotRecallBoost,
354
+ memoryHotAgeDays: runtimeConfig.memoryHotAgeDays,
275
355
  copilotAuthToken: runtimeConfig.copilotAuthToken,
276
356
  get copilotModel() {
277
357
  return _copilotModel;