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.
- package/dist/api/server-runtime.js +1 -1
- package/dist/api/server.js +13 -1
- package/dist/api/server.test.js +68 -54
- package/dist/api/sse.integration.test.js +4 -46
- package/dist/api/turn-sse.integration.test.js +20 -47
- package/dist/config.js +81 -1
- package/dist/config.test.js +123 -0
- package/dist/copilot/agents.js +27 -4
- package/dist/copilot/agents.test.js +7 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +228 -4
- package/dist/copilot/orchestrator.test.js +373 -1
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +24 -0
- package/dist/copilot/tools.agent.test.js +23 -0
- package/dist/copilot/tools.js +350 -4
- package/dist/copilot/tools.memory.test.js +248 -0
- package/dist/copilot/turn-event-log-env.test.js +19 -0
- package/dist/copilot/turn-event-log.js +22 -23
- package/dist/copilot/turn-event-log.test.js +61 -2
- package/dist/memory/active-scope.js +69 -0
- package/dist/memory/active-scope.test.js +76 -0
- package/dist/memory/checkpoint-prompt.js +71 -0
- package/dist/memory/checkpoint.js +257 -0
- package/dist/memory/checkpoint.test.js +255 -0
- package/dist/memory/decisions.js +53 -0
- package/dist/memory/decisions.test.js +92 -0
- package/dist/memory/entities.js +59 -0
- package/dist/memory/entities.test.js +65 -0
- package/dist/memory/eot.js +219 -0
- package/dist/memory/eot.test.js +263 -0
- package/dist/memory/hot-tier.js +187 -0
- package/dist/memory/hot-tier.test.js +197 -0
- package/dist/memory/housekeeping.js +352 -0
- package/dist/memory/housekeeping.test.js +280 -0
- package/dist/memory/inbox.js +73 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/observations.js +46 -0
- package/dist/memory/observations.test.js +86 -0
- package/dist/memory/recall.js +197 -0
- package/dist/memory/recall.test.js +196 -0
- package/dist/memory/scopes.js +89 -0
- package/dist/memory/scopes.test.js +201 -0
- package/dist/memory/tiering.js +193 -0
- package/dist/memory/types.js +2 -0
- package/dist/paths.js +7 -1
- package/dist/store/db.js +423 -17
- package/dist/store/db.test.js +94 -7
- package/dist/test/api-server.js +50 -0
- package/dist/test/api-server.test.js +57 -0
- package/dist/test/setup-env.js +25 -0
- package/dist/test/setup-env.test.js +38 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
- package/web/dist/assets/index-DmYLALt0.js.map +1 -0
- package/web/dist/index.html +1 -1
- 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 ??
|
|
23
|
+
const chatSseEnabled = config.chatSseEnabled ?? true;
|
|
24
24
|
if (!config.entraAuthEnabled) {
|
|
25
25
|
return {
|
|
26
26
|
appName: "Chapterhouse",
|
package/dist/api/server.js
CHANGED
|
@@ -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 —
|
|
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);
|
package/dist/api/server.test.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
45
|
+
chatSseEnabled: true,
|
|
46
46
|
}), {
|
|
47
47
|
appName: "Chapterhouse",
|
|
48
48
|
entraAuthEnabled: false,
|
|
49
49
|
standalone: false,
|
|
50
|
-
chatSseEnabled:
|
|
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
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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-
|
|
142
|
-
// session state).
|
|
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).
|
|
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 =
|
|
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
|
-
|
|
178
|
-
|
|
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:
|
|
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:
|
|
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
|
-
},
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|