chapterhouse 0.5.1 → 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 +5 -3
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +44 -0
- package/dist/copilot/orchestrator.js +2 -5
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +30 -18
- package/dist/copilot/tools.js +4 -2
- 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/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +95 -3
- package/dist/memory/inbox.test.js +178 -0
- 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/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/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";
|
|
@@ -34,6 +35,7 @@ import { childLogger } from "../util/logger.js";
|
|
|
34
35
|
import { getActiveScope } from "../memory/active-scope.js";
|
|
35
36
|
import { createScope, getScope } from "../memory/scopes.js";
|
|
36
37
|
const log = childLogger("server");
|
|
38
|
+
const modeContext = new ModeContext(config);
|
|
37
39
|
void searchIndex; // re-exported by index-manager; reference here documents the dep
|
|
38
40
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
41
|
// Built SPA bundle (web/dist/), shipped alongside dist/
|
|
@@ -148,7 +150,7 @@ catch (err) {
|
|
|
148
150
|
log.error({ err: err instanceof Error ? err.message : String(err) }, "Auth token error");
|
|
149
151
|
process.exit(1);
|
|
150
152
|
}
|
|
151
|
-
if (config.standaloneMode) {
|
|
153
|
+
if (modeContext.isPersonal() && config.standaloneMode) {
|
|
152
154
|
log.warn("Running without authentication — team features disabled");
|
|
153
155
|
}
|
|
154
156
|
function isLoopbackHostname(hostname) {
|
|
@@ -262,7 +264,7 @@ function assertValidPagePath(path) {
|
|
|
262
264
|
}
|
|
263
265
|
}
|
|
264
266
|
function getWikiPageScope(path) {
|
|
265
|
-
return teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
267
|
+
return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
266
268
|
}
|
|
267
269
|
function getEmptyWikiWelcomeContent(today = new Date()) {
|
|
268
270
|
return `---
|
|
@@ -1027,7 +1029,7 @@ app.put("/api/projects/:slug/rules/soft", async (req, res) => {
|
|
|
1027
1029
|
app.get("/api/wiki/pages", async (req, res) => {
|
|
1028
1030
|
ensureWikiStructure();
|
|
1029
1031
|
// Sync team wiki pages if connected, using the caller's auth token
|
|
1030
|
-
if (teamWikiSync.isEnabled()) {
|
|
1032
|
+
if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
|
|
1031
1033
|
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1032
1034
|
? req.headers.authorization
|
|
1033
1035
|
: undefined;
|
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) {
|
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,
|
|
@@ -13,6 +13,13 @@ async function loadRouterModule(t, options = {}) {
|
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
15
|
});
|
|
16
|
+
t.mock.module("../config.js", {
|
|
17
|
+
namedExports: {
|
|
18
|
+
config: {
|
|
19
|
+
chapterhouseMode: options.mode ?? "personal",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
16
23
|
t.mock.module("./classifier.js", {
|
|
17
24
|
namedExports: {
|
|
18
25
|
classifyWithLLM: async (_client, prompt) => await (options.classify?.(prompt) ?? null),
|
|
@@ -21,16 +28,14 @@ async function loadRouterModule(t, options = {}) {
|
|
|
21
28
|
const router = await import(new URL(`./router.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
22
29
|
return { router, state };
|
|
23
30
|
}
|
|
24
|
-
test("router config
|
|
25
|
-
const { router
|
|
26
|
-
storedConfig: "{not-json}",
|
|
27
|
-
});
|
|
31
|
+
test("router config defaults personal-mode auto-routing to standard-cost routes before opt-in", async (t) => {
|
|
32
|
+
const { router } = await loadRouterModule(t, { mode: "personal" });
|
|
28
33
|
assert.deepEqual(router.getRouterConfig(), {
|
|
29
|
-
enabled:
|
|
34
|
+
enabled: true,
|
|
30
35
|
tierModels: {
|
|
31
36
|
fast: "gpt-4.1",
|
|
32
37
|
standard: "claude-sonnet-4.6",
|
|
33
|
-
premium: "claude-
|
|
38
|
+
premium: "claude-sonnet-4.6",
|
|
34
39
|
},
|
|
35
40
|
overrides: [
|
|
36
41
|
{
|
|
@@ -39,19 +44,28 @@ test("router config falls back safely and deep-merges tier model updates", async
|
|
|
39
44
|
"design", "ui", "ux", "css", "layout", "styling", "visual",
|
|
40
45
|
"mockup", "wireframe", "frontend design", "tailwind", "responsive",
|
|
41
46
|
],
|
|
42
|
-
model: "claude-
|
|
47
|
+
model: "claude-sonnet-4.6",
|
|
43
48
|
},
|
|
44
49
|
],
|
|
45
50
|
cooldownMessages: 2,
|
|
46
51
|
});
|
|
52
|
+
});
|
|
53
|
+
test("router config keeps auto-routing off by default in team mode", async (t) => {
|
|
54
|
+
const { router } = await loadRouterModule(t, { mode: "team" });
|
|
55
|
+
assert.equal(router.getRouterConfig().enabled, false);
|
|
56
|
+
});
|
|
57
|
+
test("saving router config in personal mode opts into premium defaults and deep-merges tier model updates", async (t) => {
|
|
58
|
+
const { router, state } = await loadRouterModule(t, { mode: "personal" });
|
|
59
|
+
const saved = router.updateRouterConfig({ enabled: true });
|
|
60
|
+
assert.equal(saved.enabled, true);
|
|
61
|
+
assert.equal(saved.tierModels.premium, "claude-opus-4.6");
|
|
62
|
+
assert.equal(saved.overrides[0]?.model, "claude-opus-4.6");
|
|
47
63
|
const updated = router.updateRouterConfig({
|
|
48
|
-
enabled: true,
|
|
49
64
|
tierModels: {
|
|
50
|
-
...
|
|
65
|
+
...saved.tierModels,
|
|
51
66
|
premium: "gpt-5.5",
|
|
52
67
|
},
|
|
53
68
|
});
|
|
54
|
-
assert.equal(updated.enabled, true);
|
|
55
69
|
assert.deepEqual(updated.tierModels, {
|
|
56
70
|
fast: "gpt-4.1",
|
|
57
71
|
standard: "claude-sonnet-4.6",
|
|
@@ -60,7 +74,7 @@ test("router config falls back safely and deep-merges tier model updates", async
|
|
|
60
74
|
assert.equal(JSON.parse(state.get("router_config") || "{}").tierModels.premium, "gpt-5.5");
|
|
61
75
|
});
|
|
62
76
|
test("resolveModel stays in manual mode when the router is disabled", async (t) => {
|
|
63
|
-
const { router } = await loadRouterModule(t);
|
|
77
|
+
const { router } = await loadRouterModule(t, { mode: "team" });
|
|
64
78
|
const result = await router.resolveModel("Ship it", "claude-sonnet-4.6", []);
|
|
65
79
|
assert.deepEqual(result, {
|
|
66
80
|
model: "claude-sonnet-4.6",
|
|
@@ -69,28 +83,26 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
|
|
|
69
83
|
routerMode: "manual",
|
|
70
84
|
});
|
|
71
85
|
});
|
|
72
|
-
test("resolveModel applies overrides
|
|
86
|
+
test("resolveModel applies safe design overrides before personal-mode opt-in and ignores partial-word matches", async (t) => {
|
|
73
87
|
const { router } = await loadRouterModule(t, {
|
|
74
88
|
classify: async () => "fast",
|
|
75
89
|
});
|
|
76
|
-
router.updateRouterConfig({ enabled: true });
|
|
77
90
|
const override = await router.resolveModel("Need a UI refresh", "gpt-4.1", [], {});
|
|
78
91
|
const noOverride = await router.resolveModel("Fruit salad", "gpt-4.1", [], {});
|
|
79
92
|
assert.equal(override.overrideName, "design");
|
|
80
|
-
assert.equal(override.model, "claude-
|
|
93
|
+
assert.equal(override.model, "claude-sonnet-4.6");
|
|
81
94
|
assert.equal(override.switched, true);
|
|
82
95
|
assert.equal(noOverride.overrideName, undefined);
|
|
83
96
|
assert.equal(noOverride.model, "gpt-4.1");
|
|
84
97
|
assert.equal(noOverride.tier, "fast");
|
|
85
98
|
});
|
|
86
|
-
test("short follow-ups inherit the previous tier", async (t) => {
|
|
99
|
+
test("short follow-ups inherit the previous tier without forcing premium before opt-in", async (t) => {
|
|
87
100
|
const { router } = await loadRouterModule(t);
|
|
88
|
-
router.updateRouterConfig({ enabled: true });
|
|
89
101
|
const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
|
|
90
102
|
assert.deepEqual(result, {
|
|
91
|
-
model: "claude-
|
|
103
|
+
model: "claude-sonnet-4.6",
|
|
92
104
|
tier: "premium",
|
|
93
|
-
switched:
|
|
105
|
+
switched: false,
|
|
94
106
|
routerMode: "auto",
|
|
95
107
|
});
|
|
96
108
|
});
|
package/dist/copilot/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
|
+
import { ModeContext } from "../mode-context.js";
|
|
9
10
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
10
11
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
11
12
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
@@ -31,6 +32,7 @@ import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
|
31
32
|
import { childLogger } from "../util/logger.js";
|
|
32
33
|
import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
33
34
|
const log = childLogger("tools");
|
|
35
|
+
const modeContext = new ModeContext(config);
|
|
34
36
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
37
|
function yamlEscape(value) {
|
|
36
38
|
// Always quote and escape backslashes, double quotes, and newlines.
|
|
@@ -536,7 +538,7 @@ export function createTools(deps) {
|
|
|
536
538
|
notes: z.string().optional().describe("Optional notes about the work"),
|
|
537
539
|
}),
|
|
538
540
|
handler: async (args) => {
|
|
539
|
-
if (
|
|
541
|
+
if (!modeContext.canLogToAdo()) {
|
|
540
542
|
return "OKR progress logging is only available from personal Chapterhouse instances.";
|
|
541
543
|
}
|
|
542
544
|
const mapper = createOKRMapper();
|
|
@@ -1246,7 +1248,7 @@ export function createTools(deps) {
|
|
|
1246
1248
|
if (args.scope_slug && !requestedScope) {
|
|
1247
1249
|
return `Unknown memory scope '${args.scope_slug}'.`;
|
|
1248
1250
|
}
|
|
1249
|
-
const result = runHousekeeping({
|
|
1251
|
+
const result = await runHousekeeping({
|
|
1250
1252
|
scopeIds: requestedScope ? [requestedScope.id] : undefined,
|
|
1251
1253
|
allScopes: args.all_scopes,
|
|
1252
1254
|
passes: args.passes,
|
package/dist/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { stopEpisodeWriter } from "./copilot/episode-writer.js";
|
|
|
4
4
|
import { startApiServer, broadcastToSSE } from "./api/server.js";
|
|
5
5
|
import { getDb, closeDb, getState } from "./store/db.js";
|
|
6
6
|
import { config } from "./config.js";
|
|
7
|
+
import { ModeContext } from "./mode-context.js";
|
|
7
8
|
import { spawn } from "child_process";
|
|
8
9
|
import { readdirSync, statSync, rmSync } from "fs";
|
|
9
10
|
import { join } from "path";
|
|
@@ -21,6 +22,7 @@ import { CHAPTERHOUSE_VERSION } from "./version.js";
|
|
|
21
22
|
import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
|
|
22
23
|
import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
|
|
23
24
|
const log = logger.child({ module: "daemon" });
|
|
25
|
+
const modeContext = new ModeContext(config);
|
|
24
26
|
let memoryHousekeepingScheduler;
|
|
25
27
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
26
28
|
/**
|
|
@@ -86,6 +88,9 @@ async function main() {
|
|
|
86
88
|
if (config.selfEditEnabled) {
|
|
87
89
|
log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
|
|
88
90
|
}
|
|
91
|
+
for (const warning of config.modeCompatibilityWarnings) {
|
|
92
|
+
log.warn({ mode: config.chapterhouseMode }, warning);
|
|
93
|
+
}
|
|
89
94
|
// Set up message logging to daemon console
|
|
90
95
|
setMessageLogger((direction, source, text) => {
|
|
91
96
|
const arrow = direction === "in" ? "⟶" : "⟵";
|
|
@@ -100,7 +105,7 @@ async function main() {
|
|
|
100
105
|
if (wikiIsNew) {
|
|
101
106
|
log.info("Created wiki");
|
|
102
107
|
}
|
|
103
|
-
if (
|
|
108
|
+
if (modeContext.isTeam()) {
|
|
104
109
|
const seed = seedTeamWiki();
|
|
105
110
|
if (seed.created.length > 0) {
|
|
106
111
|
log.info({ pages: seed.created }, "Seeded team wiki pages");
|
|
@@ -153,7 +158,7 @@ async function main() {
|
|
|
153
158
|
await startApiServer();
|
|
154
159
|
memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
|
|
155
160
|
memoryHousekeepingScheduler.start();
|
|
156
|
-
if (
|
|
161
|
+
if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
|
|
157
162
|
new StandupScheduler().schedule();
|
|
158
163
|
}
|
|
159
164
|
const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { ModeContext } from "../mode-context.js";
|
|
2
3
|
export class TeamPushClient {
|
|
3
4
|
teamChapterhouseUrl;
|
|
4
5
|
teamChapterhouseToken;
|
|
@@ -6,6 +7,7 @@ export class TeamPushClient {
|
|
|
6
7
|
fetchImpl;
|
|
7
8
|
getAuthorizationHeader;
|
|
8
9
|
getCurrentUser;
|
|
10
|
+
modeContext;
|
|
9
11
|
constructor(options = {}) {
|
|
10
12
|
this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
|
|
11
13
|
this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
|
|
@@ -13,6 +15,11 @@ export class TeamPushClient {
|
|
|
13
15
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
14
16
|
this.getAuthorizationHeader = options.getAuthorizationHeader;
|
|
15
17
|
this.getCurrentUser = options.getCurrentUser;
|
|
18
|
+
this.modeContext = new ModeContext({
|
|
19
|
+
...config,
|
|
20
|
+
teamChapterhouseUrl: this.teamChapterhouseUrl,
|
|
21
|
+
standaloneMode: this.standaloneMode,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
async pushUpdate(payload) {
|
|
18
25
|
if (!this.isEnabled()) {
|
|
@@ -129,7 +136,7 @@ export class TeamPushClient {
|
|
|
129
136
|
throw new Error("Failed to push OKR update: no authenticated engineer identity is available");
|
|
130
137
|
}
|
|
131
138
|
isEnabled() {
|
|
132
|
-
return
|
|
139
|
+
return this.modeContext.canSyncTeamWiki();
|
|
133
140
|
}
|
|
134
141
|
}
|
|
135
142
|
function describeHttpFailure(status) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { ModeContext } from "../mode-context.js";
|
|
2
3
|
import { childLogger } from "../util/logger.js";
|
|
3
4
|
const log = childLogger("teams-notify");
|
|
4
5
|
export const TEAMS_MILESTONE_THRESHOLDS = [25, 50, 75, 100];
|
|
@@ -8,11 +9,17 @@ export class TeamsNotifier {
|
|
|
8
9
|
enabled;
|
|
9
10
|
fetchImpl;
|
|
10
11
|
warn;
|
|
12
|
+
modeContext;
|
|
11
13
|
constructor(options = {}) {
|
|
12
14
|
this.webhookUrl = (options.webhookUrl ?? config.teamsWebhookUrl).trim();
|
|
13
15
|
this.enabled = options.enabled ?? config.teamsNotificationsEnabled;
|
|
14
16
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
15
17
|
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
18
|
+
this.modeContext = new ModeContext({
|
|
19
|
+
...config,
|
|
20
|
+
teamsWebhookUrl: this.webhookUrl,
|
|
21
|
+
teamsNotificationsEnabled: this.enabled,
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
async sendMessage(title, body, color = DEFAULT_COLOR) {
|
|
18
25
|
return await this.postCard({
|
|
@@ -61,7 +68,7 @@ export class TeamsNotifier {
|
|
|
61
68
|
});
|
|
62
69
|
}
|
|
63
70
|
async postCard(card) {
|
|
64
|
-
if (!this.
|
|
71
|
+
if (!this.modeContext.canSyncToTeams()) {
|
|
65
72
|
this.warn("[teams] Teams notifications are disabled or TEAMS_WEBHOOK_URL is empty.");
|
|
66
73
|
return false;
|
|
67
74
|
}
|