chapterhouse 0.11.3 → 0.12.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/chapterhouse.agent.md +1 -1
- package/agents/coder.agent.md +1 -1
- package/dist/api/routes/memory.js +195 -19
- package/dist/api/routes/memory.test.js +108 -0
- package/dist/copilot/agents.js +2 -0
- package/dist/copilot/agents.mcp-servers.test.js +9 -0
- package/dist/copilot/builtin-tools.js +17 -0
- package/dist/copilot/orchestrator.js +3 -0
- package/dist/copilot/orchestrator.test.js +7 -0
- package/dist/shared/api-schemas.js +1 -0
- package/package.json +1 -1
- package/web/dist/assets/{WikiEdit-CXNLuJUo.js → WikiEdit-BZYsJt-4.js} +2 -2
- package/web/dist/assets/{WikiEdit-CXNLuJUo.js.map → WikiEdit-BZYsJt-4.js.map} +1 -1
- package/web/dist/assets/{WikiGraph-SWPuU0-f.js → WikiGraph-B7ZZWSa3.js} +2 -2
- package/web/dist/assets/{WikiGraph-SWPuU0-f.js.map → WikiGraph-B7ZZWSa3.js.map} +1 -1
- package/web/dist/assets/{index-D7CVlJKJ.js → index-gV0gH0Oi.js} +80 -80
- package/web/dist/assets/index-gV0gH0Oi.js.map +1 -0
- package/web/dist/assets/{index-Benag_dz.css → index-v0vtknrU.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D7CVlJKJ.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: Chapterhouse
|
|
3
3
|
description: Orchestrator — routes tasks to specialist agents and handles direct conversation
|
|
4
|
-
model: claude-
|
|
4
|
+
model: claude-opus-4.7
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
You are Chapterhouse, a team-level AI assistant for engineering teams running 24/7 on the user's machine. You are the engineering team's always-on assistant.
|
package/agents/coder.agent.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: Coder
|
|
3
3
|
description: Software engineering specialist — implementation, debugging, refactoring, deployment
|
|
4
|
-
model: gpt-5.
|
|
4
|
+
model: gpt-5.5
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
You are Coder, a software engineering specialist agent within Chapterhouse. You handle all coding tasks with precision and expertise.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getActiveScope, setActiveScope } from "../../memory/active-scope.js";
|
|
4
|
+
import { recordActionItem } from "../../memory/action-items.js";
|
|
4
5
|
import { recordDecision } from "../../memory/decisions.js";
|
|
5
6
|
import { upsertEntity } from "../../memory/entities.js";
|
|
6
7
|
import { handleGitCommitHook, handlePrMergeHook } from "../../memory/hooks.js";
|
|
@@ -24,7 +25,8 @@ const setActiveScopeSchema = z.object({
|
|
|
24
25
|
scope: z.string().nullable(),
|
|
25
26
|
});
|
|
26
27
|
const memoryEntriesQuerySchema = z.object({
|
|
27
|
-
store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
|
|
28
|
+
store: z.enum(["observations", "decisions", "entities", "action_items", "patterns"]).optional(),
|
|
29
|
+
kind: z.enum(["observation", "decision", "entity", "action_item", "pattern"]).optional(),
|
|
28
30
|
tier: z.enum(["hot", "warm", "cold"]).optional(),
|
|
29
31
|
cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
|
|
30
32
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
@@ -43,6 +45,9 @@ const inboxRouteSchema = z.object({
|
|
|
43
45
|
reason: z.string().optional(),
|
|
44
46
|
target_scope: z.string().optional(),
|
|
45
47
|
});
|
|
48
|
+
const inboxRejectSchema = z.object({
|
|
49
|
+
reason: requiredString("Missing 'reason' in request body"),
|
|
50
|
+
});
|
|
46
51
|
const gitCommitHookSchema = z.object({
|
|
47
52
|
message: requiredString("Missing 'message' in request body"),
|
|
48
53
|
stat: z.string().optional(),
|
|
@@ -53,6 +58,130 @@ const prMergeHookSchema = z.object({
|
|
|
53
58
|
body: z.string().optional(),
|
|
54
59
|
files_changed: z.array(z.string()).optional(),
|
|
55
60
|
});
|
|
61
|
+
function sendError(res, status, message) {
|
|
62
|
+
res.status(status).type("application/json").send(JSON.stringify({ error: message }));
|
|
63
|
+
}
|
|
64
|
+
function scopeSlugForInboxItem(item) {
|
|
65
|
+
const envelope = JSON.parse(item.payload);
|
|
66
|
+
if (typeof envelope.scope_slug === "string" && envelope.scope_slug.trim()) {
|
|
67
|
+
return envelope.scope_slug;
|
|
68
|
+
}
|
|
69
|
+
if (item.scopeId) {
|
|
70
|
+
const scope = getScope(item.scopeId);
|
|
71
|
+
if (scope) {
|
|
72
|
+
return scope.slug;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const activeScope = getActiveScope();
|
|
76
|
+
if (activeScope) {
|
|
77
|
+
return activeScope.slug;
|
|
78
|
+
}
|
|
79
|
+
const fallback = getScope("chapterhouse");
|
|
80
|
+
if (fallback) {
|
|
81
|
+
return fallback.slug;
|
|
82
|
+
}
|
|
83
|
+
throw new Error("No memory scope could be resolved for this proposal.");
|
|
84
|
+
}
|
|
85
|
+
function rememberAcceptedProposal(kind, scopeSlug, payload, source, confidence, sourceAgent) {
|
|
86
|
+
const scope = getScope(scopeSlug);
|
|
87
|
+
if (!scope) {
|
|
88
|
+
throw new Error(`Unknown memory scope '${scopeSlug}'.`);
|
|
89
|
+
}
|
|
90
|
+
const payloadRecord = payload;
|
|
91
|
+
if (kind === "observation") {
|
|
92
|
+
const content = typeof payloadRecord.content === "string" ? payloadRecord.content.trim() : "";
|
|
93
|
+
if (!content) {
|
|
94
|
+
throw new Error("Observation proposal payload requires content.");
|
|
95
|
+
}
|
|
96
|
+
recordObservation({
|
|
97
|
+
scope_id: scope.id,
|
|
98
|
+
entity_id: typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined,
|
|
99
|
+
content,
|
|
100
|
+
source: typeof payloadRecord.source === "string" ? payloadRecord.source : source,
|
|
101
|
+
confidence,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (kind === "decision") {
|
|
106
|
+
const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
|
|
107
|
+
if (!title) {
|
|
108
|
+
throw new Error("Decision proposal payload requires title.");
|
|
109
|
+
}
|
|
110
|
+
recordDecision({
|
|
111
|
+
scope_id: scope.id,
|
|
112
|
+
title,
|
|
113
|
+
rationale: typeof payloadRecord.rationale === "string" ? payloadRecord.rationale : title,
|
|
114
|
+
decided_at: typeof payloadRecord.decided_at === "string" ? payloadRecord.decided_at : undefined,
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (kind === "action_item") {
|
|
119
|
+
const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
|
|
120
|
+
if (!title) {
|
|
121
|
+
throw new Error("Action item proposal payload requires title.");
|
|
122
|
+
}
|
|
123
|
+
const entity = typeof payloadRecord.entity_name === "string" && typeof payloadRecord.entity_kind === "string"
|
|
124
|
+
? upsertEntity({
|
|
125
|
+
scope_id: scope.id,
|
|
126
|
+
kind: payloadRecord.entity_kind,
|
|
127
|
+
name: payloadRecord.entity_name,
|
|
128
|
+
confidence,
|
|
129
|
+
})
|
|
130
|
+
: undefined;
|
|
131
|
+
recordActionItem({
|
|
132
|
+
scope_id: scope.id,
|
|
133
|
+
entity_id: entity?.id ?? (typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined),
|
|
134
|
+
title,
|
|
135
|
+
detail: typeof payloadRecord.detail === "string" ? payloadRecord.detail : undefined,
|
|
136
|
+
due_at: typeof payloadRecord.due_at === "string" ? payloadRecord.due_at : undefined,
|
|
137
|
+
source: sourceAgent ? `subagent_proposal:${sourceAgent}` : source,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const entityKind = typeof payloadRecord.entity_kind === "string" ? payloadRecord.entity_kind :
|
|
142
|
+
typeof payloadRecord.kind === "string" ? payloadRecord.kind :
|
|
143
|
+
"";
|
|
144
|
+
const name = typeof payloadRecord.name === "string" ? payloadRecord.name.trim() : "";
|
|
145
|
+
if (!entityKind || !name) {
|
|
146
|
+
throw new Error("Entity proposal payload requires entity_kind and name.");
|
|
147
|
+
}
|
|
148
|
+
upsertEntity({
|
|
149
|
+
scope_id: scope.id,
|
|
150
|
+
kind: entityKind,
|
|
151
|
+
name,
|
|
152
|
+
summary: typeof payloadRecord.summary === "string" ? payloadRecord.summary : undefined,
|
|
153
|
+
confidence,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function acceptInboxItem(id) {
|
|
157
|
+
const item = getInboxItem(id);
|
|
158
|
+
if (!item) {
|
|
159
|
+
throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
|
|
160
|
+
}
|
|
161
|
+
if (item.status !== "pending") {
|
|
162
|
+
throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
|
|
163
|
+
}
|
|
164
|
+
const envelope = JSON.parse(item.payload);
|
|
165
|
+
rememberAcceptedProposal(envelope.kind, scopeSlugForInboxItem(item), envelope.payload, `agent:${item.sourceAgent}`, envelope.confidence, item.sourceAgent);
|
|
166
|
+
resolveInboxItem(id, "accepted", "Accepted via web UI");
|
|
167
|
+
}
|
|
168
|
+
function rejectInboxItem(id, reason) {
|
|
169
|
+
const item = getInboxItem(id);
|
|
170
|
+
if (!item) {
|
|
171
|
+
throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
|
|
172
|
+
}
|
|
173
|
+
if (item.status !== "pending") {
|
|
174
|
+
throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
|
|
175
|
+
}
|
|
176
|
+
resolveInboxItem(id, "rejected", reason);
|
|
177
|
+
}
|
|
178
|
+
function parseInboxId(raw) {
|
|
179
|
+
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
|
180
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
181
|
+
throw Object.assign(new Error("Invalid inbox item id"), { statusCode: 400 });
|
|
182
|
+
}
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
56
185
|
export function createMemoryRouter(options) {
|
|
57
186
|
const { authMiddleware } = options;
|
|
58
187
|
const router = Router();
|
|
@@ -74,7 +203,7 @@ export function createMemoryRouter(options) {
|
|
|
74
203
|
sendJson(res, SetActiveScopeResponseSchema, { ok: true, scope: scope?.slug ?? null });
|
|
75
204
|
}
|
|
76
205
|
catch (err) {
|
|
77
|
-
res
|
|
206
|
+
sendError(res, 404, err instanceof Error ? err.message : String(err));
|
|
78
207
|
}
|
|
79
208
|
});
|
|
80
209
|
router.get("/api/memory/scopes", (_req, res) => {
|
|
@@ -87,6 +216,7 @@ export function createMemoryRouter(options) {
|
|
|
87
216
|
decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
|
|
88
217
|
entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
|
|
89
218
|
action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
|
|
219
|
+
patterns: db.prepare(`SELECT COUNT(*) AS count FROM mem_patterns WHERE scope_id = ?`).get(scope.id).count,
|
|
90
220
|
};
|
|
91
221
|
return {
|
|
92
222
|
slug: scope.slug,
|
|
@@ -113,36 +243,69 @@ export function createMemoryRouter(options) {
|
|
|
113
243
|
sendJson(res, MemoryInboxSchema, { items: result, total: result.length });
|
|
114
244
|
});
|
|
115
245
|
router.post("/api/memory/inbox/:id/route", (req, res) => {
|
|
116
|
-
const id =
|
|
117
|
-
if (!Number.isInteger(id) || id <= 0) {
|
|
118
|
-
res.status(400).json({ error: "Invalid inbox item id" });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
246
|
+
const id = parseInboxId(req.params.id);
|
|
121
247
|
const body = parseRequest(inboxRouteSchema, req.body ?? {});
|
|
122
248
|
const item = getInboxItem(id);
|
|
123
249
|
if (!item) {
|
|
124
|
-
res
|
|
250
|
+
sendError(res, 404, `Inbox item '${id}' not found`);
|
|
125
251
|
return;
|
|
126
252
|
}
|
|
127
253
|
if (item.status !== "pending") {
|
|
128
|
-
res
|
|
254
|
+
sendError(res, 409, `Inbox item '${id}' is already resolved`);
|
|
129
255
|
return;
|
|
130
256
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
257
|
+
if (body.action === "accept") {
|
|
258
|
+
acceptInboxItem(id);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
rejectInboxItem(id, body.reason ?? "Rejected via web UI");
|
|
262
|
+
}
|
|
134
263
|
log.info({ id, action: body.action }, "inbox item routed via web UI");
|
|
135
264
|
sendJson(res, InboxRouteResponseSchema, { ok: true });
|
|
136
265
|
});
|
|
266
|
+
router.post("/api/memory/inbox/:id/accept", (req, res) => {
|
|
267
|
+
try {
|
|
268
|
+
const id = parseInboxId(req.params.id);
|
|
269
|
+
acceptInboxItem(id);
|
|
270
|
+
log.info({ id }, "inbox item accepted via web UI");
|
|
271
|
+
sendJson(res, InboxRouteResponseSchema, { ok: true });
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
|
|
275
|
+
? err.statusCode
|
|
276
|
+
: 400;
|
|
277
|
+
sendError(res, statusCode, err instanceof Error ? err.message : String(err));
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
router.post("/api/memory/inbox/:id/reject", (req, res) => {
|
|
281
|
+
const body = parseRequest(inboxRejectSchema, req.body ?? {});
|
|
282
|
+
try {
|
|
283
|
+
const id = parseInboxId(req.params.id);
|
|
284
|
+
rejectInboxItem(id, body.reason);
|
|
285
|
+
log.info({ id }, "inbox item rejected via web UI");
|
|
286
|
+
sendJson(res, InboxRouteResponseSchema, { ok: true });
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
|
|
290
|
+
? err.statusCode
|
|
291
|
+
: 400;
|
|
292
|
+
sendError(res, statusCode, err instanceof Error ? err.message : String(err));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
137
295
|
router.get("/api/memory/:scope", (req, res) => {
|
|
138
296
|
const scopeSlug = String(req.params.scope);
|
|
139
297
|
const scope = getScope(scopeSlug);
|
|
140
298
|
if (!scope) {
|
|
141
|
-
res
|
|
299
|
+
sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
|
|
142
300
|
return;
|
|
143
301
|
}
|
|
144
302
|
const query = parseRequest(memoryEntriesQuerySchema, req.query);
|
|
145
|
-
const store = query.store ?? "observations"
|
|
303
|
+
const store = query.store ?? (query.kind === "observation" ? "observations" :
|
|
304
|
+
query.kind === "decision" ? "decisions" :
|
|
305
|
+
query.kind === "entity" ? "entities" :
|
|
306
|
+
query.kind === "action_item" ? "action_items" :
|
|
307
|
+
query.kind === "pattern" ? "patterns" :
|
|
308
|
+
"observations");
|
|
146
309
|
const tier = query.tier;
|
|
147
310
|
const cursor = query.cursor ? Number(query.cursor) : undefined;
|
|
148
311
|
const limit = query.limit ?? 100;
|
|
@@ -189,7 +352,7 @@ export function createMemoryRouter(options) {
|
|
|
189
352
|
total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
|
|
190
353
|
}
|
|
191
354
|
}
|
|
192
|
-
else {
|
|
355
|
+
else if (store === "action_items") {
|
|
193
356
|
const cursorClause = cursor ? "AND id < ?" : "";
|
|
194
357
|
const params = tier ? [scope.id, tier] : [scope.id];
|
|
195
358
|
const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
|
|
@@ -202,6 +365,19 @@ export function createMemoryRouter(options) {
|
|
|
202
365
|
total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
|
|
203
366
|
}
|
|
204
367
|
}
|
|
368
|
+
else {
|
|
369
|
+
const cursorClause = cursor ? "AND id < ?" : "";
|
|
370
|
+
const params = tier ? [scope.id, tier] : [scope.id];
|
|
371
|
+
const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
|
|
372
|
+
if (tier) {
|
|
373
|
+
entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? AND tier = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
|
|
374
|
+
total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
|
|
378
|
+
total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ?`).get(scope.id).n;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
205
381
|
if (entries.length > limit) {
|
|
206
382
|
const extra = entries.pop();
|
|
207
383
|
const last = entries[entries.length - 1];
|
|
@@ -213,12 +389,12 @@ export function createMemoryRouter(options) {
|
|
|
213
389
|
const scopeSlug = String(req.params.scope);
|
|
214
390
|
const scope = getScope(scopeSlug);
|
|
215
391
|
if (!scope) {
|
|
216
|
-
res
|
|
392
|
+
sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
|
|
217
393
|
return;
|
|
218
394
|
}
|
|
219
395
|
const body = parseRequest(memoryRememberSchema, req.body ?? {});
|
|
220
396
|
if (body.entity_name && !body.entity_kind) {
|
|
221
|
-
res
|
|
397
|
+
sendError(res, 400, "entity_kind is required when entity_name is provided");
|
|
222
398
|
return;
|
|
223
399
|
}
|
|
224
400
|
const kind = body.kind ?? "observation";
|
|
@@ -232,7 +408,7 @@ export function createMemoryRouter(options) {
|
|
|
232
408
|
: undefined;
|
|
233
409
|
if (kind === "decision") {
|
|
234
410
|
if (!body.title) {
|
|
235
|
-
res
|
|
411
|
+
sendError(res, 400, "title is required when kind='decision'");
|
|
236
412
|
return;
|
|
237
413
|
}
|
|
238
414
|
const decision = recordDecision({
|
|
@@ -277,7 +453,7 @@ export function createMemoryRouter(options) {
|
|
|
277
453
|
router.post("/api/scopes", (req, res) => {
|
|
278
454
|
const body = parseRequest(scopeCreateSchema, req.body ?? {});
|
|
279
455
|
if (getScope(body.slug)) {
|
|
280
|
-
res
|
|
456
|
+
sendError(res, 409, `Memory scope '${body.slug}' already exists`);
|
|
281
457
|
return;
|
|
282
458
|
}
|
|
283
459
|
const scope = createScope({
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
const repoRoot = process.cwd();
|
|
7
|
+
const sandboxRoot = join(repoRoot, ".test-work", `api-memory-${process.pid}`);
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
async function withMemoryApi(run) {
|
|
10
|
+
const { createMemoryRouter } = await import(new URL(`./memory.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
11
|
+
const authMiddleware = (_req, _res, next) => next();
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
app.use(createMemoryRouter({ authMiddleware }));
|
|
15
|
+
const server = app.listen(0, "127.0.0.1");
|
|
16
|
+
await new Promise((resolve) => server.once("listening", resolve));
|
|
17
|
+
const address = server.address();
|
|
18
|
+
assert.ok(address && typeof address === "object");
|
|
19
|
+
try {
|
|
20
|
+
await run(`http://127.0.0.1:${address.port}`);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function loadDbModule() {
|
|
29
|
+
return await import(new URL(`../../store/db.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
30
|
+
}
|
|
31
|
+
test.beforeEach(async () => {
|
|
32
|
+
const dbModule = await loadDbModule();
|
|
33
|
+
dbModule.closeDb();
|
|
34
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
35
|
+
mkdirSync(sandboxRoot, { recursive: true });
|
|
36
|
+
});
|
|
37
|
+
test.after(async () => {
|
|
38
|
+
const dbModule = await loadDbModule();
|
|
39
|
+
dbModule.closeDb();
|
|
40
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
test("memory API lists pattern entries and accepts PRD kind filters", async () => {
|
|
43
|
+
const { getDb } = await loadDbModule();
|
|
44
|
+
const db = getDb();
|
|
45
|
+
const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
|
|
46
|
+
db.prepare(`
|
|
47
|
+
INSERT INTO mem_entities (scope_id, kind, name, summary, tier, created_at, updated_at)
|
|
48
|
+
VALUES (?, 'project', 'Memory API Test Entity', 'Team AI assistant', 'hot', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
49
|
+
`).run(scope.id);
|
|
50
|
+
db.prepare(`
|
|
51
|
+
INSERT INTO mem_patterns (scope_id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated)
|
|
52
|
+
VALUES (?, 'Agents prefer scoped memory', 'Repeated observations show agents should stay scoped.', '[1,2,3]', 0.82, 'warm', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
53
|
+
`).run(scope.id);
|
|
54
|
+
await withMemoryApi(async (baseUrl) => {
|
|
55
|
+
const entityResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=entity&tier=hot`);
|
|
56
|
+
assert.equal(entityResponse.status, 200);
|
|
57
|
+
const entityPayload = await entityResponse.json();
|
|
58
|
+
assert.equal(entityPayload.total, 1);
|
|
59
|
+
assert.equal(entityPayload.entries[0]?.name, "Memory API Test Entity");
|
|
60
|
+
const patternResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=pattern`);
|
|
61
|
+
assert.equal(patternResponse.status, 200);
|
|
62
|
+
const patternPayload = await patternResponse.json();
|
|
63
|
+
assert.equal(patternPayload.total, 1);
|
|
64
|
+
assert.equal(patternPayload.entries[0]?.title, "Agents prefer scoped memory");
|
|
65
|
+
assert.equal(patternPayload.entries[0]?.summary, "Repeated observations show agents should stay scoped.");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
test("memory API accepts and rejects inbox proposals through explicit endpoints", async () => {
|
|
69
|
+
const { getDb } = await loadDbModule();
|
|
70
|
+
const db = getDb();
|
|
71
|
+
const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
|
|
72
|
+
const first = db.prepare(`
|
|
73
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
|
|
74
|
+
VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"keep"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
|
|
75
|
+
`).run(scope.id).lastInsertRowid;
|
|
76
|
+
const second = db.prepare(`
|
|
77
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
|
|
78
|
+
VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"drop"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
|
|
79
|
+
`).run(scope.id).lastInsertRowid;
|
|
80
|
+
await withMemoryApi(async (baseUrl) => {
|
|
81
|
+
const acceptResponse = await fetch(`${baseUrl}/api/memory/inbox/${first}/accept`, { method: "POST" });
|
|
82
|
+
assert.equal(acceptResponse.status, 200);
|
|
83
|
+
assert.deepEqual(await acceptResponse.json(), { ok: true });
|
|
84
|
+
const rejectResponse = await fetch(`${baseUrl}/api/memory/inbox/${second}/reject`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ reason: "Not durable enough" }),
|
|
88
|
+
});
|
|
89
|
+
assert.equal(rejectResponse.status, 200);
|
|
90
|
+
assert.deepEqual(await rejectResponse.json(), { ok: true });
|
|
91
|
+
});
|
|
92
|
+
const rows = db.prepare(`
|
|
93
|
+
SELECT id, status, resolution_reason
|
|
94
|
+
FROM mem_inbox
|
|
95
|
+
ORDER BY id
|
|
96
|
+
`).all();
|
|
97
|
+
const acceptedObservation = db.prepare(`
|
|
98
|
+
SELECT content, source
|
|
99
|
+
FROM mem_observations
|
|
100
|
+
WHERE content = 'keep'
|
|
101
|
+
`).get();
|
|
102
|
+
assert.deepEqual(rows, [
|
|
103
|
+
{ id: Number(first), status: "accepted", resolution_reason: "Accepted via web UI" },
|
|
104
|
+
{ id: Number(second), status: "rejected", resolution_reason: "Not durable enough" },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(acceptedObservation, { content: "keep", source: "agent:coder" });
|
|
107
|
+
});
|
|
108
|
+
//# sourceMappingURL=memory.test.js.map
|
package/dist/copilot/agents.js
CHANGED
|
@@ -11,6 +11,7 @@ import { getState, setState } from "../store/db.js";
|
|
|
11
11
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
12
12
|
import { getCurrentDateSystemLine } from "./prompt-date.js";
|
|
13
13
|
import { getSkillDirectories } from "./skills.js";
|
|
14
|
+
import { EXCLUDED_BUILTIN_TOOLS } from "./builtin-tools.js";
|
|
14
15
|
import { childLogger } from "../util/logger.js";
|
|
15
16
|
const log = childLogger("agents");
|
|
16
17
|
const toolAgentContext = new AsyncLocalStorage();
|
|
@@ -414,6 +415,7 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
414
415
|
streaming: true,
|
|
415
416
|
systemMessage: { content: systemMessageContent },
|
|
416
417
|
tools,
|
|
418
|
+
excludedTools: EXCLUDED_BUILTIN_TOOLS,
|
|
417
419
|
mcpServers,
|
|
418
420
|
skillDirectories,
|
|
419
421
|
onPermissionRequest: approveAll,
|
|
@@ -84,4 +84,13 @@ test("createEphemeralAgentSession passes all MCP servers when the agent has no a
|
|
|
84
84
|
});
|
|
85
85
|
assert.deepEqual(context.createSessionOptions?.mcpServers, MCP_SERVERS);
|
|
86
86
|
});
|
|
87
|
+
test("createEphemeralAgentSession excludes the synchronous built-in `task` tool", async (t) => {
|
|
88
|
+
const context = await loadIsolatedAgentsModule(t, "");
|
|
89
|
+
await createAgentSession(context.agentsModule, (options) => {
|
|
90
|
+
context.createSessionOptions = options;
|
|
91
|
+
});
|
|
92
|
+
const excluded = context.createSessionOptions?.excludedTools;
|
|
93
|
+
assert.ok(Array.isArray(excluded), "createSession should receive an excludedTools array");
|
|
94
|
+
assert.ok(excluded.includes("task"), "the built-in `task` tool spawns subagents in-turn and must be excluded so delegation stays async");
|
|
95
|
+
});
|
|
87
96
|
//# sourceMappingURL=agents.mcp-servers.test.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Built-in Copilot CLI tools that Chapterhouse disables on every agent-facing
|
|
3
|
+
// session.
|
|
4
|
+
//
|
|
5
|
+
// The CLI ships a built-in `task` tool that spawns subagents *synchronously
|
|
6
|
+
// inside the current turn* — `session.sendAndWait` does not resolve until the
|
|
7
|
+
// spawned subagent finishes. That blocks the chat: the orchestrator (or a
|
|
8
|
+
// persistent agent) cannot start the next queued turn while the subagent runs.
|
|
9
|
+
//
|
|
10
|
+
// Chapterhouse has its own non-blocking delegation path — the `delegate_to_agent`
|
|
11
|
+
// tool — which dispatches the work and returns immediately, notifying the user
|
|
12
|
+
// when the task completes. To keep delegation asynchronous, the synchronous
|
|
13
|
+
// `task` tool is excluded from every session the model talks through.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Built-in CLI tool names excluded from all Chapterhouse agent sessions. */
|
|
16
|
+
export const EXCLUDED_BUILTIN_TOOLS = ["task"];
|
|
17
|
+
//# sourceMappingURL=builtin-tools.js.map
|
|
@@ -10,6 +10,7 @@ import { CHAPTERHOUSE_VERSION } from "../version.js";
|
|
|
10
10
|
import { config, DEFAULT_MODEL } from "../config.js";
|
|
11
11
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
12
12
|
import { getSkillDirectories } from "./skills.js";
|
|
13
|
+
import { EXCLUDED_BUILTIN_TOOLS } from "./builtin-tools.js";
|
|
13
14
|
import { resetClient } from "./client.js";
|
|
14
15
|
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, deleteCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
|
|
15
16
|
import { maybeWriteEpisode } from "./episode-writer.js";
|
|
@@ -385,6 +386,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
385
386
|
systemMessage: { content: systemMessageContent },
|
|
386
387
|
hooks: memoryHooks,
|
|
387
388
|
tools,
|
|
389
|
+
excludedTools: EXCLUDED_BUILTIN_TOOLS,
|
|
388
390
|
mcpServers,
|
|
389
391
|
skillDirectories,
|
|
390
392
|
onPermissionRequest: approveAll,
|
|
@@ -413,6 +415,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
413
415
|
systemMessage: { content: systemMessageContent },
|
|
414
416
|
hooks: memoryHooks,
|
|
415
417
|
tools,
|
|
418
|
+
excludedTools: EXCLUDED_BUILTIN_TOOLS,
|
|
416
419
|
mcpServers,
|
|
417
420
|
skillDirectories,
|
|
418
421
|
onPermissionRequest: approveAll,
|
|
@@ -670,6 +670,13 @@ test("initOrchestrator falls back to an available model and eagerly creates a se
|
|
|
670
670
|
assert.equal(state.systemOptions?.memorySummary, "Chapterhouse: wiki summary");
|
|
671
671
|
assert.equal(state.store.get("orchestrator_session_id"), "session-123");
|
|
672
672
|
});
|
|
673
|
+
test("initOrchestrator excludes the synchronous built-in `task` tool from the orchestrator session", async (t) => {
|
|
674
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t);
|
|
675
|
+
await orchestrator.initOrchestrator(client);
|
|
676
|
+
const excluded = state.createSessionCalls[0]?.excludedTools;
|
|
677
|
+
assert.ok(Array.isArray(excluded), "createSession should receive an excludedTools array");
|
|
678
|
+
assert.ok(excluded.includes("task"), "the built-in `task` tool spawns subagents in-turn and must be excluded so the chat does not block");
|
|
679
|
+
});
|
|
673
680
|
test("initOrchestrator passes hot-tier XML into the orchestrator system prompt when injection is enabled", async (t) => {
|
|
674
681
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
675
682
|
hotTierXml: [
|
package/package.json
CHANGED