chapterhouse 0.5.0 → 0.5.2
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.js +31 -3
- package/dist/api/server.test.js +48 -5
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +44 -0
- package/dist/copilot/orchestrator.js +17 -6
- package/dist/copilot/orchestrator.test.js +50 -3
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +30 -18
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.js +79 -12
- package/dist/copilot/tools.memory.test.js +94 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +115 -16
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/scopes.test.js +0 -24
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +152 -95
- package/dist/setup.test.js +122 -0
- package/dist/store/db.js +0 -18
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
- package/web/dist/assets/index-CPaILy2j.js.map +1 -0
- package/web/dist/assets/index-Cs7AGeaL.css +10 -0
- package/web/dist/index.html +2 -2
- package/agents/bellonda.agent.md +0 -11
- package/agents/hwi-noree.agent.md +0 -12
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
package/dist/api/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo,
|
|
|
9
9
|
import { agentEventBus } from "../copilot/agent-event-bus.js";
|
|
10
10
|
import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
12
|
+
import { ModeContext } from "../mode-context.js";
|
|
12
13
|
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
13
14
|
import { searchIndex, parseIndex } from "../wiki/index-manager.js";
|
|
14
15
|
import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
|
|
@@ -32,7 +33,9 @@ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfig
|
|
|
32
33
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
33
34
|
import { childLogger } from "../util/logger.js";
|
|
34
35
|
import { getActiveScope } from "../memory/active-scope.js";
|
|
36
|
+
import { createScope, getScope } from "../memory/scopes.js";
|
|
35
37
|
const log = childLogger("server");
|
|
38
|
+
const modeContext = new ModeContext(config);
|
|
36
39
|
void searchIndex; // re-exported by index-manager; reference here documents the dep
|
|
37
40
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
41
|
// Built SPA bundle (web/dist/), shipped alongside dist/
|
|
@@ -81,6 +84,12 @@ const projectCreateSchema = z.object({
|
|
|
81
84
|
cwd: requiredString("Missing 'cwd' in request body")
|
|
82
85
|
.refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
|
|
83
86
|
}).strict();
|
|
87
|
+
const scopeCreateSchema = z.object({
|
|
88
|
+
slug: requiredString("Missing 'slug' in request body")
|
|
89
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
|
|
90
|
+
title: requiredString("Missing 'title' in request body"),
|
|
91
|
+
description: z.string().optional(),
|
|
92
|
+
}).strict();
|
|
84
93
|
const projectHardRulesSchema = z.object({
|
|
85
94
|
hardRules: z.object({
|
|
86
95
|
auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
|
|
@@ -141,7 +150,7 @@ catch (err) {
|
|
|
141
150
|
log.error({ err: err instanceof Error ? err.message : String(err) }, "Auth token error");
|
|
142
151
|
process.exit(1);
|
|
143
152
|
}
|
|
144
|
-
if (config.standaloneMode) {
|
|
153
|
+
if (modeContext.isPersonal() && config.standaloneMode) {
|
|
145
154
|
log.warn("Running without authentication — team features disabled");
|
|
146
155
|
}
|
|
147
156
|
function isLoopbackHostname(hostname) {
|
|
@@ -255,7 +264,7 @@ function assertValidPagePath(path) {
|
|
|
255
264
|
}
|
|
256
265
|
}
|
|
257
266
|
function getWikiPageScope(path) {
|
|
258
|
-
return teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
267
|
+
return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
259
268
|
}
|
|
260
269
|
function getEmptyWikiWelcomeContent(today = new Date()) {
|
|
261
270
|
return `---
|
|
@@ -892,6 +901,25 @@ app.get("/api/memory/active-scope", (_req, res) => {
|
|
|
892
901
|
title: activeScope.title,
|
|
893
902
|
});
|
|
894
903
|
});
|
|
904
|
+
app.post("/api/scopes", (req, res) => {
|
|
905
|
+
const body = parseRequest(scopeCreateSchema, req.body ?? {});
|
|
906
|
+
if (getScope(body.slug)) {
|
|
907
|
+
res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const scope = createScope({
|
|
911
|
+
slug: body.slug,
|
|
912
|
+
title: body.title,
|
|
913
|
+
description: body.description ?? "",
|
|
914
|
+
keywords: [body.slug],
|
|
915
|
+
});
|
|
916
|
+
res.status(201).json({
|
|
917
|
+
slug: scope.slug,
|
|
918
|
+
title: scope.title,
|
|
919
|
+
description: scope.description,
|
|
920
|
+
active: scope.active,
|
|
921
|
+
});
|
|
922
|
+
});
|
|
895
923
|
app.post("/api/auto", (req, res) => {
|
|
896
924
|
const body = parseRequest(autoRequestSchema, req.body ?? {});
|
|
897
925
|
const updated = updateRouterConfig(body);
|
|
@@ -1001,7 +1029,7 @@ app.put("/api/projects/:slug/rules/soft", async (req, res) => {
|
|
|
1001
1029
|
app.get("/api/wiki/pages", async (req, res) => {
|
|
1002
1030
|
ensureWikiStructure();
|
|
1003
1031
|
// Sync team wiki pages if connected, using the caller's auth token
|
|
1004
|
-
if (teamWikiSync.isEnabled()) {
|
|
1032
|
+
if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
|
|
1005
1033
|
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1006
1034
|
? req.headers.authorization
|
|
1007
1035
|
: undefined;
|
package/dist/api/server.test.js
CHANGED
|
@@ -216,15 +216,10 @@ test("server channels route returns chapterhouse plus persistent agents in chann
|
|
|
216
216
|
const channels = await response.json();
|
|
217
217
|
assert.deepEqual(channels.map((channel) => channel.key), [
|
|
218
218
|
"default",
|
|
219
|
-
"agent:bellonda",
|
|
220
|
-
"agent:hwi-noree",
|
|
221
219
|
]);
|
|
222
220
|
assert.deepEqual(channels.map((channel) => channel.label), [
|
|
223
221
|
"# chapterhouse",
|
|
224
|
-
"# bellonda",
|
|
225
|
-
"# hwi-noree",
|
|
226
222
|
]);
|
|
227
|
-
assert.equal(channels.find((channel) => channel.key === "agent:bellonda")?.scope, "infra");
|
|
228
223
|
});
|
|
229
224
|
});
|
|
230
225
|
test("server runs in standalone mode without auth", async () => {
|
|
@@ -267,6 +262,54 @@ test("server exposes the active memory scope API and requires auth", async () =>
|
|
|
267
262
|
});
|
|
268
263
|
});
|
|
269
264
|
});
|
|
265
|
+
test("server creates memory scopes with duplicate and slug validation", async () => {
|
|
266
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
267
|
+
const unauthorized = await fetch(`${baseUrl}/api/scopes`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "content-type": "application/json" },
|
|
270
|
+
body: JSON.stringify({ slug: "docs-site", title: "Docs Site" }),
|
|
271
|
+
});
|
|
272
|
+
assert.equal(unauthorized.status, 401);
|
|
273
|
+
const created = await fetch(`${baseUrl}/api/scopes`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
authorization: authHeader,
|
|
277
|
+
"content-type": "application/json",
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
slug: "docs-site",
|
|
281
|
+
title: "Docs Site",
|
|
282
|
+
description: "Documentation publishing and content workflows",
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
assert.equal(created.status, 201);
|
|
286
|
+
assert.deepEqual(await created.json(), {
|
|
287
|
+
slug: "docs-site",
|
|
288
|
+
title: "Docs Site",
|
|
289
|
+
description: "Documentation publishing and content workflows",
|
|
290
|
+
active: true,
|
|
291
|
+
});
|
|
292
|
+
const duplicate = await fetch(`${baseUrl}/api/scopes`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
authorization: authHeader,
|
|
296
|
+
"content-type": "application/json",
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify({ slug: "docs-site", title: "Docs Site Again" }),
|
|
299
|
+
});
|
|
300
|
+
assert.equal(duplicate.status, 409);
|
|
301
|
+
assert.deepEqual(await duplicate.json(), { error: "Memory scope 'docs-site' already exists" });
|
|
302
|
+
const invalid = await fetch(`${baseUrl}/api/scopes`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: {
|
|
305
|
+
authorization: authHeader,
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify({ slug: "Docs Site", title: "Docs Site" }),
|
|
309
|
+
});
|
|
310
|
+
assert.equal(invalid.status, 400);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
270
313
|
test("server bootstrap rejects non-loopback origins", async () => {
|
|
271
314
|
await withStartedServer(async ({ baseUrl }) => {
|
|
272
315
|
const response = await fetch(`${baseUrl}/api/bootstrap`, {
|
package/dist/cli.js
CHANGED
|
@@ -61,9 +61,11 @@ switch (command) {
|
|
|
61
61
|
await import("./daemon.js");
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
|
-
case "setup":
|
|
65
|
-
await import("./setup.js");
|
|
64
|
+
case "setup": {
|
|
65
|
+
const { runSetup } = await import("./setup.js");
|
|
66
|
+
await runSetup();
|
|
66
67
|
break;
|
|
68
|
+
}
|
|
67
69
|
case "update": {
|
|
68
70
|
const updateFlags = args.slice(1);
|
|
69
71
|
const checkOnly = updateFlags.includes("--check-only");
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { config as loadEnv } from "dotenv";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
|
|
5
|
+
export { ModeContext } from "./mode-context.js";
|
|
5
6
|
export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
|
|
6
7
|
const DEFAULT_WORKER_TIMEOUT_MS = 900_000;
|
|
7
8
|
const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
|
|
@@ -176,6 +177,43 @@ function parseEntityCategories(rawValue) {
|
|
|
176
177
|
.filter(Boolean);
|
|
177
178
|
return cats.length > 0 ? [...new Set(cats)] : [...DEFAULT_ENTITY_CATEGORIES];
|
|
178
179
|
}
|
|
180
|
+
function configuredKeys(entries) {
|
|
181
|
+
return entries
|
|
182
|
+
.filter(([, value]) => typeof value === "boolean" ? value : value.trim().length > 0)
|
|
183
|
+
.map(([key]) => key);
|
|
184
|
+
}
|
|
185
|
+
function buildModeCompatibilityWarnings(raw, mode) {
|
|
186
|
+
if (mode !== "personal") {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
const warnings = [];
|
|
190
|
+
const adoKeys = configuredKeys([
|
|
191
|
+
["ADO_ORG", raw.ADO_ORG?.trim() || ""],
|
|
192
|
+
["ADO_PROJECT", raw.ADO_PROJECT?.trim() || ""],
|
|
193
|
+
["ADO_PAT", raw.ADO_PAT?.trim() || ""],
|
|
194
|
+
]);
|
|
195
|
+
if (adoKeys.length > 0 && adoKeys.length < 3) {
|
|
196
|
+
warnings.push(`Personal mode detected incomplete Azure DevOps settings (${adoKeys.join(", ")}); those team-only settings may be ignored.`);
|
|
197
|
+
}
|
|
198
|
+
const entraKeys = configuredKeys([
|
|
199
|
+
["ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED?.trim() === "true"],
|
|
200
|
+
["ENTRA_TENANT_ID", raw.ENTRA_TENANT_ID?.trim() || ""],
|
|
201
|
+
["ENTRA_CLIENT_ID", raw.ENTRA_CLIENT_ID?.trim() || ""],
|
|
202
|
+
["ENTRA_REQUIRED_ROLE", raw.ENTRA_REQUIRED_ROLE?.trim() || ""],
|
|
203
|
+
["ENTRA_TEAM_LEAD_ID", raw.ENTRA_TEAM_LEAD_ID?.trim() || ""],
|
|
204
|
+
]);
|
|
205
|
+
if (entraKeys.length > 0) {
|
|
206
|
+
warnings.push(`Personal mode ignores Entra auth settings (${entraKeys.join(", ")}).`);
|
|
207
|
+
}
|
|
208
|
+
const teamsKeys = configuredKeys([
|
|
209
|
+
["TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED?.trim() === "true"],
|
|
210
|
+
["TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL?.trim() || ""],
|
|
211
|
+
]);
|
|
212
|
+
if (teamsKeys.length > 0) {
|
|
213
|
+
warnings.push(`Personal mode ignores Teams notification settings (${teamsKeys.join(", ")}).`);
|
|
214
|
+
}
|
|
215
|
+
return warnings;
|
|
216
|
+
}
|
|
179
217
|
function resolveConfiguredApiToken(envToken, { apiTokenPath = API_TOKEN_PATH, exists = existsSync, readFile = readFileSync, } = {}) {
|
|
180
218
|
const trimmedEnvToken = envToken?.trim();
|
|
181
219
|
if (trimmedEnvToken) {
|
|
@@ -220,11 +258,33 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
220
258
|
const entraClientId = raw.ENTRA_CLIENT_ID?.trim() || "";
|
|
221
259
|
const entraRequiredRole = raw.ENTRA_REQUIRED_ROLE?.trim() || "";
|
|
222
260
|
const entraTeamLeadId = raw.ENTRA_TEAM_LEAD_ID?.trim() || "";
|
|
223
|
-
const
|
|
261
|
+
const rawEntraAuthEnabled = parseBooleanEnv("ENTRA_AUTH_ENABLED", raw.ENTRA_AUTH_ENABLED, false);
|
|
262
|
+
if (parsedMode === "personal" && rawEntraAuthEnabled) {
|
|
263
|
+
throw new Error("Personal mode cannot be used with ENTRA_AUTH_ENABLED=true. Set CHAPTERHOUSE_MODE=team to use Entra auth, or unset ENTRA_AUTH_ENABLED to run in unauthenticated personal mode.");
|
|
264
|
+
}
|
|
265
|
+
const parsedTeamsWebhookUrl = parseOptionalUrl("TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL);
|
|
266
|
+
const rawTeamsNotificationsEnabled = parseBooleanEnv("TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED, false);
|
|
267
|
+
const modeCompatibilityWarnings = buildModeCompatibilityWarnings(raw, parsedMode);
|
|
268
|
+
const personalModeIgnoresEntra = parsedMode === "personal" && configuredKeys([
|
|
269
|
+
["ENTRA_AUTH_ENABLED", rawEntraAuthEnabled],
|
|
270
|
+
["ENTRA_TENANT_ID", entraTenantId],
|
|
271
|
+
["ENTRA_CLIENT_ID", entraClientId],
|
|
272
|
+
["ENTRA_REQUIRED_ROLE", entraRequiredRole],
|
|
273
|
+
["ENTRA_TEAM_LEAD_ID", entraTeamLeadId],
|
|
274
|
+
]).length > 0;
|
|
275
|
+
const personalModeIgnoresTeams = parsedMode === "personal" && configuredKeys([
|
|
276
|
+
["TEAMS_NOTIFICATIONS_ENABLED", rawTeamsNotificationsEnabled],
|
|
277
|
+
["TEAMS_WEBHOOK_URL", parsedTeamsWebhookUrl],
|
|
278
|
+
]).length > 0;
|
|
279
|
+
const effectiveEntraAuthEnabled = personalModeIgnoresEntra ? false : rawEntraAuthEnabled;
|
|
280
|
+
const effectiveEntraTenantId = personalModeIgnoresEntra ? "" : entraTenantId;
|
|
281
|
+
const effectiveEntraClientId = personalModeIgnoresEntra ? "" : entraClientId;
|
|
282
|
+
const effectiveEntraRequiredRole = personalModeIgnoresEntra ? "" : entraRequiredRole;
|
|
283
|
+
const effectiveEntraTeamLeadId = personalModeIgnoresEntra ? "" : entraTeamLeadId;
|
|
284
|
+
const effectiveTeamsWebhookUrl = personalModeIgnoresTeams ? "" : parsedTeamsWebhookUrl;
|
|
285
|
+
const effectiveTeamsNotificationsEnabled = personalModeIgnoresTeams ? false : rawTeamsNotificationsEnabled;
|
|
224
286
|
const apiToken = resolveConfiguredApiToken(raw.API_TOKEN, options);
|
|
225
|
-
const standaloneMode = !
|
|
226
|
-
const teamsWebhookUrl = parseOptionalUrl("TEAMS_WEBHOOK_URL", raw.TEAMS_WEBHOOK_URL);
|
|
227
|
-
const teamsNotificationsEnabled = parseBooleanEnv("TEAMS_NOTIFICATIONS_ENABLED", raw.TEAMS_NOTIFICATIONS_ENABLED, false);
|
|
287
|
+
const standaloneMode = !effectiveEntraAuthEnabled && !apiToken;
|
|
228
288
|
const apiRateLimitWindowMs = parsePositiveIntegerEnv("API_RATE_LIMIT_WINDOW_MS", raw.API_RATE_LIMIT_WINDOW_MS, DEFAULT_API_RATE_LIMIT_WINDOW_MS);
|
|
229
289
|
const apiRateLimitGeneralMax = parsePositiveIntegerEnv("API_RATE_LIMIT_GENERAL_MAX", raw.API_RATE_LIMIT_GENERAL_MAX, DEFAULT_API_RATE_LIMIT_GENERAL_MAX);
|
|
230
290
|
const apiRateLimitAuthMax = parsePositiveIntegerEnv("API_RATE_LIMIT_AUTH_MAX", raw.API_RATE_LIMIT_AUTH_MAX, DEFAULT_API_RATE_LIMIT_AUTH_MAX);
|
|
@@ -238,10 +298,10 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
238
298
|
const memoryInboxRetentionDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS", raw.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS, 7);
|
|
239
299
|
const memoryHotRecallBoost = parsePositiveNumberEnv("CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST", raw.CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST, 1.5);
|
|
240
300
|
const memoryHotAgeDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS", raw.CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS, 30);
|
|
241
|
-
if (
|
|
301
|
+
if (effectiveEntraAuthEnabled && (!effectiveEntraTenantId || !effectiveEntraClientId)) {
|
|
242
302
|
throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
|
|
243
303
|
}
|
|
244
|
-
if (
|
|
304
|
+
if (effectiveTeamsNotificationsEnabled && !effectiveTeamsWebhookUrl) {
|
|
245
305
|
throw new Error("TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL");
|
|
246
306
|
}
|
|
247
307
|
return {
|
|
@@ -256,11 +316,11 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
256
316
|
adoProject: raw.ADO_PROJECT?.trim() || DEFAULT_ADO_PROJECT,
|
|
257
317
|
adoPat: raw.ADO_PAT?.trim() || "",
|
|
258
318
|
workerTimeoutMs: parsedWorkerTimeout,
|
|
259
|
-
entraTenantId,
|
|
260
|
-
entraClientId,
|
|
261
|
-
entraRequiredRole,
|
|
262
|
-
entraTeamLeadId,
|
|
263
|
-
entraAuthEnabled,
|
|
319
|
+
entraTenantId: effectiveEntraTenantId,
|
|
320
|
+
entraClientId: effectiveEntraClientId,
|
|
321
|
+
entraRequiredRole: effectiveEntraRequiredRole,
|
|
322
|
+
entraTeamLeadId: effectiveEntraTeamLeadId,
|
|
323
|
+
entraAuthEnabled: effectiveEntraAuthEnabled,
|
|
264
324
|
standaloneMode,
|
|
265
325
|
corsAllowedOrigins: parseCorsAllowedOrigins(raw.CORS_ALLOWED_ORIGINS),
|
|
266
326
|
copilotModel: raw.COPILOT_MODEL || DEFAULT_MODEL,
|
|
@@ -271,8 +331,8 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
271
331
|
teamWikiCacheTtlMinutes: parsedTeamWikiCacheTtlMinutes,
|
|
272
332
|
teamWikiPaths: parseTeamWikiPaths(raw.TEAM_WIKI_PATHS),
|
|
273
333
|
wikiEntityCategories: parseEntityCategories(raw.WIKI_ENTITY_CATEGORIES),
|
|
274
|
-
teamsWebhookUrl,
|
|
275
|
-
teamsNotificationsEnabled,
|
|
334
|
+
teamsWebhookUrl: effectiveTeamsWebhookUrl,
|
|
335
|
+
teamsNotificationsEnabled: effectiveTeamsNotificationsEnabled,
|
|
276
336
|
apiRateLimitWindowMs,
|
|
277
337
|
apiRateLimitGeneralMax,
|
|
278
338
|
apiRateLimitAuthMax,
|
|
@@ -295,6 +355,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
295
355
|
memoryTieringEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_TIERING_ENABLED", raw.CHAPTERHOUSE_MEMORY_TIERING_ENABLED, true),
|
|
296
356
|
memoryHotRecallBoost,
|
|
297
357
|
memoryHotAgeDays,
|
|
358
|
+
modeCompatibilityWarnings,
|
|
298
359
|
};
|
|
299
360
|
}
|
|
300
361
|
const runtimeConfig = parseRuntimeConfig(process.env);
|
|
@@ -352,6 +413,7 @@ export const config = {
|
|
|
352
413
|
memoryTieringEnabled: runtimeConfig.memoryTieringEnabled,
|
|
353
414
|
memoryHotRecallBoost: runtimeConfig.memoryHotRecallBoost,
|
|
354
415
|
memoryHotAgeDays: runtimeConfig.memoryHotAgeDays,
|
|
416
|
+
modeCompatibilityWarnings: runtimeConfig.modeCompatibilityWarnings,
|
|
355
417
|
copilotAuthToken: runtimeConfig.copilotAuthToken,
|
|
356
418
|
get copilotModel() {
|
|
357
419
|
return _copilotModel;
|
package/dist/config.test.js
CHANGED
|
@@ -14,6 +14,7 @@ test("parses Teams webhook settings", async () => {
|
|
|
14
14
|
const configModule = await import("./config.js");
|
|
15
15
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
16
16
|
const parsed = configModule.parseRuntimeConfig({
|
|
17
|
+
CHAPTERHOUSE_MODE: "team",
|
|
17
18
|
TEAMS_WEBHOOK_URL: "https://teams.example.test/webhook",
|
|
18
19
|
TEAMS_NOTIFICATIONS_ENABLED: "true",
|
|
19
20
|
});
|
|
@@ -24,6 +25,7 @@ test("rejects Teams notifications without a webhook", async () => {
|
|
|
24
25
|
const configModule = await import("./config.js");
|
|
25
26
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
26
27
|
assert.throws(() => configModule.parseRuntimeConfig({
|
|
28
|
+
CHAPTERHOUSE_MODE: "team",
|
|
27
29
|
TEAMS_NOTIFICATIONS_ENABLED: "true",
|
|
28
30
|
}), /TEAMS_NOTIFICATIONS_ENABLED=true requires TEAMS_WEBHOOK_URL/);
|
|
29
31
|
});
|
|
@@ -60,6 +62,7 @@ test("requires Entra tenant and client when auth is enabled", async () => {
|
|
|
60
62
|
const configModule = await import("./config.js");
|
|
61
63
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
62
64
|
assert.throws(() => configModule.parseRuntimeConfig({
|
|
65
|
+
CHAPTERHOUSE_MODE: "team",
|
|
63
66
|
ENTRA_AUTH_ENABLED: "true",
|
|
64
67
|
ENTRA_TENANT_ID: "tenant-id",
|
|
65
68
|
}), /ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID/);
|
|
@@ -268,4 +271,45 @@ test("rejects invalid SSE replay settings", async () => {
|
|
|
268
271
|
CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
|
|
269
272
|
}), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
|
|
270
273
|
});
|
|
274
|
+
test("personal mode rejects explicit Entra auth enablement with a clear fix suggestion", async () => {
|
|
275
|
+
const configModule = await import("./config.js");
|
|
276
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
277
|
+
assert.throws(() => configModule.parseRuntimeConfig({
|
|
278
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
279
|
+
ENTRA_AUTH_ENABLED: "true",
|
|
280
|
+
ENTRA_TENANT_ID: "tenant-id",
|
|
281
|
+
ENTRA_CLIENT_ID: "client-id",
|
|
282
|
+
}), /Personal mode cannot be used with ENTRA_AUTH_ENABLED=true[\s\S]*CHAPTERHOUSE_MODE=team[\s\S]*unset ENTRA_AUTH_ENABLED/);
|
|
283
|
+
});
|
|
284
|
+
test("personal mode still warns about leftover Entra settings when auth is not explicitly enabled", async () => {
|
|
285
|
+
const configModule = await import("./config.js");
|
|
286
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
287
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
288
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
289
|
+
ENTRA_TENANT_ID: "tenant-only",
|
|
290
|
+
});
|
|
291
|
+
assert.equal(parsed.chapterhouseMode, "personal");
|
|
292
|
+
assert.equal(parsed.entraAuthEnabled, false);
|
|
293
|
+
assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
|
|
294
|
+
});
|
|
295
|
+
test("personal mode warns and disables incomplete Teams notification settings instead of throwing", async () => {
|
|
296
|
+
const configModule = await import("./config.js");
|
|
297
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
298
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
299
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
300
|
+
TEAMS_NOTIFICATIONS_ENABLED: "true",
|
|
301
|
+
});
|
|
302
|
+
assert.equal(parsed.teamsNotificationsEnabled, false);
|
|
303
|
+
assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Teams/i);
|
|
304
|
+
});
|
|
305
|
+
test("personal mode warns when Azure DevOps settings are only partially configured", async () => {
|
|
306
|
+
const configModule = await import("./config.js");
|
|
307
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
308
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
309
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
310
|
+
ADO_PAT: "test-pat",
|
|
311
|
+
});
|
|
312
|
+
assert.equal(parsed.adoPat, "test-pat");
|
|
313
|
+
assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Azure DevOps/i);
|
|
314
|
+
});
|
|
271
315
|
//# sourceMappingURL=config.test.js.map
|
|
@@ -175,12 +175,9 @@ function scheduleHousekeeping(sessionKey, source) {
|
|
|
175
175
|
log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
void runHousekeeping({ scopeIds });
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
178
|
+
void runHousekeeping({ scopeIds }).catch((error) => {
|
|
182
179
|
log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
|
|
183
|
-
}
|
|
180
|
+
});
|
|
184
181
|
}
|
|
185
182
|
export function maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope) {
|
|
186
183
|
if (!previousScope) {
|
|
@@ -359,6 +356,17 @@ function buildHotTierContext() {
|
|
|
359
356
|
}
|
|
360
357
|
return hotTierXml.trimEnd();
|
|
361
358
|
}
|
|
359
|
+
function buildPerTurnMemoryHooks(sessionKey) {
|
|
360
|
+
if (!config.memoryInjectEnabled) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
onUserPromptSubmitted: () => {
|
|
365
|
+
const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
|
|
366
|
+
return hotTierXml ? { additionalContext: hotTierXml } : undefined;
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
362
370
|
function getSystemMessageOptions(memorySummary) {
|
|
363
371
|
return {
|
|
364
372
|
selfEditEnabled: config.selfEditEnabled,
|
|
@@ -446,7 +454,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
446
454
|
}
|
|
447
455
|
void (async () => {
|
|
448
456
|
await new Promise((resolve) => setImmediate(resolve));
|
|
449
|
-
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}.
|
|
457
|
+
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.`;
|
|
450
458
|
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
451
459
|
if (done && proactiveNotifyFn) {
|
|
452
460
|
proactiveNotifyFn(text);
|
|
@@ -507,6 +515,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
507
515
|
backgroundCompactionThreshold: 0.80,
|
|
508
516
|
bufferExhaustionThreshold: 0.95,
|
|
509
517
|
};
|
|
518
|
+
const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
|
|
510
519
|
let model = config.copilotModel;
|
|
511
520
|
let systemMessageContent;
|
|
512
521
|
let sessionMode = isProjectSession ? "project" : "default";
|
|
@@ -542,6 +551,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
542
551
|
configDir: SESSIONS_DIR,
|
|
543
552
|
streaming: true,
|
|
544
553
|
systemMessage: { content: systemMessageContent },
|
|
554
|
+
hooks: memoryHooks,
|
|
545
555
|
tools,
|
|
546
556
|
mcpServers,
|
|
547
557
|
skillDirectories,
|
|
@@ -568,6 +578,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
568
578
|
configDir: SESSIONS_DIR,
|
|
569
579
|
streaming: true,
|
|
570
580
|
systemMessage: { content: systemMessageContent },
|
|
581
|
+
hooks: memoryHooks,
|
|
571
582
|
tools,
|
|
572
583
|
mcpServers,
|
|
573
584
|
skillDirectories,
|
|
@@ -3,8 +3,12 @@ import test from "node:test";
|
|
|
3
3
|
import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
|
|
4
4
|
function createFakeClient(state) {
|
|
5
5
|
class FakeSession {
|
|
6
|
+
options;
|
|
6
7
|
sessionId = "session-123";
|
|
7
8
|
listeners = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
8
12
|
on(eventName, handler) {
|
|
9
13
|
const handlers = this.listeners.get(eventName) || [];
|
|
10
14
|
handlers.push(handler);
|
|
@@ -20,6 +24,9 @@ function createFakeClient(state) {
|
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
async sendAndWait(request, _timeoutMs) {
|
|
27
|
+
const hooks = this.options.hooks;
|
|
28
|
+
const hookResult = await hooks?.onUserPromptSubmitted?.({ prompt: request.prompt }, { sessionId: this.sessionId });
|
|
29
|
+
state.promptMemoryContexts.push(hookResult?.additionalContext);
|
|
23
30
|
state.sessionPrompts.push(request);
|
|
24
31
|
if (state.sendResult === "__PENDING__") {
|
|
25
32
|
return await new Promise((_resolve, reject) => {
|
|
@@ -57,7 +64,7 @@ function createFakeClient(state) {
|
|
|
57
64
|
if (state.createSessionError) {
|
|
58
65
|
throw new Error(state.createSessionError);
|
|
59
66
|
}
|
|
60
|
-
const session = new FakeSession();
|
|
67
|
+
const session = new FakeSession(options);
|
|
61
68
|
state.lastSession = {
|
|
62
69
|
emit: (eventName, data) => session.emit(eventName, data),
|
|
63
70
|
};
|
|
@@ -108,6 +115,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
108
115
|
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
109
116
|
],
|
|
110
117
|
sendResult: "Finished successfully",
|
|
118
|
+
promptMemoryContexts: [],
|
|
111
119
|
taskEvents: new Map(),
|
|
112
120
|
projectRegistry: {},
|
|
113
121
|
resolveProjectArgs: [],
|
|
@@ -155,7 +163,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
155
163
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
156
164
|
getHotTierEntries: (scopeId) => ({
|
|
157
165
|
scope: scopeId !== undefined
|
|
158
|
-
?
|
|
166
|
+
? scopeId === state.activeScope?.id
|
|
167
|
+
? state.activeScope
|
|
168
|
+
: makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
|
|
159
169
|
: state.activeScope ?? null,
|
|
160
170
|
entities: [],
|
|
161
171
|
observations: [],
|
|
@@ -526,6 +536,43 @@ test("initOrchestrator passes hot-tier XML into the orchestrator system prompt w
|
|
|
526
536
|
assert.match(hotTierXml, /<decision id="decision-1">hi<\/decision>/);
|
|
527
537
|
assert.doesNotMatch(hotTierXml, /<memory_context[^>]*>[\s\S]*<memory_context\b/);
|
|
528
538
|
});
|
|
539
|
+
test("orchestrator refreshes hot-tier memory context for each assistant turn", async (t) => {
|
|
540
|
+
const firstMemoryContext = [
|
|
541
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:00:00.000Z\">",
|
|
542
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
543
|
+
"</memory_context>",
|
|
544
|
+
].join("\n");
|
|
545
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
546
|
+
hotTierXml: firstMemoryContext,
|
|
547
|
+
hotTierByScope: new Map([["chapterhouse", firstMemoryContext]]),
|
|
548
|
+
});
|
|
549
|
+
await orchestrator.initOrchestrator(client);
|
|
550
|
+
await new Promise((resolve) => {
|
|
551
|
+
orchestrator.sendToOrchestrator("first turn", { type: "background" }, (text, done) => {
|
|
552
|
+
if (done)
|
|
553
|
+
resolve(text);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
const secondMemoryContext = [
|
|
557
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:01:00.000Z\">",
|
|
558
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
559
|
+
" <observation id=\"observation-2\">Checkpoint wrote this between turns</observation>",
|
|
560
|
+
"</memory_context>",
|
|
561
|
+
].join("\n");
|
|
562
|
+
state.hotTierXml = secondMemoryContext;
|
|
563
|
+
state.hotTierByScope?.set("chapterhouse", secondMemoryContext);
|
|
564
|
+
await new Promise((resolve) => {
|
|
565
|
+
orchestrator.sendToOrchestrator("second turn", { type: "background" }, (text, done) => {
|
|
566
|
+
if (done)
|
|
567
|
+
resolve(text);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
assert.equal(state.createSessionCalls.length, 1, "same SDK session should handle both turns");
|
|
571
|
+
assert.equal(state.promptMemoryContexts.length, 2);
|
|
572
|
+
assert.match(state.promptMemoryContexts[0] ?? "", /Initial hot memory/);
|
|
573
|
+
assert.doesNotMatch(state.promptMemoryContexts[0] ?? "", /Checkpoint wrote this between turns/);
|
|
574
|
+
assert.match(state.promptMemoryContexts[1] ?? "", /Checkpoint wrote this between turns/);
|
|
575
|
+
});
|
|
529
576
|
test("initOrchestrator omits hot-tier XML when no active-scope memory is available", async (t) => {
|
|
530
577
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
531
578
|
hotTierXml: "",
|
|
@@ -1194,7 +1241,7 @@ test("feedAgentResult emits an attributed short agent reply before starting the
|
|
|
1194
1241
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1195
1242
|
assert.equal(await notified, "Agent complete");
|
|
1196
1243
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1197
|
-
prompt: "[Agent task completed] @coder finished task task-9.
|
|
1244
|
+
prompt: "[Agent task completed] @coder finished task task-9. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.",
|
|
1198
1245
|
}]);
|
|
1199
1246
|
assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
|
|
1200
1247
|
const started = events.filter((event) => event.type === "turn:started");
|
package/dist/copilot/router.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { getState, setState } from "../store/db.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
2
3
|
import { classifyWithLLM } from "./classifier.js";
|
|
3
4
|
import { childLogger } from "../util/logger.js";
|
|
4
5
|
const log = childLogger("router");
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Default configuration
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
9
|
+
const STANDARD_MODEL = "claude-sonnet-4.6";
|
|
10
|
+
const PREMIUM_MODEL = "claude-opus-4.6";
|
|
8
11
|
const DEFAULT_CONFIG = {
|
|
9
|
-
enabled:
|
|
12
|
+
enabled: config.chapterhouseMode === "personal",
|
|
10
13
|
tierModels: {
|
|
11
14
|
fast: "gpt-4.1",
|
|
12
|
-
standard:
|
|
13
|
-
premium:
|
|
15
|
+
standard: STANDARD_MODEL,
|
|
16
|
+
premium: PREMIUM_MODEL,
|
|
14
17
|
},
|
|
15
18
|
overrides: [
|
|
16
19
|
{
|
|
@@ -19,11 +22,32 @@ const DEFAULT_CONFIG = {
|
|
|
19
22
|
"design", "ui", "ux", "css", "layout", "styling", "visual",
|
|
20
23
|
"mockup", "wireframe", "frontend design", "tailwind", "responsive",
|
|
21
24
|
],
|
|
22
|
-
model:
|
|
25
|
+
model: PREMIUM_MODEL,
|
|
23
26
|
},
|
|
24
27
|
],
|
|
25
28
|
cooldownMessages: 2,
|
|
26
29
|
};
|
|
30
|
+
function cloneRouterConfig(routerConfig) {
|
|
31
|
+
return {
|
|
32
|
+
...routerConfig,
|
|
33
|
+
tierModels: { ...routerConfig.tierModels },
|
|
34
|
+
overrides: routerConfig.overrides.map((rule) => ({
|
|
35
|
+
...rule,
|
|
36
|
+
keywords: [...rule.keywords],
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function getDefaultRouterConfig(hasStoredConfig) {
|
|
41
|
+
const defaults = cloneRouterConfig(DEFAULT_CONFIG);
|
|
42
|
+
if (config.chapterhouseMode === "personal" && !hasStoredConfig) {
|
|
43
|
+
defaults.tierModels.premium = defaults.tierModels.standard;
|
|
44
|
+
defaults.overrides = defaults.overrides.map((rule) => ({
|
|
45
|
+
...rule,
|
|
46
|
+
model: rule.model === PREMIUM_MODEL ? defaults.tierModels.standard : rule.model,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
return defaults;
|
|
50
|
+
}
|
|
27
51
|
// ---------------------------------------------------------------------------
|
|
28
52
|
// Module-level state
|
|
29
53
|
// ---------------------------------------------------------------------------
|
|
@@ -51,18 +75,29 @@ function wordMatch(text, keyword) {
|
|
|
51
75
|
// ---------------------------------------------------------------------------
|
|
52
76
|
export function getRouterConfig() {
|
|
53
77
|
const stored = getState("router_config");
|
|
78
|
+
const defaults = getDefaultRouterConfig(stored !== undefined);
|
|
54
79
|
if (stored) {
|
|
55
80
|
try {
|
|
56
|
-
|
|
81
|
+
const parsed = JSON.parse(stored);
|
|
82
|
+
return {
|
|
83
|
+
...defaults,
|
|
84
|
+
...parsed,
|
|
85
|
+
tierModels: {
|
|
86
|
+
...defaults.tierModels,
|
|
87
|
+
...(parsed.tierModels ?? {}),
|
|
88
|
+
},
|
|
89
|
+
overrides: parsed.overrides ?? defaults.overrides,
|
|
90
|
+
};
|
|
57
91
|
}
|
|
58
92
|
catch {
|
|
59
|
-
return
|
|
93
|
+
return defaults;
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
|
-
return
|
|
96
|
+
return defaults;
|
|
63
97
|
}
|
|
64
98
|
export function updateRouterConfig(partial) {
|
|
65
|
-
const
|
|
99
|
+
const hasStoredConfig = getState("router_config") !== undefined;
|
|
100
|
+
const current = hasStoredConfig ? getRouterConfig() : cloneRouterConfig(DEFAULT_CONFIG);
|
|
66
101
|
const merged = {
|
|
67
102
|
...current,
|
|
68
103
|
...partial,
|