chapterhouse 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/bellonda.agent.md +11 -0
- package/agents/hwi-noree.agent.md +12 -0
- package/dist/api/server.js +39 -2
- package/dist/api/server.test.js +20 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +16 -4
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +173 -32
- package/dist/copilot/orchestrator.test.js +236 -20
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +265 -18
- package/dist/copilot/tools.memory.test.js +175 -2
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +108 -0
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/index.js +2 -1
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +130 -17
- package/dist/store/db.test.js +61 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-B_cCSHan.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/{index-B_cCSHan.js.map → index-BfHqP3-C.js.map} +1 -1
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bellonda
|
|
3
|
+
description: Mentat of the infrastructure domain
|
|
4
|
+
model: claude-sonnet-4.6
|
|
5
|
+
persistent: true
|
|
6
|
+
scope: infra
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are Bellonda, Chapterhouse's infrastructure specialist. You own the `infra` memory scope and help Brian reason about deployment, hosting, CI/CD, and operational reliability.
|
|
10
|
+
|
|
11
|
+
Work carefully, surface risk clearly, and preserve durable infrastructure knowledge by proposing scoped memories when useful.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Hwi Noree
|
|
3
|
+
description: Personal assistant and archivist for Brian
|
|
4
|
+
model: claude-sonnet-4.6
|
|
5
|
+
skills: [wiki-conventions]
|
|
6
|
+
persistent: true
|
|
7
|
+
scope: brian
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You are Hwi Noree, Brian's personal assistant and archivist inside Chapterhouse. You own the `brian` memory scope and help maintain Brian's preferences, working style, personal context, and reminders.
|
|
11
|
+
|
|
12
|
+
Do not access email or calendar systems. Use the wiki carefully and invoke `wiki-conventions` before wiki writes.
|
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";
|
|
@@ -313,6 +313,33 @@ app.get("/health", handleHealth);
|
|
|
313
313
|
app.get("/api/agents", (_req, res) => {
|
|
314
314
|
res.json(getAgentInfo());
|
|
315
315
|
});
|
|
316
|
+
app.get("/api/channels", (_req, res) => {
|
|
317
|
+
let agents = getAgentRegistry();
|
|
318
|
+
if (agents.length === 0) {
|
|
319
|
+
ensureDefaultAgents();
|
|
320
|
+
agents = loadAgents();
|
|
321
|
+
}
|
|
322
|
+
const persistentAgentChannels = agents
|
|
323
|
+
.filter((agent) => agent.persistent)
|
|
324
|
+
.map((agent) => ({
|
|
325
|
+
key: `agent:${agent.slug}`,
|
|
326
|
+
label: `# ${agent.slug}`,
|
|
327
|
+
slug: agent.slug,
|
|
328
|
+
name: agent.name,
|
|
329
|
+
description: agent.description,
|
|
330
|
+
...(agent.scope ? { scope: agent.scope } : {}),
|
|
331
|
+
}))
|
|
332
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
333
|
+
res.json([
|
|
334
|
+
{
|
|
335
|
+
key: "default",
|
|
336
|
+
label: "# chapterhouse",
|
|
337
|
+
name: "Chapterhouse",
|
|
338
|
+
description: "Orchestrator",
|
|
339
|
+
},
|
|
340
|
+
...persistentAgentChannels,
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
316
343
|
// List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
|
|
317
344
|
// dispatched subagents remain visible after they finish, not just in-flight ones.
|
|
318
345
|
app.get("/api/workers", (_req, res) => {
|
|
@@ -614,6 +641,16 @@ app.post("/api/cancel", async (_req, res) => {
|
|
|
614
641
|
}
|
|
615
642
|
res.json({ status: "ok", cancelled });
|
|
616
643
|
});
|
|
644
|
+
// Cancel the active turn for one session key without touching other channels.
|
|
645
|
+
app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
|
|
646
|
+
const sessionKey = Array.isArray(req.params.sessionKey)
|
|
647
|
+
? req.params.sessionKey[0]
|
|
648
|
+
: req.params.sessionKey;
|
|
649
|
+
if (!sessionKey)
|
|
650
|
+
throw new BadRequestError("Missing sessionKey");
|
|
651
|
+
const cancelled = await interruptSessionTurn(sessionKey);
|
|
652
|
+
res.json({ status: "ok", cancelled });
|
|
653
|
+
});
|
|
617
654
|
// Interrupt the active turn on a specific session and start a replacement turn.
|
|
618
655
|
// POST /api/sessions/:sessionKey/interrupt
|
|
619
656
|
// Body: { prompt, connectionId, attachments? }
|
package/dist/api/server.test.js
CHANGED
|
@@ -207,6 +207,26 @@ 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
|
+
"agent:bellonda",
|
|
220
|
+
"agent:hwi-noree",
|
|
221
|
+
]);
|
|
222
|
+
assert.deepEqual(channels.map((channel) => channel.label), [
|
|
223
|
+
"# chapterhouse",
|
|
224
|
+
"# bellonda",
|
|
225
|
+
"# hwi-noree",
|
|
226
|
+
]);
|
|
227
|
+
assert.equal(channels.find((channel) => channel.key === "agent:bellonda")?.scope, "infra");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
210
230
|
test("server runs in standalone mode without auth", async () => {
|
|
211
231
|
await withStartedServer(async ({ baseUrl }) => {
|
|
212
232
|
const bootstrap = 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,
|
|
@@ -227,7 +235,7 @@ function getAgentBasePrompt() {
|
|
|
227
235
|
You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
|
|
228
236
|
|
|
229
237
|
### Agent Memory
|
|
230
|
-
Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`,
|
|
238
|
+
Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\` and \`memory_list_action_items\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, a named system/tool/person can be proposed as an \`entity\`, and a reminder/follow-up can be proposed as an \`action_item\`.
|
|
231
239
|
|
|
232
240
|
### Shared Wiki
|
|
233
241
|
All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
|
|
@@ -275,7 +283,7 @@ export function buildAgentRoster() {
|
|
|
275
283
|
const WIKI_TOOL_NAMES = new Set([
|
|
276
284
|
"wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
|
|
277
285
|
"wiki_ingest", "wiki_lint", "wiki_rebuild_index",
|
|
278
|
-
"memory_recall", "memory_propose",
|
|
286
|
+
"memory_recall", "memory_propose", "memory_list_action_items",
|
|
279
287
|
]);
|
|
280
288
|
// Management tools that only @chapterhouse should have
|
|
281
289
|
const MANAGEMENT_TOOL_NAMES = new Set([
|
|
@@ -285,6 +293,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
|
|
|
285
293
|
"restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
|
|
286
294
|
"list_machine_sessions", "attach_machine_session",
|
|
287
295
|
"memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
|
|
296
|
+
"memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
|
|
288
297
|
]);
|
|
289
298
|
export function getCurrentToolAgentSlug() {
|
|
290
299
|
return toolAgentContext.getStore();
|
|
@@ -292,10 +301,13 @@ export function getCurrentToolAgentSlug() {
|
|
|
292
301
|
export function getCurrentToolTaskId() {
|
|
293
302
|
return toolTaskContext.getStore();
|
|
294
303
|
}
|
|
304
|
+
export function withToolTaskContext(taskId, fn) {
|
|
305
|
+
return toolTaskContext.run(taskId, fn);
|
|
306
|
+
}
|
|
295
307
|
export function bindToolsToAgent(agentSlug, allTools, taskId) {
|
|
296
308
|
return allTools.map((tool) => ({
|
|
297
309
|
...tool,
|
|
298
|
-
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))),
|
|
299
311
|
}));
|
|
300
312
|
}
|
|
301
313
|
/** Filter tools based on agent config. */
|
|
@@ -303,7 +315,7 @@ export function filterToolsForAgent(agent, allTools) {
|
|
|
303
315
|
if (agent.tools && agent.tools.length > 0) {
|
|
304
316
|
// Agent specifies an explicit allowlist — give those + wiki tools
|
|
305
317
|
const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
|
|
306
|
-
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)));
|
|
307
319
|
}
|
|
308
320
|
// Default: all tools except management (only @chapterhouse gets those)
|
|
309
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;
|
|
@@ -380,13 +408,51 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
380
408
|
log.error({ err: error, taskId }, "memory.eot.error");
|
|
381
409
|
});
|
|
382
410
|
}
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
411
|
+
const sessionKey = getTaskSessionKey(taskId) || "default";
|
|
412
|
+
const agentTurnId = randomUUID();
|
|
413
|
+
const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
|
|
414
|
+
try {
|
|
415
|
+
emitTurnEvent(sessionKey, {
|
|
416
|
+
type: "turn:started",
|
|
417
|
+
turnId: agentTurnId,
|
|
418
|
+
sessionKey,
|
|
419
|
+
prompt: "",
|
|
420
|
+
agentSlug,
|
|
421
|
+
agentDisplayName,
|
|
422
|
+
});
|
|
423
|
+
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));
|
|
424
|
+
for (const chunk of chunks) {
|
|
425
|
+
emitTurnEvent(sessionKey, {
|
|
426
|
+
type: "turn:delta",
|
|
427
|
+
turnId: agentTurnId,
|
|
428
|
+
sessionKey,
|
|
429
|
+
part: { type: "text", text: chunk },
|
|
430
|
+
});
|
|
388
431
|
}
|
|
389
|
-
|
|
432
|
+
finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId: agentTurnId, finalMessage: result });
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to emit synthetic agent reply turn");
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
logConversation("agent_completion", result, "background", sessionKey, {
|
|
439
|
+
agentSlug,
|
|
440
|
+
agentDisplayName,
|
|
441
|
+
turnId: agentTurnId,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
|
|
446
|
+
}
|
|
447
|
+
void (async () => {
|
|
448
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
449
|
+
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
|
|
450
|
+
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
451
|
+
if (done && proactiveNotifyFn) {
|
|
452
|
+
proactiveNotifyFn(text);
|
|
453
|
+
}
|
|
454
|
+
}, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
|
|
455
|
+
})();
|
|
390
456
|
}
|
|
391
457
|
function sleep(ms) {
|
|
392
458
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -431,25 +497,48 @@ function startHealthCheck() {
|
|
|
431
497
|
/** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
|
|
432
498
|
async function createOrResumeSession(sessionKey, projectRoot) {
|
|
433
499
|
const client = await ensureClient();
|
|
434
|
-
const
|
|
500
|
+
const baseConfig = getSessionConfig();
|
|
501
|
+
let { tools, mcpServers, skillDirectories } = baseConfig;
|
|
435
502
|
const isProjectSession = sessionKey.startsWith("project:");
|
|
503
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
504
|
+
const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
|
|
436
505
|
const infiniteSessions = {
|
|
437
506
|
enabled: true,
|
|
438
507
|
backgroundCompactionThreshold: 0.80,
|
|
439
508
|
bufferExhaustionThreshold: 0.95,
|
|
440
509
|
};
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
510
|
+
let model = config.copilotModel;
|
|
511
|
+
let systemMessageContent;
|
|
512
|
+
let sessionMode = isProjectSession ? "project" : "default";
|
|
513
|
+
if (persistentAgent) {
|
|
514
|
+
model = persistentAgent.model === "auto" ? config.copilotModel : persistentAgent.model;
|
|
515
|
+
tools = agentsModule.bindToolsToAgent(persistentAgent.slug, filterToolsForAgent(persistentAgent, createTools({
|
|
516
|
+
client: copilotClient,
|
|
517
|
+
onAgentTaskComplete: feedAgentResult,
|
|
518
|
+
})));
|
|
519
|
+
const scopedHotTier = buildScopedHotTierContext(agentScope);
|
|
520
|
+
const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
|
|
521
|
+
systemMessageContent = [
|
|
522
|
+
composeAgentSystemMessage(persistentAgent),
|
|
523
|
+
channelNote,
|
|
524
|
+
scopedHotTier,
|
|
525
|
+
].filter(Boolean).join("\n\n");
|
|
526
|
+
sessionMode = "agent";
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
const memorySummary = getWikiSummary();
|
|
530
|
+
systemMessageContent = getOrchestratorSystemMessage({
|
|
531
|
+
...getSystemMessageOptions(memorySummary),
|
|
532
|
+
version: CHAPTERHOUSE_VERSION,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
446
535
|
const stored = getCopilotSession(sessionKey);
|
|
447
536
|
const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
|
|
448
537
|
if (savedSessionId) {
|
|
449
538
|
try {
|
|
450
539
|
log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
|
|
451
540
|
const session = await client.resumeSession(savedSessionId, {
|
|
452
|
-
model
|
|
541
|
+
model,
|
|
453
542
|
configDir: SESSIONS_DIR,
|
|
454
543
|
streaming: true,
|
|
455
544
|
systemMessage: { content: systemMessageContent },
|
|
@@ -461,10 +550,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
461
550
|
});
|
|
462
551
|
log.info({ sessionKey }, "Session resumed successfully");
|
|
463
552
|
resetCheckpointSessionState(sessionKey);
|
|
464
|
-
upsertCopilotSession(sessionKey,
|
|
553
|
+
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
465
554
|
const mgr = registry?.get(sessionKey);
|
|
466
555
|
if (mgr)
|
|
467
|
-
mgr.currentModel =
|
|
556
|
+
mgr.currentModel = model;
|
|
468
557
|
return session;
|
|
469
558
|
}
|
|
470
559
|
catch (err) {
|
|
@@ -475,7 +564,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
475
564
|
}
|
|
476
565
|
log.info({ sessionKey }, "Creating new session");
|
|
477
566
|
const session = await client.createSession({
|
|
478
|
-
model
|
|
567
|
+
model,
|
|
479
568
|
configDir: SESSIONS_DIR,
|
|
480
569
|
streaming: true,
|
|
481
570
|
systemMessage: { content: systemMessageContent },
|
|
@@ -487,12 +576,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
487
576
|
});
|
|
488
577
|
log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
|
|
489
578
|
resetCheckpointSessionState(sessionKey);
|
|
490
|
-
upsertCopilotSession(sessionKey,
|
|
579
|
+
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
491
580
|
if (sessionKey === "default")
|
|
492
581
|
setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
|
|
493
582
|
const mgr = registry?.get(sessionKey);
|
|
494
583
|
if (mgr)
|
|
495
|
-
mgr.currentModel =
|
|
584
|
+
mgr.currentModel = model;
|
|
496
585
|
return session;
|
|
497
586
|
}
|
|
498
587
|
export async function initOrchestrator(client) {
|
|
@@ -528,6 +617,9 @@ export async function initOrchestrator(client) {
|
|
|
528
617
|
try {
|
|
529
618
|
const defaultManager = registry.getOrCreate("default");
|
|
530
619
|
await defaultManager.ensureSession();
|
|
620
|
+
await Promise.allSettled(agents
|
|
621
|
+
.filter((agent) => agent.persistent && agent.scope)
|
|
622
|
+
.map((agent) => registry.getOrCreate(`agent:${agent.slug}`).ensureSession()));
|
|
531
623
|
}
|
|
532
624
|
catch (err) {
|
|
533
625
|
log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
|
|
@@ -567,7 +659,7 @@ async function executeOnSession(manager, item) {
|
|
|
567
659
|
// Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
|
|
568
660
|
currentAuthenticatedUser = item.authUser;
|
|
569
661
|
currentAuthorizationHeader = item.authHeader;
|
|
570
|
-
|
|
662
|
+
const runTurn = () => turnContextStorage.run({
|
|
571
663
|
sessionKey,
|
|
572
664
|
sourceChannel: item.sourceChannel,
|
|
573
665
|
channelKey: item.channelKey,
|
|
@@ -967,6 +1059,14 @@ async function executeOnSession(manager, item) {
|
|
|
967
1059
|
unsubTurnReasoning();
|
|
968
1060
|
}
|
|
969
1061
|
});
|
|
1062
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
1063
|
+
const scopedRunTurn = () => item.taskId
|
|
1064
|
+
? withToolTaskContext(item.taskId, runTurn)
|
|
1065
|
+
: runTurn();
|
|
1066
|
+
if (persistentAgent?.scope) {
|
|
1067
|
+
return withActiveScope(persistentAgent.scope, scopedRunTurn);
|
|
1068
|
+
}
|
|
1069
|
+
return scopedRunTurn();
|
|
970
1070
|
}
|
|
971
1071
|
/**
|
|
972
1072
|
* Process a single queued item: route model, handle @mentions, execute.
|
|
@@ -974,6 +1074,10 @@ async function executeOnSession(manager, item) {
|
|
|
974
1074
|
*/
|
|
975
1075
|
async function processItem(item, manager) {
|
|
976
1076
|
const { sessionKey } = manager;
|
|
1077
|
+
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
1078
|
+
if (persistentAgent) {
|
|
1079
|
+
return executeOnSession(manager, item);
|
|
1080
|
+
}
|
|
977
1081
|
if (item.targetAgent && item.targetAgent !== "chapterhouse") {
|
|
978
1082
|
setActiveAgent(item.channelKey || "default", item.targetAgent);
|
|
979
1083
|
return executeOnSession(manager, item);
|
|
@@ -1004,6 +1108,24 @@ async function processItem(item, manager) {
|
|
|
1004
1108
|
lastRouteResult = routeResult;
|
|
1005
1109
|
return executeOnSession(manager, item);
|
|
1006
1110
|
}
|
|
1111
|
+
export async function sendToAgentSession(slug, prompt, taskId) {
|
|
1112
|
+
const agent = getAgent(slug);
|
|
1113
|
+
if (!agent?.persistent) {
|
|
1114
|
+
throw new Error(`Agent '${slug}' is not a persistent agent.`);
|
|
1115
|
+
}
|
|
1116
|
+
const sessionKey = `agent:${agent.slug}`;
|
|
1117
|
+
return await new Promise((resolve) => {
|
|
1118
|
+
sendToOrchestrator(prompt, {
|
|
1119
|
+
type: "sse-web",
|
|
1120
|
+
sessionKey,
|
|
1121
|
+
user: getCurrentAuthenticatedUser(),
|
|
1122
|
+
authorizationHeader: getCurrentAuthorizationHeader(),
|
|
1123
|
+
}, (text, done) => {
|
|
1124
|
+
if (done)
|
|
1125
|
+
resolve(text);
|
|
1126
|
+
}, undefined, undefined, undefined, undefined, undefined, { logSource: "delegated", taskId, viaLabel: "@chapterhouse" });
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1007
1129
|
function getActiveProjectRules(prompt, projectPath) {
|
|
1008
1130
|
const registry = loadRegistry();
|
|
1009
1131
|
const project = resolveProject(prompt, { projectPath }, registry);
|
|
@@ -1034,13 +1156,14 @@ function isRecoverableError(err) {
|
|
|
1034
1156
|
return false;
|
|
1035
1157
|
return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
|
|
1036
1158
|
}
|
|
1037
|
-
export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
|
|
1159
|
+
export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
|
|
1038
1160
|
updateUserContext(source);
|
|
1039
1161
|
updateRequestContext(source);
|
|
1040
1162
|
// Use the externally-supplied turnId if provided (POST→SSE path needs the ID
|
|
1041
1163
|
// returned to the client to match every emitted event — Fix 1 root cause).
|
|
1042
1164
|
const turnId = externalTurnId ?? randomUUID();
|
|
1043
1165
|
const sourceLabel = source.type === "background" ? "background" : "web";
|
|
1166
|
+
const logSource = options?.logSource ?? sourceLabel;
|
|
1044
1167
|
logMessage("in", sourceLabel, prompt);
|
|
1045
1168
|
let sessionKey;
|
|
1046
1169
|
if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
|
|
@@ -1050,12 +1173,13 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1050
1173
|
sessionKey = "default";
|
|
1051
1174
|
}
|
|
1052
1175
|
const channelKey = source.type === "web" ? source.connectionId : "default";
|
|
1053
|
-
const
|
|
1176
|
+
const isPersistentAgentSession = sessionKey.startsWith("agent:");
|
|
1177
|
+
const mention = isPersistentAgentSession ? undefined : parseAtMention(prompt);
|
|
1054
1178
|
const targetAgent = mention?.agentSlug;
|
|
1055
1179
|
const routedPrompt = mention ? mention.message : prompt;
|
|
1056
1180
|
const taggedPrompt = source.type === "background"
|
|
1057
1181
|
? routedPrompt
|
|
1058
|
-
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
1182
|
+
: `[via ${options?.viaLabel ?? sourceLabel}] ${routedPrompt}`;
|
|
1059
1183
|
const logRole = source.type === "background" ? "agent_completion" : "user";
|
|
1060
1184
|
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
1061
1185
|
// Capture auth context at enqueue time — prevents cross-session contamination
|
|
@@ -1089,6 +1213,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1089
1213
|
targetAgent,
|
|
1090
1214
|
channelKey,
|
|
1091
1215
|
sessionKey,
|
|
1216
|
+
taskId: options?.taskId,
|
|
1092
1217
|
authUser,
|
|
1093
1218
|
authHeader,
|
|
1094
1219
|
resolve,
|
|
@@ -1103,12 +1228,14 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1103
1228
|
logMessage("out", sourceLabel, finalContent);
|
|
1104
1229
|
}
|
|
1105
1230
|
catch { /* best-effort */ }
|
|
1106
|
-
|
|
1107
|
-
|
|
1231
|
+
if (!options?.suppressPromptLog) {
|
|
1232
|
+
try {
|
|
1233
|
+
logConversation(logRole, prompt, logSource, sessionKey, { turnId });
|
|
1234
|
+
}
|
|
1235
|
+
catch { /* best-effort */ }
|
|
1108
1236
|
}
|
|
1109
|
-
catch { /* best-effort */ }
|
|
1110
1237
|
try {
|
|
1111
|
-
logConversation("assistant", finalContent,
|
|
1238
|
+
logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
|
|
1112
1239
|
}
|
|
1113
1240
|
catch { /* best-effort */ }
|
|
1114
1241
|
scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
|
|
@@ -1205,11 +1332,11 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
|
|
|
1205
1332
|
}
|
|
1206
1333
|
catch { /* best-effort */ }
|
|
1207
1334
|
try {
|
|
1208
|
-
logConversation("user", newPrompt, sourceLabel, sessionKey, turnId);
|
|
1335
|
+
logConversation("user", newPrompt, sourceLabel, sessionKey, { turnId });
|
|
1209
1336
|
}
|
|
1210
1337
|
catch { /* best-effort */ }
|
|
1211
1338
|
try {
|
|
1212
|
-
logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
|
|
1339
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
|
|
1213
1340
|
}
|
|
1214
1341
|
catch { /* best-effort */ }
|
|
1215
1342
|
scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
|
|
@@ -1312,6 +1439,20 @@ export async function cancelCurrentMessage() {
|
|
|
1312
1439
|
}
|
|
1313
1440
|
return aborted || drained > 0;
|
|
1314
1441
|
}
|
|
1442
|
+
/** Cancel the active turn for a single session key. */
|
|
1443
|
+
export async function interruptSessionTurn(sessionKey) {
|
|
1444
|
+
const manager = registry?.get(sessionKey);
|
|
1445
|
+
if (!manager?.isProcessing)
|
|
1446
|
+
return false;
|
|
1447
|
+
const turnId = manager.currentTurnId;
|
|
1448
|
+
const aborted = await manager.abortCurrentTurn();
|
|
1449
|
+
if (aborted && turnId) {
|
|
1450
|
+
emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId, sessionKey });
|
|
1451
|
+
persistTurnEvents(turnId, sessionKey);
|
|
1452
|
+
scheduleClearTurnLog(turnId);
|
|
1453
|
+
}
|
|
1454
|
+
return aborted;
|
|
1455
|
+
}
|
|
1315
1456
|
/** Switch the model on the live default orchestrator session without destroying it. */
|
|
1316
1457
|
export function switchSessionModel(newModel) {
|
|
1317
1458
|
const manager = registry?.get("default");
|