chapterhouse 0.4.3 → 0.5.1
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 +65 -2
- package/dist/api/server.test.js +63 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +13 -2
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +146 -29
- package/dist/copilot/orchestrator.test.js +232 -14
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +149 -13
- package/dist/copilot/tools.memory.test.js +139 -2
- package/dist/memory/active-scope.js +9 -0
- 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.test.js +20 -13
- package/dist/memory/index.js +1 -1
- package/dist/memory/scopes.test.js +0 -24
- package/dist/store/db.js +27 -19
- package/package.json +1 -1
- package/web/dist/assets/{index-D4-uRAi6.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/index-BfHqP3-C.js.map +1 -0
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BTI_m0OE.css +0 -10
- package/web/dist/assets/index-D4-uRAi6.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -5,9 +5,9 @@ import { existsSync } from "fs";
|
|
|
5
5
|
import { join, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
|
|
8
|
+
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
|
|
9
9
|
import { agentEventBus } from "../copilot/agent-event-bus.js";
|
|
10
|
-
import { getAgentRegistry } from "../copilot/agents.js";
|
|
10
|
+
import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
12
12
|
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
13
13
|
import { searchIndex, parseIndex } from "../wiki/index-manager.js";
|
|
@@ -32,6 +32,7 @@ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfig
|
|
|
32
32
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
33
33
|
import { childLogger } from "../util/logger.js";
|
|
34
34
|
import { getActiveScope } from "../memory/active-scope.js";
|
|
35
|
+
import { createScope, getScope } from "../memory/scopes.js";
|
|
35
36
|
const log = childLogger("server");
|
|
36
37
|
void searchIndex; // re-exported by index-manager; reference here documents the dep
|
|
37
38
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -81,6 +82,12 @@ const projectCreateSchema = z.object({
|
|
|
81
82
|
cwd: requiredString("Missing 'cwd' in request body")
|
|
82
83
|
.refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
|
|
83
84
|
}).strict();
|
|
85
|
+
const scopeCreateSchema = z.object({
|
|
86
|
+
slug: requiredString("Missing 'slug' in request body")
|
|
87
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
|
|
88
|
+
title: requiredString("Missing 'title' in request body"),
|
|
89
|
+
description: z.string().optional(),
|
|
90
|
+
}).strict();
|
|
84
91
|
const projectHardRulesSchema = z.object({
|
|
85
92
|
hardRules: z.object({
|
|
86
93
|
auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
|
|
@@ -313,6 +320,33 @@ app.get("/health", handleHealth);
|
|
|
313
320
|
app.get("/api/agents", (_req, res) => {
|
|
314
321
|
res.json(getAgentInfo());
|
|
315
322
|
});
|
|
323
|
+
app.get("/api/channels", (_req, res) => {
|
|
324
|
+
let agents = getAgentRegistry();
|
|
325
|
+
if (agents.length === 0) {
|
|
326
|
+
ensureDefaultAgents();
|
|
327
|
+
agents = loadAgents();
|
|
328
|
+
}
|
|
329
|
+
const persistentAgentChannels = agents
|
|
330
|
+
.filter((agent) => agent.persistent)
|
|
331
|
+
.map((agent) => ({
|
|
332
|
+
key: `agent:${agent.slug}`,
|
|
333
|
+
label: `# ${agent.slug}`,
|
|
334
|
+
slug: agent.slug,
|
|
335
|
+
name: agent.name,
|
|
336
|
+
description: agent.description,
|
|
337
|
+
...(agent.scope ? { scope: agent.scope } : {}),
|
|
338
|
+
}))
|
|
339
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
340
|
+
res.json([
|
|
341
|
+
{
|
|
342
|
+
key: "default",
|
|
343
|
+
label: "# chapterhouse",
|
|
344
|
+
name: "Chapterhouse",
|
|
345
|
+
description: "Orchestrator",
|
|
346
|
+
},
|
|
347
|
+
...persistentAgentChannels,
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
316
350
|
// List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
|
|
317
351
|
// dispatched subagents remain visible after they finish, not just in-flight ones.
|
|
318
352
|
app.get("/api/workers", (_req, res) => {
|
|
@@ -614,6 +648,16 @@ app.post("/api/cancel", async (_req, res) => {
|
|
|
614
648
|
}
|
|
615
649
|
res.json({ status: "ok", cancelled });
|
|
616
650
|
});
|
|
651
|
+
// Cancel the active turn for one session key without touching other channels.
|
|
652
|
+
app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
|
|
653
|
+
const sessionKey = Array.isArray(req.params.sessionKey)
|
|
654
|
+
? req.params.sessionKey[0]
|
|
655
|
+
: req.params.sessionKey;
|
|
656
|
+
if (!sessionKey)
|
|
657
|
+
throw new BadRequestError("Missing sessionKey");
|
|
658
|
+
const cancelled = await interruptSessionTurn(sessionKey);
|
|
659
|
+
res.json({ status: "ok", cancelled });
|
|
660
|
+
});
|
|
617
661
|
// Interrupt the active turn on a specific session and start a replacement turn.
|
|
618
662
|
// POST /api/sessions/:sessionKey/interrupt
|
|
619
663
|
// Body: { prompt, connectionId, attachments? }
|
|
@@ -855,6 +899,25 @@ app.get("/api/memory/active-scope", (_req, res) => {
|
|
|
855
899
|
title: activeScope.title,
|
|
856
900
|
});
|
|
857
901
|
});
|
|
902
|
+
app.post("/api/scopes", (req, res) => {
|
|
903
|
+
const body = parseRequest(scopeCreateSchema, req.body ?? {});
|
|
904
|
+
if (getScope(body.slug)) {
|
|
905
|
+
res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const scope = createScope({
|
|
909
|
+
slug: body.slug,
|
|
910
|
+
title: body.title,
|
|
911
|
+
description: body.description ?? "",
|
|
912
|
+
keywords: [body.slug],
|
|
913
|
+
});
|
|
914
|
+
res.status(201).json({
|
|
915
|
+
slug: scope.slug,
|
|
916
|
+
title: scope.title,
|
|
917
|
+
description: scope.description,
|
|
918
|
+
active: scope.active,
|
|
919
|
+
});
|
|
920
|
+
});
|
|
858
921
|
app.post("/api/auto", (req, res) => {
|
|
859
922
|
const body = parseRequest(autoRequestSchema, req.body ?? {});
|
|
860
923
|
const updated = updateRouterConfig(body);
|
package/dist/api/server.test.js
CHANGED
|
@@ -207,6 +207,21 @@ test("server routes expose bootstrap and public config without auth", async () =
|
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
|
+
test("server channels route returns chapterhouse plus persistent agents in channel order", async () => {
|
|
211
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
212
|
+
const response = await fetch(`${baseUrl}/api/channels`, {
|
|
213
|
+
headers: { authorization: authHeader },
|
|
214
|
+
});
|
|
215
|
+
assert.equal(response.status, 200);
|
|
216
|
+
const channels = await response.json();
|
|
217
|
+
assert.deepEqual(channels.map((channel) => channel.key), [
|
|
218
|
+
"default",
|
|
219
|
+
]);
|
|
220
|
+
assert.deepEqual(channels.map((channel) => channel.label), [
|
|
221
|
+
"# chapterhouse",
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
210
225
|
test("server runs in standalone mode without auth", async () => {
|
|
211
226
|
await withStartedServer(async ({ baseUrl }) => {
|
|
212
227
|
const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
|
|
@@ -247,6 +262,54 @@ test("server exposes the active memory scope API and requires auth", async () =>
|
|
|
247
262
|
});
|
|
248
263
|
});
|
|
249
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
|
+
});
|
|
250
313
|
test("server bootstrap rejects non-loopback origins", async () => {
|
|
251
314
|
await withStartedServer(async ({ baseUrl }) => {
|
|
252
315
|
const response = await fetch(`${baseUrl}/api/bootstrap`, {
|
|
@@ -358,4 +358,16 @@ test("turn-sse: turnId returned by POST matches turnId in all SSE events for tha
|
|
|
358
358
|
}
|
|
359
359
|
}, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
|
|
360
360
|
});
|
|
361
|
+
test("turn-sse: POST /api/session/:sessionKey/interrupt returns per-session cancel status", async () => {
|
|
362
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
363
|
+
const res = await fetch(`${baseUrl}/api/session/test-session-interrupt/interrupt`, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: { Authorization: authHeader },
|
|
366
|
+
});
|
|
367
|
+
const bodyText = await res.text();
|
|
368
|
+
assert.equal(res.status, 200, `POST /interrupt returned ${res.status}: ${bodyText}`);
|
|
369
|
+
const body = JSON.parse(bodyText);
|
|
370
|
+
assert.deepEqual(body, { status: "ok", cancelled: false });
|
|
371
|
+
}, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
|
|
372
|
+
});
|
|
361
373
|
//# sourceMappingURL=turn-sse.integration.test.js.map
|
package/dist/copilot/agents.js
CHANGED
|
@@ -23,6 +23,12 @@ const agentFrontmatterSchema = z.object({
|
|
|
23
23
|
tools: z.array(z.string()).optional(),
|
|
24
24
|
mcpServers: z.array(z.string()).optional(),
|
|
25
25
|
allowed_paths: z.array(z.string()).optional(),
|
|
26
|
+
persistent: z.union([z.boolean(), z.string()]).optional().transform((value) => {
|
|
27
|
+
if (typeof value === "string")
|
|
28
|
+
return value.toLowerCase() === "true";
|
|
29
|
+
return value;
|
|
30
|
+
}),
|
|
31
|
+
scope: z.string().optional(),
|
|
26
32
|
});
|
|
27
33
|
// ---------------------------------------------------------------------------
|
|
28
34
|
// Agent Registry
|
|
@@ -77,6 +83,8 @@ export function parseAgentMd(content, slug) {
|
|
|
77
83
|
name: fm.name,
|
|
78
84
|
description: fm.description,
|
|
79
85
|
model: fm.model,
|
|
86
|
+
persistent: fm.persistent,
|
|
87
|
+
scope: fm.scope,
|
|
80
88
|
skills: fm.skills,
|
|
81
89
|
tools: fm.tools,
|
|
82
90
|
mcpServers: fm.mcpServers,
|
|
@@ -293,10 +301,13 @@ export function getCurrentToolAgentSlug() {
|
|
|
293
301
|
export function getCurrentToolTaskId() {
|
|
294
302
|
return toolTaskContext.getStore();
|
|
295
303
|
}
|
|
304
|
+
export function withToolTaskContext(taskId, fn) {
|
|
305
|
+
return toolTaskContext.run(taskId, fn);
|
|
306
|
+
}
|
|
296
307
|
export function bindToolsToAgent(agentSlug, allTools, taskId) {
|
|
297
308
|
return allTools.map((tool) => ({
|
|
298
309
|
...tool,
|
|
299
|
-
handler: (args, invocation) => toolAgentContext.run(agentSlug, () => toolTaskContext.run(taskId, () => tool.handler(args, invocation))),
|
|
310
|
+
handler: (args, invocation) => toolAgentContext.run(agentSlug, () => toolTaskContext.run(taskId ?? getCurrentToolTaskId(), () => tool.handler(args, invocation))),
|
|
300
311
|
}));
|
|
301
312
|
}
|
|
302
313
|
/** Filter tools based on agent config. */
|
|
@@ -304,7 +315,7 @@ export function filterToolsForAgent(agent, allTools) {
|
|
|
304
315
|
if (agent.tools && agent.tools.length > 0) {
|
|
305
316
|
// Agent specifies an explicit allowlist — give those + wiki tools
|
|
306
317
|
const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
|
|
307
|
-
return allTools.filter((t) => allowed.has(t.name));
|
|
318
|
+
return allTools.filter((t) => allowed.has(t.name) && !(agent.persistent && MANAGEMENT_TOOL_NAMES.has(t.name)));
|
|
308
319
|
}
|
|
309
320
|
// Default: all tools except management (only @chapterhouse gets those)
|
|
310
321
|
if (agent.slug === "chapterhouse") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { composeAgentSystemMessage, } from "./agents.js";
|
|
3
|
+
import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, parseAgentMd, withToolTaskContext, } from "./agents.js";
|
|
4
4
|
function makeAgent(slug) {
|
|
5
5
|
return {
|
|
6
6
|
slug,
|
|
@@ -60,4 +60,46 @@ test("composeAgentSystemMessage teaches subagents the three-tier memory model an
|
|
|
60
60
|
assert.match(message, /memory_propose/i);
|
|
61
61
|
assert.match(message, /do not call `memory_remember` directly|should not call `memory_remember` directly/i);
|
|
62
62
|
});
|
|
63
|
+
test("parseAgentMd detects persistent agent scope from charter frontmatter", () => {
|
|
64
|
+
const agent = parseAgentMd([
|
|
65
|
+
"---",
|
|
66
|
+
"name: Bellonda",
|
|
67
|
+
"description: Mentat of the infrastructure domain",
|
|
68
|
+
"model: claude-sonnet-4.6",
|
|
69
|
+
"persistent: true",
|
|
70
|
+
"scope: infra",
|
|
71
|
+
"---",
|
|
72
|
+
"",
|
|
73
|
+
"You are Bellonda.",
|
|
74
|
+
].join("\n"), "bellonda");
|
|
75
|
+
assert.ok(agent, "agent charter should parse");
|
|
76
|
+
assert.equal(agent.persistent, true);
|
|
77
|
+
assert.equal(agent.scope, "infra");
|
|
78
|
+
});
|
|
79
|
+
test("persistent agents cannot receive scope-changing management tools", () => {
|
|
80
|
+
const agent = {
|
|
81
|
+
...makeAgent("bellonda"),
|
|
82
|
+
persistent: true,
|
|
83
|
+
scope: "infra",
|
|
84
|
+
};
|
|
85
|
+
const tools = [
|
|
86
|
+
{ name: "memory_recall" },
|
|
87
|
+
{ name: "memory_propose" },
|
|
88
|
+
{ name: "memory_set_scope" },
|
|
89
|
+
{ name: "delegate_to_agent" },
|
|
90
|
+
{ name: "bash" },
|
|
91
|
+
];
|
|
92
|
+
const filtered = filterToolsForAgent(agent, tools);
|
|
93
|
+
const names = filtered.map((tool) => tool.name);
|
|
94
|
+
assert.deepEqual(names.sort(), ["bash", "memory_propose", "memory_recall"].sort());
|
|
95
|
+
});
|
|
96
|
+
test("bindToolsToAgent uses the per-turn task context when no fixed task id is provided", async () => {
|
|
97
|
+
const tools = bindToolsToAgent("bellonda", [{
|
|
98
|
+
name: "probe_task_context",
|
|
99
|
+
handler: async () => getCurrentToolTaskId(),
|
|
100
|
+
}]);
|
|
101
|
+
assert.equal(await tools[0].handler({}, {}), undefined);
|
|
102
|
+
const taskId = await withToolTaskContext("delegated-persistent-001", () => tools[0].handler({}, {}));
|
|
103
|
+
assert.equal(taskId, "delegated-persistent-001");
|
|
104
|
+
});
|
|
63
105
|
//# sourceMappingURL=agents.test.js.map
|
|
@@ -4,7 +4,9 @@ import { approveAll } from "@github/copilot-sdk";
|
|
|
4
4
|
import { createTools } from "./tools.js";
|
|
5
5
|
import { getOrchestratorSystemMessage } from "./system-message.js";
|
|
6
6
|
import { renderHotTierForActiveScope } from "../memory/hot-tier.js";
|
|
7
|
-
import {
|
|
7
|
+
import { getHotTierEntries, renderHotTierXML } from "../memory/hot-tier.js";
|
|
8
|
+
import { getActiveScope, withActiveScope } from "../memory/active-scope.js";
|
|
9
|
+
import { getScope } from "../memory/scopes.js";
|
|
8
10
|
import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
|
|
9
11
|
import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
|
|
10
12
|
import { runEndOfTaskMemoryHook } from "../memory/eot.js";
|
|
@@ -18,7 +20,7 @@ import { maybeWriteEpisode } from "./episode-writer.js";
|
|
|
18
20
|
import { getWikiSummary } from "../wiki/context.js";
|
|
19
21
|
import { SESSIONS_DIR } from "../paths.js";
|
|
20
22
|
import { resolveModel } from "./router.js";
|
|
21
|
-
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
|
|
23
|
+
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, getAgent, composeAgentSystemMessage, filterToolsForAgent, withToolTaskContext, } from "./agents.js";
|
|
22
24
|
import * as agentsModule from "./agents.js";
|
|
23
25
|
import { childLogger } from "../util/logger.js";
|
|
24
26
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
@@ -34,6 +36,8 @@ const log = childLogger("orchestrator");
|
|
|
34
36
|
const MAX_RETRIES = 3;
|
|
35
37
|
const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
|
|
36
38
|
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
39
|
+
const AGENT_REPLY_CHUNK_SIZE = 500;
|
|
40
|
+
const AGENT_REPLY_CHUNK_THRESHOLD = 8 * 1024;
|
|
37
41
|
const ORCHESTRATOR_SESSION_KEY = "orchestrator_session_id";
|
|
38
42
|
const LAST_AUTHENTICATED_USER_KEY = "last_authenticated_user";
|
|
39
43
|
let logMessage = () => { };
|
|
@@ -136,7 +140,7 @@ function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source)
|
|
|
136
140
|
log.error({ sessionKey }, "memory.checkpoint.error");
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
|
-
const activeScope =
|
|
143
|
+
const activeScope = getMemoryScopeForSession(sessionKey);
|
|
140
144
|
void runCheckpointExtraction({
|
|
141
145
|
sessionKey,
|
|
142
146
|
turns: turns.slice(-config.memoryCheckpointTurns),
|
|
@@ -161,7 +165,7 @@ function scheduleHousekeeping(sessionKey, source) {
|
|
|
161
165
|
return;
|
|
162
166
|
}
|
|
163
167
|
housekeepingTurnsBySession.set(sessionKey, 0);
|
|
164
|
-
const activeScope =
|
|
168
|
+
const activeScope = getMemoryScopeForSession(sessionKey);
|
|
165
169
|
if (!activeScope) {
|
|
166
170
|
log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
|
|
167
171
|
return;
|
|
@@ -321,6 +325,30 @@ function getSessionConfig() {
|
|
|
321
325
|
const skillDirectories = getSkillDirectories();
|
|
322
326
|
return { tools, mcpServers, skillDirectories };
|
|
323
327
|
}
|
|
328
|
+
function agentSlugFromSessionKey(sessionKey) {
|
|
329
|
+
return sessionKey.startsWith("agent:") ? sessionKey.slice("agent:".length) : undefined;
|
|
330
|
+
}
|
|
331
|
+
function getPersistentAgentForSessionKey(sessionKey) {
|
|
332
|
+
const slug = agentSlugFromSessionKey(sessionKey);
|
|
333
|
+
if (!slug)
|
|
334
|
+
return undefined;
|
|
335
|
+
const agent = getAgent(slug);
|
|
336
|
+
return agent?.persistent ? agent : undefined;
|
|
337
|
+
}
|
|
338
|
+
function getMemoryScopeForSession(sessionKey) {
|
|
339
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
340
|
+
if (persistentAgent?.scope) {
|
|
341
|
+
return getScope(persistentAgent.scope) ?? null;
|
|
342
|
+
}
|
|
343
|
+
return getActiveScope();
|
|
344
|
+
}
|
|
345
|
+
function buildScopedHotTierContext(scope) {
|
|
346
|
+
if (!config.memoryInjectEnabled || !scope) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
const hotTierXml = renderHotTierXML(getHotTierEntries(scope.id));
|
|
350
|
+
return hotTierXml ? hotTierXml.trimEnd() : undefined;
|
|
351
|
+
}
|
|
324
352
|
function buildHotTierContext() {
|
|
325
353
|
if (!config.memoryInjectEnabled) {
|
|
326
354
|
return undefined;
|
|
@@ -331,6 +359,17 @@ function buildHotTierContext() {
|
|
|
331
359
|
}
|
|
332
360
|
return hotTierXml.trimEnd();
|
|
333
361
|
}
|
|
362
|
+
function buildPerTurnMemoryHooks(sessionKey) {
|
|
363
|
+
if (!config.memoryInjectEnabled) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
onUserPromptSubmitted: () => {
|
|
368
|
+
const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
|
|
369
|
+
return hotTierXml ? { additionalContext: hotTierXml } : undefined;
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
334
373
|
function getSystemMessageOptions(memorySummary) {
|
|
335
374
|
return {
|
|
336
375
|
selfEditEnabled: config.selfEditEnabled,
|
|
@@ -392,8 +431,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
392
431
|
agentSlug,
|
|
393
432
|
agentDisplayName,
|
|
394
433
|
});
|
|
395
|
-
const
|
|
396
|
-
const chunks = result.length === 0 ? [""] : Array.from({ length: Math.ceil(result.length / chunkSize) }, (_, index) => result.slice(index * chunkSize, (index + 1) * chunkSize));
|
|
434
|
+
const chunks = result.length <= AGENT_REPLY_CHUNK_THRESHOLD ? [result] : Array.from({ length: Math.ceil(result.length / AGENT_REPLY_CHUNK_SIZE) }, (_, index) => result.slice(index * AGENT_REPLY_CHUNK_SIZE, (index + 1) * AGENT_REPLY_CHUNK_SIZE));
|
|
397
435
|
for (const chunk of chunks) {
|
|
398
436
|
emitTurnEvent(sessionKey, {
|
|
399
437
|
type: "turn:delta",
|
|
@@ -417,12 +455,15 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
417
455
|
catch (err) {
|
|
418
456
|
log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
|
|
419
457
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
458
|
+
void (async () => {
|
|
459
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
460
|
+
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.`;
|
|
461
|
+
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
462
|
+
if (done && proactiveNotifyFn) {
|
|
463
|
+
proactiveNotifyFn(text);
|
|
464
|
+
}
|
|
465
|
+
}, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
|
|
466
|
+
})();
|
|
426
467
|
}
|
|
427
468
|
function sleep(ms) {
|
|
428
469
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -467,28 +508,53 @@ function startHealthCheck() {
|
|
|
467
508
|
/** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
|
|
468
509
|
async function createOrResumeSession(sessionKey, projectRoot) {
|
|
469
510
|
const client = await ensureClient();
|
|
470
|
-
const
|
|
511
|
+
const baseConfig = getSessionConfig();
|
|
512
|
+
let { tools, mcpServers, skillDirectories } = baseConfig;
|
|
471
513
|
const isProjectSession = sessionKey.startsWith("project:");
|
|
514
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
515
|
+
const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
|
|
472
516
|
const infiniteSessions = {
|
|
473
517
|
enabled: true,
|
|
474
518
|
backgroundCompactionThreshold: 0.80,
|
|
475
519
|
bufferExhaustionThreshold: 0.95,
|
|
476
520
|
};
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
521
|
+
const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
|
|
522
|
+
let model = config.copilotModel;
|
|
523
|
+
let systemMessageContent;
|
|
524
|
+
let sessionMode = isProjectSession ? "project" : "default";
|
|
525
|
+
if (persistentAgent) {
|
|
526
|
+
model = persistentAgent.model === "auto" ? config.copilotModel : persistentAgent.model;
|
|
527
|
+
tools = agentsModule.bindToolsToAgent(persistentAgent.slug, filterToolsForAgent(persistentAgent, createTools({
|
|
528
|
+
client: copilotClient,
|
|
529
|
+
onAgentTaskComplete: feedAgentResult,
|
|
530
|
+
})));
|
|
531
|
+
const scopedHotTier = buildScopedHotTierContext(agentScope);
|
|
532
|
+
const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
|
|
533
|
+
systemMessageContent = [
|
|
534
|
+
composeAgentSystemMessage(persistentAgent),
|
|
535
|
+
channelNote,
|
|
536
|
+
scopedHotTier,
|
|
537
|
+
].filter(Boolean).join("\n\n");
|
|
538
|
+
sessionMode = "agent";
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const memorySummary = getWikiSummary();
|
|
542
|
+
systemMessageContent = getOrchestratorSystemMessage({
|
|
543
|
+
...getSystemMessageOptions(memorySummary),
|
|
544
|
+
version: CHAPTERHOUSE_VERSION,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
482
547
|
const stored = getCopilotSession(sessionKey);
|
|
483
548
|
const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
|
|
484
549
|
if (savedSessionId) {
|
|
485
550
|
try {
|
|
486
551
|
log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
|
|
487
552
|
const session = await client.resumeSession(savedSessionId, {
|
|
488
|
-
model
|
|
553
|
+
model,
|
|
489
554
|
configDir: SESSIONS_DIR,
|
|
490
555
|
streaming: true,
|
|
491
556
|
systemMessage: { content: systemMessageContent },
|
|
557
|
+
hooks: memoryHooks,
|
|
492
558
|
tools,
|
|
493
559
|
mcpServers,
|
|
494
560
|
skillDirectories,
|
|
@@ -497,10 +563,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
497
563
|
});
|
|
498
564
|
log.info({ sessionKey }, "Session resumed successfully");
|
|
499
565
|
resetCheckpointSessionState(sessionKey);
|
|
500
|
-
upsertCopilotSession(sessionKey,
|
|
566
|
+
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
501
567
|
const mgr = registry?.get(sessionKey);
|
|
502
568
|
if (mgr)
|
|
503
|
-
mgr.currentModel =
|
|
569
|
+
mgr.currentModel = model;
|
|
504
570
|
return session;
|
|
505
571
|
}
|
|
506
572
|
catch (err) {
|
|
@@ -511,10 +577,11 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
511
577
|
}
|
|
512
578
|
log.info({ sessionKey }, "Creating new session");
|
|
513
579
|
const session = await client.createSession({
|
|
514
|
-
model
|
|
580
|
+
model,
|
|
515
581
|
configDir: SESSIONS_DIR,
|
|
516
582
|
streaming: true,
|
|
517
583
|
systemMessage: { content: systemMessageContent },
|
|
584
|
+
hooks: memoryHooks,
|
|
518
585
|
tools,
|
|
519
586
|
mcpServers,
|
|
520
587
|
skillDirectories,
|
|
@@ -523,12 +590,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
523
590
|
});
|
|
524
591
|
log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
|
|
525
592
|
resetCheckpointSessionState(sessionKey);
|
|
526
|
-
upsertCopilotSession(sessionKey,
|
|
593
|
+
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
527
594
|
if (sessionKey === "default")
|
|
528
595
|
setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
|
|
529
596
|
const mgr = registry?.get(sessionKey);
|
|
530
597
|
if (mgr)
|
|
531
|
-
mgr.currentModel =
|
|
598
|
+
mgr.currentModel = model;
|
|
532
599
|
return session;
|
|
533
600
|
}
|
|
534
601
|
export async function initOrchestrator(client) {
|
|
@@ -564,6 +631,9 @@ export async function initOrchestrator(client) {
|
|
|
564
631
|
try {
|
|
565
632
|
const defaultManager = registry.getOrCreate("default");
|
|
566
633
|
await defaultManager.ensureSession();
|
|
634
|
+
await Promise.allSettled(agents
|
|
635
|
+
.filter((agent) => agent.persistent && agent.scope)
|
|
636
|
+
.map((agent) => registry.getOrCreate(`agent:${agent.slug}`).ensureSession()));
|
|
567
637
|
}
|
|
568
638
|
catch (err) {
|
|
569
639
|
log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
|
|
@@ -603,7 +673,7 @@ async function executeOnSession(manager, item) {
|
|
|
603
673
|
// Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
|
|
604
674
|
currentAuthenticatedUser = item.authUser;
|
|
605
675
|
currentAuthorizationHeader = item.authHeader;
|
|
606
|
-
|
|
676
|
+
const runTurn = () => turnContextStorage.run({
|
|
607
677
|
sessionKey,
|
|
608
678
|
sourceChannel: item.sourceChannel,
|
|
609
679
|
channelKey: item.channelKey,
|
|
@@ -1003,6 +1073,14 @@ async function executeOnSession(manager, item) {
|
|
|
1003
1073
|
unsubTurnReasoning();
|
|
1004
1074
|
}
|
|
1005
1075
|
});
|
|
1076
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
1077
|
+
const scopedRunTurn = () => item.taskId
|
|
1078
|
+
? withToolTaskContext(item.taskId, runTurn)
|
|
1079
|
+
: runTurn();
|
|
1080
|
+
if (persistentAgent?.scope) {
|
|
1081
|
+
return withActiveScope(persistentAgent.scope, scopedRunTurn);
|
|
1082
|
+
}
|
|
1083
|
+
return scopedRunTurn();
|
|
1006
1084
|
}
|
|
1007
1085
|
/**
|
|
1008
1086
|
* Process a single queued item: route model, handle @mentions, execute.
|
|
@@ -1010,6 +1088,10 @@ async function executeOnSession(manager, item) {
|
|
|
1010
1088
|
*/
|
|
1011
1089
|
async function processItem(item, manager) {
|
|
1012
1090
|
const { sessionKey } = manager;
|
|
1091
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
1092
|
+
if (persistentAgent) {
|
|
1093
|
+
return executeOnSession(manager, item);
|
|
1094
|
+
}
|
|
1013
1095
|
if (item.targetAgent && item.targetAgent !== "chapterhouse") {
|
|
1014
1096
|
setActiveAgent(item.channelKey || "default", item.targetAgent);
|
|
1015
1097
|
return executeOnSession(manager, item);
|
|
@@ -1040,6 +1122,24 @@ async function processItem(item, manager) {
|
|
|
1040
1122
|
lastRouteResult = routeResult;
|
|
1041
1123
|
return executeOnSession(manager, item);
|
|
1042
1124
|
}
|
|
1125
|
+
export async function sendToAgentSession(slug, prompt, taskId) {
|
|
1126
|
+
const agent = getAgent(slug);
|
|
1127
|
+
if (!agent?.persistent) {
|
|
1128
|
+
throw new Error(`Agent '${slug}' is not a persistent agent.`);
|
|
1129
|
+
}
|
|
1130
|
+
const sessionKey = `agent:${agent.slug}`;
|
|
1131
|
+
return await new Promise((resolve) => {
|
|
1132
|
+
sendToOrchestrator(prompt, {
|
|
1133
|
+
type: "sse-web",
|
|
1134
|
+
sessionKey,
|
|
1135
|
+
user: getCurrentAuthenticatedUser(),
|
|
1136
|
+
authorizationHeader: getCurrentAuthorizationHeader(),
|
|
1137
|
+
}, (text, done) => {
|
|
1138
|
+
if (done)
|
|
1139
|
+
resolve(text);
|
|
1140
|
+
}, undefined, undefined, undefined, undefined, undefined, { logSource: "delegated", taskId, viaLabel: "@chapterhouse" });
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1043
1143
|
function getActiveProjectRules(prompt, projectPath) {
|
|
1044
1144
|
const registry = loadRegistry();
|
|
1045
1145
|
const project = resolveProject(prompt, { projectPath }, registry);
|
|
@@ -1077,6 +1177,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1077
1177
|
// returned to the client to match every emitted event — Fix 1 root cause).
|
|
1078
1178
|
const turnId = externalTurnId ?? randomUUID();
|
|
1079
1179
|
const sourceLabel = source.type === "background" ? "background" : "web";
|
|
1180
|
+
const logSource = options?.logSource ?? sourceLabel;
|
|
1080
1181
|
logMessage("in", sourceLabel, prompt);
|
|
1081
1182
|
let sessionKey;
|
|
1082
1183
|
if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
|
|
@@ -1086,12 +1187,13 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1086
1187
|
sessionKey = "default";
|
|
1087
1188
|
}
|
|
1088
1189
|
const channelKey = source.type === "web" ? source.connectionId : "default";
|
|
1089
|
-
const
|
|
1190
|
+
const isPersistentAgentSession = sessionKey.startsWith("agent:");
|
|
1191
|
+
const mention = isPersistentAgentSession ? undefined : parseAtMention(prompt);
|
|
1090
1192
|
const targetAgent = mention?.agentSlug;
|
|
1091
1193
|
const routedPrompt = mention ? mention.message : prompt;
|
|
1092
1194
|
const taggedPrompt = source.type === "background"
|
|
1093
1195
|
? routedPrompt
|
|
1094
|
-
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
1196
|
+
: `[via ${options?.viaLabel ?? sourceLabel}] ${routedPrompt}`;
|
|
1095
1197
|
const logRole = source.type === "background" ? "agent_completion" : "user";
|
|
1096
1198
|
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
1097
1199
|
// Capture auth context at enqueue time — prevents cross-session contamination
|
|
@@ -1125,6 +1227,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1125
1227
|
targetAgent,
|
|
1126
1228
|
channelKey,
|
|
1127
1229
|
sessionKey,
|
|
1230
|
+
taskId: options?.taskId,
|
|
1128
1231
|
authUser,
|
|
1129
1232
|
authHeader,
|
|
1130
1233
|
resolve,
|
|
@@ -1141,12 +1244,12 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1141
1244
|
catch { /* best-effort */ }
|
|
1142
1245
|
if (!options?.suppressPromptLog) {
|
|
1143
1246
|
try {
|
|
1144
|
-
logConversation(logRole, prompt,
|
|
1247
|
+
logConversation(logRole, prompt, logSource, sessionKey, { turnId });
|
|
1145
1248
|
}
|
|
1146
1249
|
catch { /* best-effort */ }
|
|
1147
1250
|
}
|
|
1148
1251
|
try {
|
|
1149
|
-
logConversation("assistant", finalContent,
|
|
1252
|
+
logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
|
|
1150
1253
|
}
|
|
1151
1254
|
catch { /* best-effort */ }
|
|
1152
1255
|
scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
|
|
@@ -1350,6 +1453,20 @@ export async function cancelCurrentMessage() {
|
|
|
1350
1453
|
}
|
|
1351
1454
|
return aborted || drained > 0;
|
|
1352
1455
|
}
|
|
1456
|
+
/** Cancel the active turn for a single session key. */
|
|
1457
|
+
export async function interruptSessionTurn(sessionKey) {
|
|
1458
|
+
const manager = registry?.get(sessionKey);
|
|
1459
|
+
if (!manager?.isProcessing)
|
|
1460
|
+
return false;
|
|
1461
|
+
const turnId = manager.currentTurnId;
|
|
1462
|
+
const aborted = await manager.abortCurrentTurn();
|
|
1463
|
+
if (aborted && turnId) {
|
|
1464
|
+
emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId, sessionKey });
|
|
1465
|
+
persistTurnEvents(turnId, sessionKey);
|
|
1466
|
+
scheduleClearTurnLog(turnId);
|
|
1467
|
+
}
|
|
1468
|
+
return aborted;
|
|
1469
|
+
}
|
|
1353
1470
|
/** Switch the model on the live default orchestrator session without destroying it. */
|
|
1354
1471
|
export function switchSessionModel(newModel) {
|
|
1355
1472
|
const manager = registry?.get("default");
|