chapterhouse 0.6.0 → 0.8.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/korg.agent.md +65 -0
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +420 -13
- package/dist/api/server.test.js +533 -3
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +117 -50
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +137 -2
- package/dist/copilot/orchestrator.js +62 -13
- package/dist/copilot/orchestrator.test.js +130 -8
- package/dist/copilot/session-manager.js +34 -0
- package/dist/copilot/system-message.js +11 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +184 -376
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +53 -59
- package/dist/daemon.js +9 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +100 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/store/db.js +119 -4
- package/dist/store/db.test.js +19 -1
- package/dist/test/setup-env.js +3 -1
- package/dist/test/setup-env.test.js +8 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +140 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/index-manager.js +246 -330
- package/dist/wiki/index-manager.test.js +138 -145
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +3 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/index-5kz9aRU9.css +10 -0
- package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
- package/web/dist/assets/index-DknKAtDS.css +0 -10
package/dist/config.test.js
CHANGED
|
@@ -126,6 +126,26 @@ test("defaults memory checkpoint turns to 5 and parses integer overrides", async
|
|
|
126
126
|
assert.equal(parsedThree.memoryCheckpointTurns, 3);
|
|
127
127
|
assert.equal(parsedTen.memoryCheckpointTurns, 10);
|
|
128
128
|
});
|
|
129
|
+
test("parses PKB consolidation defaults and explicit hour overrides", async () => {
|
|
130
|
+
const configModule = await import("./config.js");
|
|
131
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
132
|
+
const parsedDefault = configModule.parseRuntimeConfig({});
|
|
133
|
+
const parsedExplicit = configModule.parseRuntimeConfig({
|
|
134
|
+
CHAPTERHOUSE_PKB_CONSOLIDATION_ENABLED: "false",
|
|
135
|
+
CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: "5",
|
|
136
|
+
});
|
|
137
|
+
assert.equal(parsedDefault.pkbConsolidationEnabled, true);
|
|
138
|
+
assert.equal(parsedDefault.pkbConsolidationHour, 3);
|
|
139
|
+
assert.equal(parsedExplicit.pkbConsolidationEnabled, false);
|
|
140
|
+
assert.equal(parsedExplicit.pkbConsolidationHour, 5);
|
|
141
|
+
});
|
|
142
|
+
test("rejects invalid PKB consolidation hours outside 0-23", async () => {
|
|
143
|
+
const configModule = await import("./config.js");
|
|
144
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
145
|
+
assert.throws(() => configModule.parseRuntimeConfig({
|
|
146
|
+
CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR: "24",
|
|
147
|
+
}), /CHAPTERHOUSE_PKB_CONSOLIDATION_HOUR must be an integer between 0 and 23/);
|
|
148
|
+
});
|
|
129
149
|
test("defaults memory injection on and still honors explicit CHAPTERHOUSE_MEMORY_INJECT overrides", async () => {
|
|
130
150
|
const configModule = await import("./config.js");
|
|
131
151
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* session:tool_call — tool invoked payload: { toolName, toolArgs, resultType?, _kind?, _seq?, _ts?, _summary? }
|
|
12
12
|
* session:destroyed — subagent finished payload: { agentName, reason: "complete" | "error" | "abort" }
|
|
13
13
|
* session:error — subagent failed payload: { agentName, error }
|
|
14
|
+
* agent_saved — custom agent saved payload: { slug }
|
|
14
15
|
*
|
|
15
16
|
* @module copilot/agent-event-bus
|
|
16
17
|
*/
|
package/dist/copilot/agents.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { join, dirname, sep } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { approveAll } from "@github/copilot-sdk";
|
|
8
9
|
import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
|
|
@@ -34,48 +35,42 @@ const agentFrontmatterSchema = z.object({
|
|
|
34
35
|
// Agent Registry
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
let agentRegistry = [];
|
|
38
|
+
const defaultAgentSaveRuntimeHooks = {
|
|
39
|
+
getPersistentSessionState: () => "none",
|
|
40
|
+
reloadPersistentSession: async () => "none",
|
|
41
|
+
emitAgentReloadEvent: () => { },
|
|
42
|
+
};
|
|
43
|
+
let agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
37
44
|
/** Bundled agents shipped with the package */
|
|
38
45
|
const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
|
|
39
|
-
const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
40
|
-
const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
46
|
+
export const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
47
|
+
export const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
48
|
+
const BUILTIN_AGENT_SLUGS = new Set(RESERVED_SLUGS);
|
|
49
|
+
if (existsSync(BUNDLED_AGENTS_DIR)) {
|
|
50
|
+
for (const file of readdirSync(BUNDLED_AGENTS_DIR)) {
|
|
51
|
+
if (file.endsWith(".agent.md")) {
|
|
52
|
+
BUILTIN_AGENT_SLUGS.add(file.replace(/\.agent\.md$/, ""));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function isBuiltinAgent(slug) {
|
|
57
|
+
return BUILTIN_AGENT_SLUGS.has(slug);
|
|
58
|
+
}
|
|
41
59
|
/** Parse YAML frontmatter and markdown body from an .agent.md file. */
|
|
42
|
-
export function
|
|
60
|
+
export function parseAgentMdOrThrow(content, slug) {
|
|
43
61
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\s*([\s\S]*)$/);
|
|
44
|
-
if (!fmMatch)
|
|
45
|
-
|
|
62
|
+
if (!fmMatch) {
|
|
63
|
+
throw new Error("Missing YAML frontmatter");
|
|
64
|
+
}
|
|
46
65
|
const frontmatterRaw = fmMatch[1];
|
|
47
66
|
const body = fmMatch[2].trim();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const idx = line.indexOf(": ");
|
|
52
|
-
if (idx <= 0)
|
|
53
|
-
continue;
|
|
54
|
-
const key = line.slice(0, idx).trim();
|
|
55
|
-
let value = line.slice(idx + 2).trim();
|
|
56
|
-
// Handle YAML quoted strings
|
|
57
|
-
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
|
58
|
-
value = value.slice(1, -1);
|
|
59
|
-
}
|
|
60
|
-
parsed[key] = value;
|
|
67
|
+
const loaded = yamlLoad(frontmatterRaw);
|
|
68
|
+
if (loaded !== undefined && (loaded === null || typeof loaded !== "object" || Array.isArray(loaded))) {
|
|
69
|
+
throw new Error("Agent frontmatter must be a YAML object");
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
for (const key of ["skills", "tools", "mcpServers", "allowed_paths"]) {
|
|
64
|
-
const raw = parsed[key];
|
|
65
|
-
if (typeof raw === "string") {
|
|
66
|
-
const arrMatch = raw.match(/^\[(.*)\]$/);
|
|
67
|
-
if (arrMatch) {
|
|
68
|
-
parsed[key] = arrMatch[1]
|
|
69
|
-
.split(",")
|
|
70
|
-
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
71
|
-
.filter(Boolean);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const result = agentFrontmatterSchema.safeParse(parsed);
|
|
71
|
+
const result = agentFrontmatterSchema.safeParse(loaded ?? {});
|
|
76
72
|
if (!result.success) {
|
|
77
|
-
|
|
78
|
-
return null;
|
|
73
|
+
throw new Error(z.prettifyError(result.error));
|
|
79
74
|
}
|
|
80
75
|
const fm = result.data;
|
|
81
76
|
return {
|
|
@@ -92,6 +87,29 @@ export function parseAgentMd(content, slug) {
|
|
|
92
87
|
systemMessage: body,
|
|
93
88
|
};
|
|
94
89
|
}
|
|
90
|
+
export function parseAgentMd(content, slug) {
|
|
91
|
+
try {
|
|
92
|
+
return parseAgentMdOrThrow(content, slug);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Invalid agent file");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function serializeAgentMd(agent) {
|
|
100
|
+
const frontmatter = {
|
|
101
|
+
name: agent.name,
|
|
102
|
+
description: agent.description,
|
|
103
|
+
model: agent.model,
|
|
104
|
+
...(agent.persistent !== undefined ? { persistent: agent.persistent } : {}),
|
|
105
|
+
...(agent.scope !== undefined ? { scope: agent.scope } : {}),
|
|
106
|
+
...(agent.skills !== undefined ? { skills: agent.skills } : {}),
|
|
107
|
+
...(agent.tools !== undefined ? { tools: agent.tools } : {}),
|
|
108
|
+
...(agent.mcpServers !== undefined ? { mcpServers: agent.mcpServers } : {}),
|
|
109
|
+
...(agent.allowedPaths !== undefined ? { allowed_paths: agent.allowedPaths } : {}),
|
|
110
|
+
};
|
|
111
|
+
return `---\n${yamlDump(frontmatter, { lineWidth: -1 })}---\n\n${agent.systemMessage}`;
|
|
112
|
+
}
|
|
95
113
|
/** Scan ~/.chapterhouse/agents/ for .agent.md files and load configs. */
|
|
96
114
|
export function loadAgents() {
|
|
97
115
|
if (!existsSync(AGENTS_DIR)) {
|
|
@@ -132,6 +150,49 @@ export function getAgent(nameOrSlug) {
|
|
|
132
150
|
export function getAgentRegistry() {
|
|
133
151
|
return [...agentRegistry];
|
|
134
152
|
}
|
|
153
|
+
export function setAgentSaveRuntimeHooks(hooks) {
|
|
154
|
+
agentSaveRuntimeHooks = {
|
|
155
|
+
...agentSaveRuntimeHooks,
|
|
156
|
+
...hooks,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function resetAgentSaveRuntimeHooks() {
|
|
160
|
+
agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
161
|
+
}
|
|
162
|
+
export async function notifyAgentSaved(slug, savedConfig) {
|
|
163
|
+
const config = savedConfig ?? parseAgentMdOrThrow(readFileSync(join(AGENTS_DIR, `${slug}.agent.md`), "utf-8"), slug);
|
|
164
|
+
const existingIndex = agentRegistry.findIndex((entry) => entry.slug === slug);
|
|
165
|
+
if (existingIndex >= 0) {
|
|
166
|
+
agentRegistry[existingIndex] = config;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
agentRegistry.push(config);
|
|
170
|
+
}
|
|
171
|
+
void import("./orchestrator.js").then(({ enqueueForSse }) => {
|
|
172
|
+
enqueueForSse({ type: "agent_saved", slug });
|
|
173
|
+
}).catch((err) => {
|
|
174
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Failed to emit agent_saved SSE event");
|
|
175
|
+
});
|
|
176
|
+
const emitReloaded = () => {
|
|
177
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
178
|
+
type: "agent_reloaded",
|
|
179
|
+
slug,
|
|
180
|
+
reason: "session_restart",
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
const result = await agentSaveRuntimeHooks.reloadPersistentSession(slug, emitReloaded);
|
|
184
|
+
if (result === "scheduled") {
|
|
185
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
186
|
+
type: "agent_reload_pending",
|
|
187
|
+
slug,
|
|
188
|
+
reason: "in_flight",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (result === "reloaded") {
|
|
193
|
+
emitReloaded();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
135
196
|
/** Copy bundled agents to ~/.chapterhouse/agents/, updating stale copies when the bundled version changes.
|
|
136
197
|
* Respects user customizations: if the user edited the deployed file after our last sync, we skip it. */
|
|
137
198
|
export function ensureDefaultAgents() {
|
|
@@ -186,16 +247,14 @@ export function createAgentFile(slug, name, description, model, systemPrompt, sk
|
|
|
186
247
|
if (existsSync(filePath)) {
|
|
187
248
|
return `Agent '${slug}' already exists. Edit it directly or remove it first.`;
|
|
188
249
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
frontmatter += "\n---\n\n";
|
|
198
|
-
writeFileSync(filePath, frontmatter + systemPrompt + "\n");
|
|
250
|
+
writeFileSync(filePath, serializeAgentMd({
|
|
251
|
+
name,
|
|
252
|
+
description,
|
|
253
|
+
model,
|
|
254
|
+
skills,
|
|
255
|
+
tools,
|
|
256
|
+
systemMessage: `${systemPrompt}\n`,
|
|
257
|
+
}));
|
|
199
258
|
return null;
|
|
200
259
|
}
|
|
201
260
|
/** Remove an agent .md file. Returns error string or null on success. */
|
|
@@ -203,7 +262,7 @@ export function removeAgentFile(slug) {
|
|
|
203
262
|
if (!SLUG_REGEX.test(slug)) {
|
|
204
263
|
return `Invalid slug '${slug}'.`;
|
|
205
264
|
}
|
|
206
|
-
if (
|
|
265
|
+
if (isBuiltinAgent(slug)) {
|
|
207
266
|
return `Cannot remove built-in agent '${slug}'. You can edit its file instead.`;
|
|
208
267
|
}
|
|
209
268
|
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
@@ -240,7 +299,7 @@ Chapterhouse agent memory follows a three-tier memory model: **read** with \`mem
|
|
|
240
299
|
### Shared Wiki
|
|
241
300
|
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.
|
|
242
301
|
|
|
243
|
-
Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update
|
|
302
|
+
Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\` and \`wiki_ingest_source\` as write-sensitive workflows, and use \`memory_propose\` for agent-memory handoff. Before using wiki write tools, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
|
|
244
303
|
|
|
245
304
|
### Communication
|
|
246
305
|
- You receive tasks from @chapterhouse (the orchestrator) or directly from the user
|
|
@@ -281,8 +340,7 @@ export function buildAgentRoster() {
|
|
|
281
340
|
}
|
|
282
341
|
// The wiki tools that every agent gets regardless of tool config
|
|
283
342
|
const WIKI_TOOL_NAMES = new Set([
|
|
284
|
-
"wiki_search", "wiki_read", "wiki_update", "
|
|
285
|
-
"wiki_ingest", "wiki_lint", "wiki_rebuild_index",
|
|
343
|
+
"wiki_search", "wiki_read", "wiki_update", "wiki_append_timeline", "wiki_ingest_source",
|
|
286
344
|
"memory_recall", "memory_propose", "memory_list_action_items",
|
|
287
345
|
]);
|
|
288
346
|
// Management tools that only @chapterhouse should have
|
|
@@ -292,7 +350,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
|
|
|
292
350
|
"switch_model", "toggle_auto", "list_models",
|
|
293
351
|
"restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
|
|
294
352
|
"list_machine_sessions", "attach_machine_session",
|
|
295
|
-
"memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
|
|
353
|
+
"memory_remember", "memory_set_scope", "memory_housekeep", "memory_reflect", "memory_promote", "memory_demote",
|
|
296
354
|
"memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
|
|
297
355
|
]);
|
|
298
356
|
export function getCurrentToolAgentSlug() {
|
|
@@ -323,6 +381,14 @@ export function filterToolsForAgent(agent, allTools) {
|
|
|
323
381
|
}
|
|
324
382
|
return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
|
|
325
383
|
}
|
|
384
|
+
/** Filter MCP servers based on agent config. */
|
|
385
|
+
export function filterMcpServersForAgent(agent, allMcpServers) {
|
|
386
|
+
if (agent.mcpServers && agent.mcpServers.length > 0) {
|
|
387
|
+
const allowed = new Set(agent.mcpServers);
|
|
388
|
+
return Object.fromEntries(Object.entries(allMcpServers).filter(([name]) => allowed.has(name)));
|
|
389
|
+
}
|
|
390
|
+
return allMcpServers;
|
|
391
|
+
}
|
|
326
392
|
/** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
|
|
327
393
|
export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
|
|
328
394
|
const agent = getAgent(slug);
|
|
@@ -334,7 +400,8 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
334
400
|
? modelOverride
|
|
335
401
|
: (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
|
|
336
402
|
const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
|
|
337
|
-
const
|
|
403
|
+
const allMcpServers = loadMcpConfig();
|
|
404
|
+
const mcpServers = filterMcpServersForAgent(agent, allMcpServers);
|
|
338
405
|
const skillDirectories = getSkillDirectories();
|
|
339
406
|
const baseSystemMessage = composeAgentSystemMessage(agent);
|
|
340
407
|
const systemMessageContent = systemMessagePrefix
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
const MCP_SERVERS = {
|
|
7
|
+
filesystem: { command: "filesystem" },
|
|
8
|
+
truenas: { command: "truenas" },
|
|
9
|
+
};
|
|
10
|
+
async function loadIsolatedAgentsModule(t, agentFrontmatter) {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "chapterhouse-agents-mcp-"));
|
|
12
|
+
const agentsDir = join(root, "agents");
|
|
13
|
+
const sessionsDir = join(root, "sessions");
|
|
14
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
15
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(agentsDir, "mcp-test-agent.agent.md"), [
|
|
17
|
+
"---",
|
|
18
|
+
"name: MCP Test Agent",
|
|
19
|
+
"description: Agent used to verify MCP session config",
|
|
20
|
+
"model: claude-sonnet-4.6",
|
|
21
|
+
agentFrontmatter,
|
|
22
|
+
"---",
|
|
23
|
+
"",
|
|
24
|
+
"You are an MCP test agent.",
|
|
25
|
+
].filter(Boolean).join("\n"));
|
|
26
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
27
|
+
t.mock.module("../paths.js", {
|
|
28
|
+
namedExports: {
|
|
29
|
+
AGENTS_DIR: agentsDir,
|
|
30
|
+
SESSIONS_DIR: sessionsDir,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
t.mock.module("./mcp-config.js", {
|
|
34
|
+
namedExports: {
|
|
35
|
+
loadMcpConfig: () => MCP_SERVERS,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
t.mock.module("./skills.js", {
|
|
39
|
+
namedExports: {
|
|
40
|
+
getSkillDirectories: () => [],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
t.mock.module("../store/db.js", {
|
|
44
|
+
namedExports: {
|
|
45
|
+
getState: () => undefined,
|
|
46
|
+
setState: () => undefined,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
let createSessionOptions;
|
|
50
|
+
const agentsModule = await import(new URL(`./agents.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
51
|
+
agentsModule.loadAgents();
|
|
52
|
+
return {
|
|
53
|
+
agentsModule,
|
|
54
|
+
get createSessionOptions() {
|
|
55
|
+
return createSessionOptions;
|
|
56
|
+
},
|
|
57
|
+
set createSessionOptions(value) {
|
|
58
|
+
createSessionOptions = value;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function createAgentSession(agentsModule, setCreateSessionOptions) {
|
|
63
|
+
const client = {
|
|
64
|
+
createSession: async (options) => {
|
|
65
|
+
setCreateSessionOptions(options);
|
|
66
|
+
return {};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
await agentsModule.createEphemeralAgentSession("mcp-test-agent", client, []);
|
|
70
|
+
}
|
|
71
|
+
test("createEphemeralAgentSession only passes MCP servers listed by the agent allowlist", async (t) => {
|
|
72
|
+
const context = await loadIsolatedAgentsModule(t, "mcpServers: [truenas]");
|
|
73
|
+
await createAgentSession(context.agentsModule, (options) => {
|
|
74
|
+
context.createSessionOptions = options;
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(context.createSessionOptions?.mcpServers, {
|
|
77
|
+
truenas: { command: "truenas" },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
test("createEphemeralAgentSession passes all MCP servers when the agent has no allowlist", async (t) => {
|
|
81
|
+
const context = await loadIsolatedAgentsModule(t, "");
|
|
82
|
+
await createAgentSession(context.agentsModule, (options) => {
|
|
83
|
+
context.createSessionOptions = options;
|
|
84
|
+
});
|
|
85
|
+
assert.deepEqual(context.createSessionOptions?.mcpServers, MCP_SERVERS);
|
|
86
|
+
});
|
|
87
|
+
//# sourceMappingURL=agents.mcp-servers.test.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseAgentMd } from "./agents.js";
|
|
4
|
+
test("parseAgentMd parses block-style YAML arrays", () => {
|
|
5
|
+
const agent = parseAgentMd([
|
|
6
|
+
"---",
|
|
7
|
+
"name: Designer",
|
|
8
|
+
"description: Handles UI flows",
|
|
9
|
+
"model: claude-sonnet-4.6",
|
|
10
|
+
"skills:",
|
|
11
|
+
" - frontend-design",
|
|
12
|
+
" - ux-copy",
|
|
13
|
+
"tools:",
|
|
14
|
+
" - read",
|
|
15
|
+
" - write",
|
|
16
|
+
"---",
|
|
17
|
+
"",
|
|
18
|
+
"You are Designer.",
|
|
19
|
+
].join("\n"), "designer");
|
|
20
|
+
assert.ok(agent, "agent charter should still parse");
|
|
21
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
22
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
23
|
+
});
|
|
24
|
+
test("parseAgentMd still parses inline YAML arrays", () => {
|
|
25
|
+
const agent = parseAgentMd([
|
|
26
|
+
"---",
|
|
27
|
+
"name: Designer",
|
|
28
|
+
"description: Handles UI flows",
|
|
29
|
+
"model: claude-sonnet-4.6",
|
|
30
|
+
"skills: [frontend-design, ux-copy]",
|
|
31
|
+
"tools: [read, write]",
|
|
32
|
+
"---",
|
|
33
|
+
"",
|
|
34
|
+
"You are Designer.",
|
|
35
|
+
].join("\n"), "designer");
|
|
36
|
+
assert.ok(agent, "agent charter should parse");
|
|
37
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
38
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
39
|
+
});
|
|
40
|
+
test("parseAgentMd tolerates missing optional fields", () => {
|
|
41
|
+
const agent = parseAgentMd([
|
|
42
|
+
"---",
|
|
43
|
+
"name: Designer",
|
|
44
|
+
"description: Handles UI flows",
|
|
45
|
+
"model: claude-sonnet-4.6",
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
"You are Designer.",
|
|
49
|
+
].join("\n"), "designer");
|
|
50
|
+
assert.ok(agent, "agent charter should parse");
|
|
51
|
+
assert.equal(agent.skills, undefined);
|
|
52
|
+
assert.equal(agent.tools, undefined);
|
|
53
|
+
assert.equal(agent.mcpServers, undefined);
|
|
54
|
+
assert.equal(agent.allowedPaths, undefined);
|
|
55
|
+
});
|
|
56
|
+
test("parseAgentMd returns null for invalid YAML frontmatter", () => {
|
|
57
|
+
const agent = parseAgentMd([
|
|
58
|
+
"---",
|
|
59
|
+
"name: Designer",
|
|
60
|
+
"description: Handles UI flows",
|
|
61
|
+
"model: claude-sonnet-4.6",
|
|
62
|
+
"skills: [frontend-design",
|
|
63
|
+
"---",
|
|
64
|
+
"",
|
|
65
|
+
"You are Designer.",
|
|
66
|
+
].join("\n"), "designer");
|
|
67
|
+
assert.equal(agent, null);
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=agents.parse.test.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import test from "node:test";
|
|
3
|
-
import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, parseAgentMd, withToolTaskContext, } from "./agents.js";
|
|
5
|
+
import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, getAgentRegistry, parseAgentMd, withToolTaskContext, } from "./agents.js";
|
|
4
6
|
function makeAgent(slug) {
|
|
5
7
|
return {
|
|
6
8
|
slug,
|
|
@@ -48,7 +50,8 @@ test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions",
|
|
|
48
50
|
for (const slug of ["coder", "general-purpose"]) {
|
|
49
51
|
const message = composeAgentSystemMessage(makeAgent(slug));
|
|
50
52
|
assert.match(message, /invoke `wiki-conventions` before wiki writes/i);
|
|
51
|
-
assert.match(message, /wiki_update[\s\S]{0,
|
|
53
|
+
assert.match(message, /wiki_update[\s\S]{0,120}wiki_ingest_source[\s\S]{0,120}memory_propose/i);
|
|
54
|
+
assert.doesNotMatch(message, /`remember`|`forget`|`wiki_ingest`(?!_source)|`wiki_lint`|`wiki_rebuild_index`/);
|
|
52
55
|
assert.match(message, /read `pages\/index\.md`/i);
|
|
53
56
|
assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
|
|
54
57
|
}
|
|
@@ -76,6 +79,30 @@ test("parseAgentMd detects persistent agent scope from charter frontmatter", ()
|
|
|
76
79
|
assert.equal(agent.persistent, true);
|
|
77
80
|
assert.equal(agent.scope, "infra");
|
|
78
81
|
});
|
|
82
|
+
test("parseAgentMd preserves block-style skills arrays from charter frontmatter", () => {
|
|
83
|
+
const agent = parseAgentMd([
|
|
84
|
+
"---",
|
|
85
|
+
"name: Designer",
|
|
86
|
+
"description: UI/UX design specialist",
|
|
87
|
+
"model: claude-opus-4.6",
|
|
88
|
+
"skills:",
|
|
89
|
+
" - frontend-design",
|
|
90
|
+
" - accessibility-review",
|
|
91
|
+
"---",
|
|
92
|
+
"",
|
|
93
|
+
"You are the Designer.",
|
|
94
|
+
].join("\n"), "designer");
|
|
95
|
+
assert.ok(agent, "agent charter should parse");
|
|
96
|
+
assert.deepEqual(agent.skills, ["frontend-design", "accessibility-review"]);
|
|
97
|
+
});
|
|
98
|
+
test("korg charter is persistent and standardizes on the Summary heading", () => {
|
|
99
|
+
const charter = readFileSync(join(process.cwd(), "agents", "korg.agent.md"), "utf8");
|
|
100
|
+
const agent = parseAgentMd(charter, "korg");
|
|
101
|
+
assert.ok(agent, "korg charter should parse");
|
|
102
|
+
assert.equal(agent.persistent, true);
|
|
103
|
+
assert.match(charter, /## Summary/);
|
|
104
|
+
assert.doesNotMatch(charter, /## Compiled Truth/);
|
|
105
|
+
});
|
|
79
106
|
test("persistent agents cannot receive scope-changing management tools", () => {
|
|
80
107
|
const agent = {
|
|
81
108
|
...makeAgent("bellonda"),
|
|
@@ -102,4 +129,112 @@ test("bindToolsToAgent uses the per-turn task context when no fixed task id is p
|
|
|
102
129
|
const taskId = await withToolTaskContext("delegated-persistent-001", () => tools[0].handler({}, {}));
|
|
103
130
|
assert.equal(taskId, "delegated-persistent-001");
|
|
104
131
|
});
|
|
132
|
+
test("notifyAgentSaved uses the provided charter instead of rereading from CHAPTERHOUSE_HOME", async () => {
|
|
133
|
+
const slug = "notify-agent-saved-inline-charter";
|
|
134
|
+
const agents = await import("./agents.js");
|
|
135
|
+
const config = {
|
|
136
|
+
...makeAgent(slug),
|
|
137
|
+
persistent: true,
|
|
138
|
+
scope: "infra",
|
|
139
|
+
};
|
|
140
|
+
agents.setAgentSaveRuntimeHooks({
|
|
141
|
+
reloadPersistentSession: async () => "none",
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
await agents.notifyAgentSaved(slug, config);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
148
|
+
}
|
|
149
|
+
const saved = getAgentRegistry().find((entry) => entry.slug === slug);
|
|
150
|
+
assert.deepEqual(saved, config);
|
|
151
|
+
});
|
|
152
|
+
test("notifyAgentSaved reloads idle persistent sessions and emits a restart event", async () => {
|
|
153
|
+
const events = [];
|
|
154
|
+
const reloads = [];
|
|
155
|
+
const agents = await import("./agents.js");
|
|
156
|
+
const config = {
|
|
157
|
+
...makeAgent("bellonda"),
|
|
158
|
+
persistent: true,
|
|
159
|
+
scope: "infra",
|
|
160
|
+
};
|
|
161
|
+
agents.setAgentSaveRuntimeHooks({
|
|
162
|
+
reloadPersistentSession: async (slug) => {
|
|
163
|
+
reloads.push(slug);
|
|
164
|
+
return "reloaded";
|
|
165
|
+
},
|
|
166
|
+
emitAgentReloadEvent: (event) => {
|
|
167
|
+
events.push(event);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
175
|
+
}
|
|
176
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
177
|
+
assert.deepEqual(events, [{ type: "agent_reloaded", slug: "bellonda", reason: "session_restart" }]);
|
|
178
|
+
});
|
|
179
|
+
test("notifyAgentSaved marks in-flight persistent sessions as pending and emits restart after the deferred reload", async () => {
|
|
180
|
+
const events = [];
|
|
181
|
+
const reloads = [];
|
|
182
|
+
const agents = await import("./agents.js");
|
|
183
|
+
const config = {
|
|
184
|
+
...makeAgent("bellonda"),
|
|
185
|
+
persistent: true,
|
|
186
|
+
scope: "infra",
|
|
187
|
+
};
|
|
188
|
+
let finishReload;
|
|
189
|
+
agents.setAgentSaveRuntimeHooks({
|
|
190
|
+
reloadPersistentSession: async (slug, onReloaded) => {
|
|
191
|
+
reloads.push(slug);
|
|
192
|
+
finishReload = onReloaded;
|
|
193
|
+
return "scheduled";
|
|
194
|
+
},
|
|
195
|
+
emitAgentReloadEvent: (event) => {
|
|
196
|
+
events.push(event);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
201
|
+
assert.deepEqual(events, [{ type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" }]);
|
|
202
|
+
finishReload?.();
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
206
|
+
}
|
|
207
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
208
|
+
assert.deepEqual(events, [
|
|
209
|
+
{ type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" },
|
|
210
|
+
{ type: "agent_reloaded", slug: "bellonda", reason: "session_restart" },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
test("notifyAgentSaved leaves agents without open persistent sessions alone", async () => {
|
|
214
|
+
const events = [];
|
|
215
|
+
const reloads = [];
|
|
216
|
+
const agents = await import("./agents.js");
|
|
217
|
+
const config = {
|
|
218
|
+
...makeAgent("bellonda"),
|
|
219
|
+
persistent: true,
|
|
220
|
+
scope: "infra",
|
|
221
|
+
};
|
|
222
|
+
agents.setAgentSaveRuntimeHooks({
|
|
223
|
+
reloadPersistentSession: async (slug) => {
|
|
224
|
+
reloads.push(slug);
|
|
225
|
+
return "none";
|
|
226
|
+
},
|
|
227
|
+
emitAgentReloadEvent: (event) => {
|
|
228
|
+
events.push(event);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
236
|
+
}
|
|
237
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
238
|
+
assert.deepEqual(events, []);
|
|
239
|
+
});
|
|
105
240
|
//# sourceMappingURL=agents.test.js.map
|