chapterhouse 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +26 -0
- package/dist/api/server.test.js +48 -5
- package/dist/copilot/orchestrator.js +15 -1
- package/dist/copilot/orchestrator.test.js +50 -3
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.js +75 -10
- package/dist/copilot/tools.memory.test.js +94 -6
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.test.js +20 -13
- package/dist/memory/scopes.test.js +0 -24
- package/dist/store/db.js +0 -18
- package/package.json +1 -1
- package/agents/bellonda.agent.md +0 -11
- package/agents/hwi-noree.agent.md +0 -12
package/dist/api/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfig
|
|
|
32
32
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
33
33
|
import { childLogger } from "../util/logger.js";
|
|
34
34
|
import { getActiveScope } from "../memory/active-scope.js";
|
|
35
|
+
import { createScope, getScope } from "../memory/scopes.js";
|
|
35
36
|
const log = childLogger("server");
|
|
36
37
|
void searchIndex; // re-exported by index-manager; reference here documents the dep
|
|
37
38
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -81,6 +82,12 @@ const projectCreateSchema = z.object({
|
|
|
81
82
|
cwd: requiredString("Missing 'cwd' in request body")
|
|
82
83
|
.refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
|
|
83
84
|
}).strict();
|
|
85
|
+
const scopeCreateSchema = z.object({
|
|
86
|
+
slug: requiredString("Missing 'slug' in request body")
|
|
87
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
|
|
88
|
+
title: requiredString("Missing 'title' in request body"),
|
|
89
|
+
description: z.string().optional(),
|
|
90
|
+
}).strict();
|
|
84
91
|
const projectHardRulesSchema = z.object({
|
|
85
92
|
hardRules: z.object({
|
|
86
93
|
auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
|
|
@@ -892,6 +899,25 @@ app.get("/api/memory/active-scope", (_req, res) => {
|
|
|
892
899
|
title: activeScope.title,
|
|
893
900
|
});
|
|
894
901
|
});
|
|
902
|
+
app.post("/api/scopes", (req, res) => {
|
|
903
|
+
const body = parseRequest(scopeCreateSchema, req.body ?? {});
|
|
904
|
+
if (getScope(body.slug)) {
|
|
905
|
+
res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const scope = createScope({
|
|
909
|
+
slug: body.slug,
|
|
910
|
+
title: body.title,
|
|
911
|
+
description: body.description ?? "",
|
|
912
|
+
keywords: [body.slug],
|
|
913
|
+
});
|
|
914
|
+
res.status(201).json({
|
|
915
|
+
slug: scope.slug,
|
|
916
|
+
title: scope.title,
|
|
917
|
+
description: scope.description,
|
|
918
|
+
active: scope.active,
|
|
919
|
+
});
|
|
920
|
+
});
|
|
895
921
|
app.post("/api/auto", (req, res) => {
|
|
896
922
|
const body = parseRequest(autoRequestSchema, req.body ?? {});
|
|
897
923
|
const updated = updateRouterConfig(body);
|
package/dist/api/server.test.js
CHANGED
|
@@ -216,15 +216,10 @@ test("server channels route returns chapterhouse plus persistent agents in chann
|
|
|
216
216
|
const channels = await response.json();
|
|
217
217
|
assert.deepEqual(channels.map((channel) => channel.key), [
|
|
218
218
|
"default",
|
|
219
|
-
"agent:bellonda",
|
|
220
|
-
"agent:hwi-noree",
|
|
221
219
|
]);
|
|
222
220
|
assert.deepEqual(channels.map((channel) => channel.label), [
|
|
223
221
|
"# chapterhouse",
|
|
224
|
-
"# bellonda",
|
|
225
|
-
"# hwi-noree",
|
|
226
222
|
]);
|
|
227
|
-
assert.equal(channels.find((channel) => channel.key === "agent:bellonda")?.scope, "infra");
|
|
228
223
|
});
|
|
229
224
|
});
|
|
230
225
|
test("server runs in standalone mode without auth", async () => {
|
|
@@ -267,6 +262,54 @@ test("server exposes the active memory scope API and requires auth", async () =>
|
|
|
267
262
|
});
|
|
268
263
|
});
|
|
269
264
|
});
|
|
265
|
+
test("server creates memory scopes with duplicate and slug validation", async () => {
|
|
266
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
267
|
+
const unauthorized = await fetch(`${baseUrl}/api/scopes`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "content-type": "application/json" },
|
|
270
|
+
body: JSON.stringify({ slug: "docs-site", title: "Docs Site" }),
|
|
271
|
+
});
|
|
272
|
+
assert.equal(unauthorized.status, 401);
|
|
273
|
+
const created = await fetch(`${baseUrl}/api/scopes`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
authorization: authHeader,
|
|
277
|
+
"content-type": "application/json",
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
slug: "docs-site",
|
|
281
|
+
title: "Docs Site",
|
|
282
|
+
description: "Documentation publishing and content workflows",
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
assert.equal(created.status, 201);
|
|
286
|
+
assert.deepEqual(await created.json(), {
|
|
287
|
+
slug: "docs-site",
|
|
288
|
+
title: "Docs Site",
|
|
289
|
+
description: "Documentation publishing and content workflows",
|
|
290
|
+
active: true,
|
|
291
|
+
});
|
|
292
|
+
const duplicate = await fetch(`${baseUrl}/api/scopes`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
authorization: authHeader,
|
|
296
|
+
"content-type": "application/json",
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify({ slug: "docs-site", title: "Docs Site Again" }),
|
|
299
|
+
});
|
|
300
|
+
assert.equal(duplicate.status, 409);
|
|
301
|
+
assert.deepEqual(await duplicate.json(), { error: "Memory scope 'docs-site' already exists" });
|
|
302
|
+
const invalid = await fetch(`${baseUrl}/api/scopes`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: {
|
|
305
|
+
authorization: authHeader,
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify({ slug: "Docs Site", title: "Docs Site" }),
|
|
309
|
+
});
|
|
310
|
+
assert.equal(invalid.status, 400);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
270
313
|
test("server bootstrap rejects non-loopback origins", async () => {
|
|
271
314
|
await withStartedServer(async ({ baseUrl }) => {
|
|
272
315
|
const response = await fetch(`${baseUrl}/api/bootstrap`, {
|
|
@@ -359,6 +359,17 @@ function buildHotTierContext() {
|
|
|
359
359
|
}
|
|
360
360
|
return hotTierXml.trimEnd();
|
|
361
361
|
}
|
|
362
|
+
function buildPerTurnMemoryHooks(sessionKey) {
|
|
363
|
+
if (!config.memoryInjectEnabled) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
onUserPromptSubmitted: () => {
|
|
368
|
+
const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
|
|
369
|
+
return hotTierXml ? { additionalContext: hotTierXml } : undefined;
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
362
373
|
function getSystemMessageOptions(memorySummary) {
|
|
363
374
|
return {
|
|
364
375
|
selfEditEnabled: config.selfEditEnabled,
|
|
@@ -446,7 +457,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
446
457
|
}
|
|
447
458
|
void (async () => {
|
|
448
459
|
await new Promise((resolve) => setImmediate(resolve));
|
|
449
|
-
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}.
|
|
460
|
+
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.`;
|
|
450
461
|
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
451
462
|
if (done && proactiveNotifyFn) {
|
|
452
463
|
proactiveNotifyFn(text);
|
|
@@ -507,6 +518,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
507
518
|
backgroundCompactionThreshold: 0.80,
|
|
508
519
|
bufferExhaustionThreshold: 0.95,
|
|
509
520
|
};
|
|
521
|
+
const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
|
|
510
522
|
let model = config.copilotModel;
|
|
511
523
|
let systemMessageContent;
|
|
512
524
|
let sessionMode = isProjectSession ? "project" : "default";
|
|
@@ -542,6 +554,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
542
554
|
configDir: SESSIONS_DIR,
|
|
543
555
|
streaming: true,
|
|
544
556
|
systemMessage: { content: systemMessageContent },
|
|
557
|
+
hooks: memoryHooks,
|
|
545
558
|
tools,
|
|
546
559
|
mcpServers,
|
|
547
560
|
skillDirectories,
|
|
@@ -568,6 +581,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
568
581
|
configDir: SESSIONS_DIR,
|
|
569
582
|
streaming: true,
|
|
570
583
|
systemMessage: { content: systemMessageContent },
|
|
584
|
+
hooks: memoryHooks,
|
|
571
585
|
tools,
|
|
572
586
|
mcpServers,
|
|
573
587
|
skillDirectories,
|
|
@@ -3,8 +3,12 @@ import test from "node:test";
|
|
|
3
3
|
import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
|
|
4
4
|
function createFakeClient(state) {
|
|
5
5
|
class FakeSession {
|
|
6
|
+
options;
|
|
6
7
|
sessionId = "session-123";
|
|
7
8
|
listeners = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
8
12
|
on(eventName, handler) {
|
|
9
13
|
const handlers = this.listeners.get(eventName) || [];
|
|
10
14
|
handlers.push(handler);
|
|
@@ -20,6 +24,9 @@ function createFakeClient(state) {
|
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
async sendAndWait(request, _timeoutMs) {
|
|
27
|
+
const hooks = this.options.hooks;
|
|
28
|
+
const hookResult = await hooks?.onUserPromptSubmitted?.({ prompt: request.prompt }, { sessionId: this.sessionId });
|
|
29
|
+
state.promptMemoryContexts.push(hookResult?.additionalContext);
|
|
23
30
|
state.sessionPrompts.push(request);
|
|
24
31
|
if (state.sendResult === "__PENDING__") {
|
|
25
32
|
return await new Promise((_resolve, reject) => {
|
|
@@ -57,7 +64,7 @@ function createFakeClient(state) {
|
|
|
57
64
|
if (state.createSessionError) {
|
|
58
65
|
throw new Error(state.createSessionError);
|
|
59
66
|
}
|
|
60
|
-
const session = new FakeSession();
|
|
67
|
+
const session = new FakeSession(options);
|
|
61
68
|
state.lastSession = {
|
|
62
69
|
emit: (eventName, data) => session.emit(eventName, data),
|
|
63
70
|
};
|
|
@@ -108,6 +115,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
108
115
|
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
109
116
|
],
|
|
110
117
|
sendResult: "Finished successfully",
|
|
118
|
+
promptMemoryContexts: [],
|
|
111
119
|
taskEvents: new Map(),
|
|
112
120
|
projectRegistry: {},
|
|
113
121
|
resolveProjectArgs: [],
|
|
@@ -155,7 +163,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
155
163
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
156
164
|
getHotTierEntries: (scopeId) => ({
|
|
157
165
|
scope: scopeId !== undefined
|
|
158
|
-
?
|
|
166
|
+
? scopeId === state.activeScope?.id
|
|
167
|
+
? state.activeScope
|
|
168
|
+
: makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
|
|
159
169
|
: state.activeScope ?? null,
|
|
160
170
|
entities: [],
|
|
161
171
|
observations: [],
|
|
@@ -526,6 +536,43 @@ test("initOrchestrator passes hot-tier XML into the orchestrator system prompt w
|
|
|
526
536
|
assert.match(hotTierXml, /<decision id="decision-1">hi<\/decision>/);
|
|
527
537
|
assert.doesNotMatch(hotTierXml, /<memory_context[^>]*>[\s\S]*<memory_context\b/);
|
|
528
538
|
});
|
|
539
|
+
test("orchestrator refreshes hot-tier memory context for each assistant turn", async (t) => {
|
|
540
|
+
const firstMemoryContext = [
|
|
541
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:00:00.000Z\">",
|
|
542
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
543
|
+
"</memory_context>",
|
|
544
|
+
].join("\n");
|
|
545
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
546
|
+
hotTierXml: firstMemoryContext,
|
|
547
|
+
hotTierByScope: new Map([["chapterhouse", firstMemoryContext]]),
|
|
548
|
+
});
|
|
549
|
+
await orchestrator.initOrchestrator(client);
|
|
550
|
+
await new Promise((resolve) => {
|
|
551
|
+
orchestrator.sendToOrchestrator("first turn", { type: "background" }, (text, done) => {
|
|
552
|
+
if (done)
|
|
553
|
+
resolve(text);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
const secondMemoryContext = [
|
|
557
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:01:00.000Z\">",
|
|
558
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
559
|
+
" <observation id=\"observation-2\">Checkpoint wrote this between turns</observation>",
|
|
560
|
+
"</memory_context>",
|
|
561
|
+
].join("\n");
|
|
562
|
+
state.hotTierXml = secondMemoryContext;
|
|
563
|
+
state.hotTierByScope?.set("chapterhouse", secondMemoryContext);
|
|
564
|
+
await new Promise((resolve) => {
|
|
565
|
+
orchestrator.sendToOrchestrator("second turn", { type: "background" }, (text, done) => {
|
|
566
|
+
if (done)
|
|
567
|
+
resolve(text);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
assert.equal(state.createSessionCalls.length, 1, "same SDK session should handle both turns");
|
|
571
|
+
assert.equal(state.promptMemoryContexts.length, 2);
|
|
572
|
+
assert.match(state.promptMemoryContexts[0] ?? "", /Initial hot memory/);
|
|
573
|
+
assert.doesNotMatch(state.promptMemoryContexts[0] ?? "", /Checkpoint wrote this between turns/);
|
|
574
|
+
assert.match(state.promptMemoryContexts[1] ?? "", /Checkpoint wrote this between turns/);
|
|
575
|
+
});
|
|
529
576
|
test("initOrchestrator omits hot-tier XML when no active-scope memory is available", async (t) => {
|
|
530
577
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
531
578
|
hotTierXml: "",
|
|
@@ -1194,7 +1241,7 @@ test("feedAgentResult emits an attributed short agent reply before starting the
|
|
|
1194
1241
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1195
1242
|
assert.equal(await notified, "Agent complete");
|
|
1196
1243
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1197
|
-
prompt: "[Agent task completed] @coder finished task task-9.
|
|
1244
|
+
prompt: "[Agent task completed] @coder finished task task-9. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.",
|
|
1198
1245
|
}]);
|
|
1199
1246
|
assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
|
|
1200
1247
|
const started = events.filter((event) => event.type === "turn:started");
|
|
@@ -39,7 +39,7 @@ ${hotTierBlock}
|
|
|
39
39
|
You are a Node.js daemon process built with the Copilot SDK. Here's how you work:
|
|
40
40
|
|
|
41
41
|
- **Web UI**: Your primary interface. The team talks to you in a browser tab at http://localhost:7788. Messages arrive tagged with \`[via web]\`. Markdown rendering and code blocks are fully supported, so feel free to be detailed when it helps — but stay focused.
|
|
42
|
-
- **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated.
|
|
42
|
+
- **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated or system follow-ups. Only summarize background content that has not already been rendered in a separate agent bubble.
|
|
43
43
|
|
|
44
44
|
When no source tag is present, assume web.
|
|
45
45
|
|
|
@@ -68,7 +68,7 @@ The \`delegate_to_agent\` tool is **non-blocking**. It dispatches the task and r
|
|
|
68
68
|
1. When you delegate a task, acknowledge it right away. Be natural and brief: "On it — I've asked @coder to handle that." or "Sending this to @designer."
|
|
69
69
|
2. You do NOT wait for the agent to finish. The tool returns immediately.
|
|
70
70
|
3. After delegating, do NOT poll \`get_agent_result\` in a loop. Wait silently for the \`[Agent task completed]\` message to arrive automatically.
|
|
71
|
-
4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then
|
|
71
|
+
4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then follow the subagent completion rule below.
|
|
72
72
|
|
|
73
73
|
You can delegate **multiple tasks simultaneously**. Different agents can work in parallel.
|
|
74
74
|
|
|
@@ -146,7 +146,7 @@ Subagent proposals from \`memory_propose\` are processed automatically at end-of
|
|
|
146
146
|
2. **Skill-first mindset**: Search skills.sh for existing skills before building from scratch.
|
|
147
147
|
3. For execution tasks, **always** delegate to a specialist agent. You cannot write code, run commands, or read files directly.
|
|
148
148
|
4. **Announce your delegations**: Tell the user which agent you're sending work to and what the task is.
|
|
149
|
-
5. When you receive
|
|
149
|
+
5. **Subagent completion rule**: Subagent replies are already shown to the user in their own bubble. When you receive \`[Agent task completed]\`, your follow-up should be a brief acknowledgment unless you have non-obvious framing or next-step decisions to add. Do NOT restate, paraphrase, or summarize the agent's content. Do NOT re-list files changed, re-state merge SHAs, re-quote the agent's bullet points, or copy the agent's table verbatim.
|
|
150
150
|
6. If asked about status, check agent status and give a consolidated update.
|
|
151
151
|
7. You can delegate to multiple agents simultaneously — use this for parallel work.
|
|
152
152
|
8. When a task is complete, relay the results clearly.
|
|
@@ -43,6 +43,16 @@ test("orchestrator prompt tells Chapterhouse to wait for agent completion notifi
|
|
|
43
43
|
assert.match(message, /wait silently for the `\[Agent task completed\]` message/i);
|
|
44
44
|
assert.match(message, /call `get_agent_result` exactly once/i);
|
|
45
45
|
});
|
|
46
|
+
test("orchestrator prompt tells Chapterhouse not to restate already-rendered subagent replies", () => {
|
|
47
|
+
const message = getOrchestratorSystemMessage();
|
|
48
|
+
assert.match(message, /Subagent replies are already shown to the user in their own bubble/i);
|
|
49
|
+
assert.match(message, /brief acknowledgment/i);
|
|
50
|
+
assert.match(message, /Do NOT restate, paraphrase, or summarize the agent's content/i);
|
|
51
|
+
assert.match(message, /re-list files changed/i);
|
|
52
|
+
assert.match(message, /re-state merge SHAs/i);
|
|
53
|
+
assert.doesNotMatch(message, /When you receive background results, summarize the key points/i);
|
|
54
|
+
assert.doesNotMatch(message, /summarize the result and relay it to the user/i);
|
|
55
|
+
});
|
|
46
56
|
test("orchestrator prompt expands shorthand paths with the current home directory", () => {
|
|
47
57
|
const message = getOrchestratorSystemMessage();
|
|
48
58
|
assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
package/dist/copilot/tools.js
CHANGED
|
@@ -29,7 +29,7 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
|
|
|
29
29
|
import { TeamPushClient } from "../integrations/team-push.js";
|
|
30
30
|
import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
31
31
|
import { childLogger } from "../util/logger.js";
|
|
32
|
-
import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
32
|
+
import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
33
33
|
const log = childLogger("tools");
|
|
34
34
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
35
|
function yamlEscape(value) {
|
|
@@ -66,19 +66,19 @@ function requireOrchestratorMemoryWrite() {
|
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
68
|
function resolveProposalScopeSlug(requestedScopeSlug) {
|
|
69
|
-
|
|
70
|
-
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
69
|
+
if (requestedScopeSlug) {
|
|
71
70
|
return requestedScopeSlug;
|
|
72
71
|
}
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
return
|
|
72
|
+
const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
|
|
73
|
+
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
74
|
+
return getMemoryActiveScope()?.slug;
|
|
76
75
|
}
|
|
77
76
|
const agent = getAgent(agentSlug);
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
|
|
78
|
+
if (boundScope && getMemoryScope(boundScope)) {
|
|
79
|
+
return boundScope;
|
|
80
80
|
}
|
|
81
|
-
return
|
|
81
|
+
return getMemoryActiveScope()?.slug;
|
|
82
82
|
}
|
|
83
83
|
function resolveMemoryScopeForWrite(explicitScope, content) {
|
|
84
84
|
const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
|
|
@@ -138,11 +138,28 @@ const entityProposalPayloadSchema = z.object({
|
|
|
138
138
|
summary: value.summary,
|
|
139
139
|
}));
|
|
140
140
|
const actionItemProposalPayloadSchema = z.object({
|
|
141
|
-
title: z.string(),
|
|
141
|
+
title: z.string().trim().min(1, "title is required for action_item proposals."),
|
|
142
142
|
detail: z.string().optional(),
|
|
143
143
|
due_at: z.string().optional(),
|
|
144
144
|
source: z.string().optional(),
|
|
145
145
|
entity_id: z.number().int().positive().optional(),
|
|
146
|
+
entity_name: z.string().trim().min(1, "entity_name must be non-empty when provided.").optional(),
|
|
147
|
+
entity_kind: z.string().trim().min(1, "entity_kind must be non-empty when provided.").optional(),
|
|
148
|
+
}).superRefine((value, context) => {
|
|
149
|
+
if ((value.entity_name && !value.entity_kind) || (!value.entity_name && value.entity_kind)) {
|
|
150
|
+
context.addIssue({
|
|
151
|
+
code: z.ZodIssueCode.custom,
|
|
152
|
+
message: "entity_name and entity_kind must be provided together for action_item proposals.",
|
|
153
|
+
path: ["entity_name"],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (value.entity_id !== undefined && value.entity_name) {
|
|
157
|
+
context.addIssue({
|
|
158
|
+
code: z.ZodIssueCode.custom,
|
|
159
|
+
message: "Provide either entity_id or entity_name/entity_kind for action_item proposals, not both.",
|
|
160
|
+
path: ["entity_id"],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
146
163
|
});
|
|
147
164
|
const memoryProposeArgsSchema = z.object({
|
|
148
165
|
kind: z.enum(["observation", "decision", "entity", "action_item"]),
|
|
@@ -967,6 +984,54 @@ export function createTools(deps) {
|
|
|
967
984
|
}
|
|
968
985
|
},
|
|
969
986
|
}),
|
|
987
|
+
defineTool("memory_create_scope", {
|
|
988
|
+
description: "Create a new user-defined memory scope. Slugs must be unique kebab-case; baseline installs only include global and chapterhouse.",
|
|
989
|
+
parameters: z.object({
|
|
990
|
+
slug: z.string()
|
|
991
|
+
.trim()
|
|
992
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
|
|
993
|
+
title: z.string().trim().min(1, "title is required"),
|
|
994
|
+
description: z.string().optional(),
|
|
995
|
+
}),
|
|
996
|
+
handler: async (args) => {
|
|
997
|
+
const denied = requireOrchestratorMemoryWrite();
|
|
998
|
+
if (denied)
|
|
999
|
+
return denied;
|
|
1000
|
+
const parsed = z.object({
|
|
1001
|
+
slug: z.string()
|
|
1002
|
+
.trim()
|
|
1003
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be unique kebab-case"),
|
|
1004
|
+
title: z.string().trim().min(1, "title is required"),
|
|
1005
|
+
description: z.string().optional(),
|
|
1006
|
+
}).safeParse(args);
|
|
1007
|
+
if (!parsed.success) {
|
|
1008
|
+
return parsed.error.issues.map((issue) => issue.message).join("; ");
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
if (getMemoryScope(parsed.data.slug)) {
|
|
1012
|
+
return `Memory scope '${parsed.data.slug}' already exists.`;
|
|
1013
|
+
}
|
|
1014
|
+
const scope = createMemoryScope({
|
|
1015
|
+
slug: parsed.data.slug,
|
|
1016
|
+
title: parsed.data.title,
|
|
1017
|
+
description: parsed.data.description ?? "",
|
|
1018
|
+
keywords: [parsed.data.slug],
|
|
1019
|
+
});
|
|
1020
|
+
return {
|
|
1021
|
+
ok: true,
|
|
1022
|
+
scope: {
|
|
1023
|
+
slug: scope.slug,
|
|
1024
|
+
title: scope.title,
|
|
1025
|
+
description: scope.description,
|
|
1026
|
+
active: scope.active,
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
catch (err) {
|
|
1031
|
+
return err instanceof Error ? err.message : String(err);
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
}),
|
|
970
1035
|
defineTool("memory_add_action_item", {
|
|
971
1036
|
description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
|
|
972
1037
|
parameters: z.object({
|
|
@@ -85,6 +85,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
85
85
|
const memoryRemember = findTool(tools, "memory_remember");
|
|
86
86
|
const memoryRecall = findTool(tools, "memory_recall");
|
|
87
87
|
const memorySetScope = findTool(tools, "memory_set_scope");
|
|
88
|
+
const memoryCreateScope = findTool(tools, "memory_create_scope");
|
|
88
89
|
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
89
90
|
assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
|
|
90
91
|
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
@@ -121,6 +122,30 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
121
122
|
assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
|
|
122
123
|
const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
|
|
123
124
|
assert.equal(scopeResult.active_scope?.slug, "chapterhouse");
|
|
125
|
+
const createdScope = await memoryCreateScope.handler({
|
|
126
|
+
slug: "docs-site",
|
|
127
|
+
title: "Docs Site",
|
|
128
|
+
description: "Documentation publishing and content workflows",
|
|
129
|
+
}, {});
|
|
130
|
+
assert.deepEqual(createdScope, {
|
|
131
|
+
ok: true,
|
|
132
|
+
scope: {
|
|
133
|
+
slug: "docs-site",
|
|
134
|
+
title: "Docs Site",
|
|
135
|
+
description: "Documentation publishing and content workflows",
|
|
136
|
+
active: true,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const duplicateScope = await memoryCreateScope.handler({
|
|
140
|
+
slug: "docs-site",
|
|
141
|
+
title: "Docs Site Again",
|
|
142
|
+
}, {});
|
|
143
|
+
assert.match(String(duplicateScope), /already exists/i);
|
|
144
|
+
const invalidScope = await memoryCreateScope.handler({
|
|
145
|
+
slug: "Docs Site",
|
|
146
|
+
title: "Docs Site",
|
|
147
|
+
}, {});
|
|
148
|
+
assert.match(String(invalidScope), /kebab-case/i);
|
|
124
149
|
const implicitScopeRemember = await chapterhouseRemember.handler({
|
|
125
150
|
content: "Implicit active-scope writes should route to chapterhouse.",
|
|
126
151
|
kind: "observation",
|
|
@@ -151,7 +176,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
151
176
|
}, {});
|
|
152
177
|
assert.equal(typeof coderRecalled, "object");
|
|
153
178
|
assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
|
|
154
|
-
assert.ok(memoryRemember && memoryRecall && memorySetScope);
|
|
179
|
+
assert.ok(memoryRemember && memoryRecall && memorySetScope && memoryCreateScope);
|
|
155
180
|
});
|
|
156
181
|
test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
|
|
157
182
|
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
@@ -195,11 +220,10 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
|
|
|
195
220
|
assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
|
|
196
221
|
assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
|
|
197
222
|
});
|
|
198
|
-
test("memory_propose from a persistent agent
|
|
223
|
+
test("memory_propose from a persistent agent honors an explicit scope_slug", async () => {
|
|
199
224
|
const home = process.env.CHAPTERHOUSE_HOME;
|
|
200
225
|
assert.ok(home, "test home should be set");
|
|
201
|
-
const
|
|
202
|
-
const agentsDir = join(chapterhouseHome, "agents");
|
|
226
|
+
const { AGENTS_DIR: agentsDir } = await import("../paths.js");
|
|
203
227
|
mkdirSync(agentsDir, { recursive: true });
|
|
204
228
|
writeFileSync(join(agentsDir, "bellonda.agent.md"), [
|
|
205
229
|
"---",
|
|
@@ -228,7 +252,7 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
|
|
|
228
252
|
kind: "observation",
|
|
229
253
|
scope_slug: "chapterhouse",
|
|
230
254
|
payload: {
|
|
231
|
-
content: "Persistent agents
|
|
255
|
+
content: "Persistent agents can explicitly route proposals to another scope when needed.",
|
|
232
256
|
},
|
|
233
257
|
}, {}));
|
|
234
258
|
assert.equal(proposed.status, "queued");
|
|
@@ -241,7 +265,51 @@ test("memory_propose from a persistent agent is bound to that agent's scope", as
|
|
|
241
265
|
assert.equal(row.source_agent, "bellonda");
|
|
242
266
|
assert.equal(row.source_task_id, "task-persistent-scope-001");
|
|
243
267
|
const payload = JSON.parse(row.payload);
|
|
244
|
-
assert.equal(payload.scope_slug, "
|
|
268
|
+
assert.equal(payload.scope_slug, "chapterhouse");
|
|
269
|
+
});
|
|
270
|
+
test("memory_propose ignores a missing subagent bound scope and falls back to the active scope", async () => {
|
|
271
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
272
|
+
assert.ok(home, "test home should be set");
|
|
273
|
+
const { AGENTS_DIR: agentsDir } = await import("../paths.js");
|
|
274
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
275
|
+
writeFileSync(join(agentsDir, "hwi.agent.md"), [
|
|
276
|
+
"---",
|
|
277
|
+
"name: Hwi",
|
|
278
|
+
"description: Mentat of Brian's personal scope",
|
|
279
|
+
"model: claude-sonnet-4.6",
|
|
280
|
+
"persistent: true",
|
|
281
|
+
"scope: brian",
|
|
282
|
+
"---",
|
|
283
|
+
"",
|
|
284
|
+
"You are Hwi.",
|
|
285
|
+
].join("\n"));
|
|
286
|
+
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
287
|
+
agentsModule.loadAgents();
|
|
288
|
+
const tools = toolsModule.createTools({
|
|
289
|
+
client: { async listModels() { return []; } },
|
|
290
|
+
onAgentTaskComplete: () => { },
|
|
291
|
+
});
|
|
292
|
+
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
293
|
+
assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
|
|
294
|
+
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
295
|
+
const hwiTools = bindToolsToAgent("hwi", tools, "task-hwi-action-item");
|
|
296
|
+
await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
|
|
297
|
+
const proposed = await findTool(hwiTools, "memory_propose").handler({
|
|
298
|
+
kind: "action_item",
|
|
299
|
+
payload: {
|
|
300
|
+
title: "Follow up with Brian",
|
|
301
|
+
},
|
|
302
|
+
}, {});
|
|
303
|
+
assert.equal(proposed.status, "queued");
|
|
304
|
+
const row = dbModule.getDb().prepare(`
|
|
305
|
+
SELECT source_agent, payload
|
|
306
|
+
FROM mem_inbox
|
|
307
|
+
WHERE id = ?
|
|
308
|
+
`).get(proposed.proposal_id);
|
|
309
|
+
assert.ok(row, "memory_propose should insert a mem_inbox row");
|
|
310
|
+
assert.equal(row.source_agent, "hwi");
|
|
311
|
+
const payload = JSON.parse(row.payload);
|
|
312
|
+
assert.equal(payload.scope_slug, "chapterhouse");
|
|
245
313
|
});
|
|
246
314
|
test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
|
|
247
315
|
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
@@ -367,6 +435,26 @@ test("memory_propose accepts action_item proposals with a resolvable payload sha
|
|
|
367
435
|
assert.equal(payload.payload.title, "Remind infra about high disk usage");
|
|
368
436
|
assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
|
|
369
437
|
});
|
|
438
|
+
test("memory_propose rejects action_item proposals with blank titles before queuing", async () => {
|
|
439
|
+
const { toolsModule, dbModule } = await loadModules();
|
|
440
|
+
const tools = toolsModule.createTools({
|
|
441
|
+
client: { async listModels() { return []; } },
|
|
442
|
+
onAgentTaskComplete: () => { },
|
|
443
|
+
});
|
|
444
|
+
const memoryPropose = findTool(tools, "memory_propose");
|
|
445
|
+
const result = await memoryPropose.handler({
|
|
446
|
+
kind: "action_item",
|
|
447
|
+
payload: {
|
|
448
|
+
title: " ",
|
|
449
|
+
},
|
|
450
|
+
}, {});
|
|
451
|
+
assert.match(String(result), /title/i);
|
|
452
|
+
const queued = dbModule.getDb().prepare(`
|
|
453
|
+
SELECT COUNT(*) AS count
|
|
454
|
+
FROM mem_inbox
|
|
455
|
+
`).get();
|
|
456
|
+
assert.equal(queued.count, 0);
|
|
457
|
+
});
|
|
370
458
|
test("memory_propose rejects invalid proposal kinds", async () => {
|
|
371
459
|
const { toolsModule } = await loadModules();
|
|
372
460
|
const tools = toolsModule.createTools({
|
|
@@ -38,11 +38,16 @@ test("active scope can be set, read, and cleared without changing scope activati
|
|
|
38
38
|
const getActiveScope = getFunction(memoryModule, "getActiveScope");
|
|
39
39
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
40
40
|
const deactivateScope = getFunction(memoryModule, "deactivateScope");
|
|
41
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
41
42
|
assert.equal(getActiveScope(), null);
|
|
42
43
|
assert.equal(setActiveScope("chapterhouse")?.slug, "chapterhouse");
|
|
43
44
|
assert.equal(getActiveScope()?.slug, "chapterhouse");
|
|
44
|
-
const team =
|
|
45
|
-
|
|
45
|
+
const team = createScope({
|
|
46
|
+
slug: "team",
|
|
47
|
+
title: "Team",
|
|
48
|
+
description: "Team test scope",
|
|
49
|
+
keywords: ["team"],
|
|
50
|
+
});
|
|
46
51
|
const deactivated = deactivateScope(team.id);
|
|
47
52
|
assert.equal(deactivated.active, false);
|
|
48
53
|
assert.equal(getActiveScope()?.slug, "chapterhouse");
|
package/dist/memory/eot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { getAgent, loadAgents } from "../copilot/agents.js";
|
|
2
3
|
import { runOneShotPrompt } from "../copilot/oneshot.js";
|
|
3
4
|
import { childLogger } from "../util/logger.js";
|
|
4
5
|
import { recordDecision } from "./decisions.js";
|
|
@@ -6,6 +7,7 @@ import { upsertEntity } from "./entities.js";
|
|
|
6
7
|
import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
|
|
7
8
|
import { recordObservation } from "./observations.js";
|
|
8
9
|
import { recordActionItem } from "./action-items.js";
|
|
10
|
+
import { getActiveScope } from "./active-scope.js";
|
|
9
11
|
import { getScope } from "./scopes.js";
|
|
10
12
|
const log = childLogger("memory.eot");
|
|
11
13
|
function isEndOfTaskHookEnabled() {
|
|
@@ -119,7 +121,75 @@ function parseReviewerResponse(raw) {
|
|
|
119
121
|
: [],
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
|
-
function
|
|
124
|
+
function isNonEmptyString(value) {
|
|
125
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
126
|
+
}
|
|
127
|
+
function isIsoTimestamp(value) {
|
|
128
|
+
return !Number.isNaN(Date.parse(value));
|
|
129
|
+
}
|
|
130
|
+
function validateActionItemPayload(payload) {
|
|
131
|
+
const actionItem = payload;
|
|
132
|
+
if (!isNonEmptyString(actionItem.title)) {
|
|
133
|
+
throw new Error("Action item proposal payload requires a non-empty title.");
|
|
134
|
+
}
|
|
135
|
+
if (actionItem.detail !== undefined && typeof actionItem.detail !== "string") {
|
|
136
|
+
throw new Error("Action item proposal payload detail must be a string.");
|
|
137
|
+
}
|
|
138
|
+
if (actionItem.due_at !== undefined && (typeof actionItem.due_at !== "string" || !isIsoTimestamp(actionItem.due_at))) {
|
|
139
|
+
throw new Error("Action item proposal payload due_at must be an ISO timestamp.");
|
|
140
|
+
}
|
|
141
|
+
if (actionItem.entity_id !== undefined && (!Number.isInteger(actionItem.entity_id) || actionItem.entity_id <= 0)) {
|
|
142
|
+
throw new Error("Action item proposal payload entity_id must be a positive integer.");
|
|
143
|
+
}
|
|
144
|
+
if (actionItem.entity_id !== undefined && actionItem.entity_name !== undefined) {
|
|
145
|
+
throw new Error("Action item proposal payload must provide either entity_id or entity_name/entity_kind, not both.");
|
|
146
|
+
}
|
|
147
|
+
const hasEntityName = actionItem.entity_name !== undefined;
|
|
148
|
+
const hasEntityKind = actionItem.entity_kind !== undefined;
|
|
149
|
+
if (hasEntityName !== hasEntityKind) {
|
|
150
|
+
throw new Error("Action item proposal payload entity_name and entity_kind must be provided together.");
|
|
151
|
+
}
|
|
152
|
+
if (hasEntityName && !isNonEmptyString(actionItem.entity_name)) {
|
|
153
|
+
throw new Error("Action item proposal payload entity_name must be non-empty when provided.");
|
|
154
|
+
}
|
|
155
|
+
if (hasEntityKind && !isNonEmptyString(actionItem.entity_kind)) {
|
|
156
|
+
throw new Error("Action item proposal payload entity_kind must be non-empty when provided.");
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
title: actionItem.title.trim(),
|
|
160
|
+
detail: actionItem.detail,
|
|
161
|
+
due_at: actionItem.due_at,
|
|
162
|
+
source: actionItem.source,
|
|
163
|
+
entity_id: actionItem.entity_id,
|
|
164
|
+
entity_name: actionItem.entity_name?.trim(),
|
|
165
|
+
entity_kind: actionItem.entity_kind?.trim(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function getBoundScopeSlug(sourceAgent) {
|
|
169
|
+
const agent = getAgent(sourceAgent);
|
|
170
|
+
return agent?.scope ?? loadAgents().find((entry) => entry.slug === sourceAgent)?.scope;
|
|
171
|
+
}
|
|
172
|
+
function resolveAcceptedProposalScopeSlug(envelope, proposal) {
|
|
173
|
+
if (isNonEmptyString(envelope.scope_slug)) {
|
|
174
|
+
return envelope.scope_slug.trim();
|
|
175
|
+
}
|
|
176
|
+
const boundScope = getBoundScopeSlug(proposal.sourceAgent);
|
|
177
|
+
if (boundScope) {
|
|
178
|
+
return boundScope;
|
|
179
|
+
}
|
|
180
|
+
const activeScope = getActiveScope();
|
|
181
|
+
if (activeScope) {
|
|
182
|
+
return activeScope.slug;
|
|
183
|
+
}
|
|
184
|
+
if (proposal.scopeId) {
|
|
185
|
+
const queuedScope = getScope(proposal.scopeId);
|
|
186
|
+
if (queuedScope) {
|
|
187
|
+
return queuedScope.slug;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new Error("No memory scope could be resolved for this proposal.");
|
|
191
|
+
}
|
|
192
|
+
function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
|
|
123
193
|
const scope = getScope(scopeSlug);
|
|
124
194
|
if (!scope) {
|
|
125
195
|
throw new Error(`Unknown memory scope '${scopeSlug}'.`);
|
|
@@ -146,14 +216,22 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
|
|
|
146
216
|
return;
|
|
147
217
|
}
|
|
148
218
|
if (kind === "action_item") {
|
|
149
|
-
const actionItem = payload;
|
|
219
|
+
const actionItem = validateActionItemPayload(payload);
|
|
220
|
+
const entity = actionItem.entity_name && actionItem.entity_kind
|
|
221
|
+
? upsertEntity({
|
|
222
|
+
scope_id: scope.id,
|
|
223
|
+
kind: actionItem.entity_kind,
|
|
224
|
+
name: actionItem.entity_name,
|
|
225
|
+
confidence,
|
|
226
|
+
})
|
|
227
|
+
: undefined;
|
|
150
228
|
recordActionItem({
|
|
151
229
|
scope_id: scope.id,
|
|
152
|
-
entity_id: actionItem.entity_id,
|
|
230
|
+
entity_id: entity?.id ?? actionItem.entity_id,
|
|
153
231
|
title: actionItem.title,
|
|
154
232
|
detail: actionItem.detail,
|
|
155
233
|
due_at: actionItem.due_at,
|
|
156
|
-
source: actionItem.source ?? source,
|
|
234
|
+
source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
|
|
157
235
|
});
|
|
158
236
|
return;
|
|
159
237
|
}
|
|
@@ -209,11 +287,21 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
209
287
|
}
|
|
210
288
|
reviewedProposalIds.add(proposal.id);
|
|
211
289
|
if (decision.decision === "accept") {
|
|
212
|
-
|
|
213
|
-
|
|
290
|
+
if (!autoAcceptEnabled) {
|
|
291
|
+
summary.accepted++;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
214
294
|
const envelope = parseEnvelope(proposal.payload);
|
|
215
|
-
|
|
216
|
-
|
|
295
|
+
try {
|
|
296
|
+
rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
297
|
+
resolveInboxItem(proposal.id, "accepted", decision.reason);
|
|
298
|
+
summary.accepted++;
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
302
|
+
resolveInboxItem(proposal.id, "rejected", reason);
|
|
303
|
+
summary.rejected++;
|
|
304
|
+
}
|
|
217
305
|
}
|
|
218
306
|
continue;
|
|
219
307
|
}
|
package/dist/memory/eot.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
const repoRoot = process.cwd();
|
|
@@ -15,7 +15,8 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
15
15
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
16
16
|
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
17
17
|
const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
|
|
18
|
-
|
|
18
|
+
const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
|
+
return { dbModule, memoryModule, eotModule, agentsModule };
|
|
19
20
|
}
|
|
20
21
|
function getFunction(module, name) {
|
|
21
22
|
const value = module[name];
|
|
@@ -171,7 +172,6 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
|
|
|
171
172
|
payload: {
|
|
172
173
|
title: "Migrate feature ideas",
|
|
173
174
|
detail: "Move feature-ideas.md into mem_action_items.",
|
|
174
|
-
source: "subagent_proposal",
|
|
175
175
|
},
|
|
176
176
|
confidence: 0.9,
|
|
177
177
|
}));
|
|
@@ -189,11 +189,192 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
|
|
|
189
189
|
}),
|
|
190
190
|
});
|
|
191
191
|
const actionItems = listActionItems({ scope_id: chapterhouse.id });
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
const actionItem = actionItems.find((item) => item.title === "Migrate feature ideas");
|
|
193
|
+
assert.ok(actionItem);
|
|
194
|
+
assert.equal(actionItem.detail, "Move feature-ideas.md into mem_action_items.");
|
|
195
|
+
assert.equal(actionItem.status, "open");
|
|
196
|
+
assert.equal(actionItem.source, "subagent_proposal:coder");
|
|
194
197
|
const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
195
198
|
assert.equal(inbox.status, "accepted");
|
|
196
199
|
});
|
|
200
|
+
test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear reason", async () => {
|
|
201
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
|
|
202
|
+
const db = dbModule.getDb();
|
|
203
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
204
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
205
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
206
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
207
|
+
const chapterhouse = getScope("chapterhouse");
|
|
208
|
+
assert.ok(chapterhouse);
|
|
209
|
+
const inserted = db.prepare(`
|
|
210
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
211
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-invalid', 'pending')
|
|
212
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
213
|
+
kind: "action_item",
|
|
214
|
+
payload: {
|
|
215
|
+
detail: "A title is required before this can become an action item.",
|
|
216
|
+
},
|
|
217
|
+
confidence: 0.9,
|
|
218
|
+
}));
|
|
219
|
+
await runEndOfTaskMemoryHook({
|
|
220
|
+
taskId: "task-eot-action-item-invalid",
|
|
221
|
+
finalResult: "Completed and proposed a malformed follow-up.",
|
|
222
|
+
copilotClient: {},
|
|
223
|
+
callLLM: async () => JSON.stringify({
|
|
224
|
+
decisions: [{
|
|
225
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
226
|
+
decision: "accept",
|
|
227
|
+
reason: "Concrete follow-up.",
|
|
228
|
+
}],
|
|
229
|
+
implicit_memories: [],
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
|
|
233
|
+
const inbox = db.prepare(`
|
|
234
|
+
SELECT status, resolution_reason, resolved_at
|
|
235
|
+
FROM mem_inbox
|
|
236
|
+
WHERE id = ?
|
|
237
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
238
|
+
assert.equal(inbox.status, "rejected");
|
|
239
|
+
assert.match(inbox.resolution_reason ?? "", /title/i);
|
|
240
|
+
assert.ok(inbox.resolved_at);
|
|
241
|
+
});
|
|
242
|
+
test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
|
|
243
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
|
|
244
|
+
const db = dbModule.getDb();
|
|
245
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
246
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
247
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
248
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
249
|
+
const chapterhouse = getScope("chapterhouse");
|
|
250
|
+
assert.ok(chapterhouse);
|
|
251
|
+
const inserted = db.prepare(`
|
|
252
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
253
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-ambiguous-entity', 'pending')
|
|
254
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
255
|
+
kind: "action_item",
|
|
256
|
+
payload: {
|
|
257
|
+
title: "Resolve ambiguous entity link",
|
|
258
|
+
entity_id: 1,
|
|
259
|
+
entity_name: "Bellonda",
|
|
260
|
+
entity_kind: "agent",
|
|
261
|
+
},
|
|
262
|
+
confidence: 0.9,
|
|
263
|
+
}));
|
|
264
|
+
await runEndOfTaskMemoryHook({
|
|
265
|
+
taskId: "task-eot-action-item-ambiguous-entity",
|
|
266
|
+
finalResult: "Completed and proposed an ambiguous follow-up.",
|
|
267
|
+
copilotClient: {},
|
|
268
|
+
callLLM: async () => JSON.stringify({
|
|
269
|
+
decisions: [{
|
|
270
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
271
|
+
decision: "accept",
|
|
272
|
+
reason: "Concrete follow-up.",
|
|
273
|
+
}],
|
|
274
|
+
implicit_memories: [],
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
|
|
278
|
+
const inbox = db.prepare(`
|
|
279
|
+
SELECT status, resolution_reason
|
|
280
|
+
FROM mem_inbox
|
|
281
|
+
WHERE id = ?
|
|
282
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
283
|
+
assert.equal(inbox.status, "rejected");
|
|
284
|
+
assert.match(inbox.resolution_reason ?? "", /entity_id.*entity_name|entity_name.*entity_id/i);
|
|
285
|
+
});
|
|
286
|
+
test("runEndOfTaskMemoryHook falls back to the source agent bound scope for action_item proposals without scope_slug", async () => {
|
|
287
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
288
|
+
assert.ok(home, "test home should be set");
|
|
289
|
+
const { AGENTS_DIR: agentsDir } = await import("../paths.js");
|
|
290
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
291
|
+
writeFileSync(join(agentsDir, "bellonda.agent.md"), [
|
|
292
|
+
"---",
|
|
293
|
+
"name: Bellonda",
|
|
294
|
+
"description: Mentat of the infrastructure domain",
|
|
295
|
+
"model: claude-sonnet-4.6",
|
|
296
|
+
"persistent: true",
|
|
297
|
+
"scope: infra",
|
|
298
|
+
"---",
|
|
299
|
+
"",
|
|
300
|
+
"You are Bellonda.",
|
|
301
|
+
].join("\n"));
|
|
302
|
+
const { dbModule, memoryModule, eotModule, agentsModule } = await loadModules("action-item-bound-scope");
|
|
303
|
+
agentsModule.loadAgents();
|
|
304
|
+
const db = dbModule.getDb();
|
|
305
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
306
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
307
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
308
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
309
|
+
const chapterhouse = getScope("chapterhouse");
|
|
310
|
+
const infra = createScope({
|
|
311
|
+
slug: "infra",
|
|
312
|
+
title: "Infra",
|
|
313
|
+
description: "Infra test scope",
|
|
314
|
+
keywords: ["infra"],
|
|
315
|
+
});
|
|
316
|
+
assert.ok(chapterhouse);
|
|
317
|
+
const inserted = db.prepare(`
|
|
318
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
319
|
+
VALUES (?, 'memory_proposal', ?, 'bellonda', 'task-eot-action-item-bound-scope', 'pending')
|
|
320
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
321
|
+
kind: "action_item",
|
|
322
|
+
payload: {
|
|
323
|
+
title: "Review NAS disk alerts",
|
|
324
|
+
},
|
|
325
|
+
confidence: 0.9,
|
|
326
|
+
}));
|
|
327
|
+
await runEndOfTaskMemoryHook({
|
|
328
|
+
taskId: "task-eot-action-item-bound-scope",
|
|
329
|
+
finalResult: "Bellonda proposed an infra follow-up.",
|
|
330
|
+
copilotClient: {},
|
|
331
|
+
callLLM: async () => JSON.stringify({
|
|
332
|
+
decisions: [{
|
|
333
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
334
|
+
decision: "accept",
|
|
335
|
+
reason: "Concrete infra follow-up.",
|
|
336
|
+
}],
|
|
337
|
+
implicit_memories: [],
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
assert.equal(listActionItems({ scope_id: infra.id }).some((item) => item.title === "Review NAS disk alerts"), true);
|
|
341
|
+
assert.equal(listActionItems({ scope_id: chapterhouse.id }).some((item) => item.title === "Review NAS disk alerts"), false);
|
|
342
|
+
});
|
|
343
|
+
test("runEndOfTaskMemoryHook still accepts observation, decision, and entity proposals", async () => {
|
|
344
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("existing-proposal-kinds");
|
|
345
|
+
const db = dbModule.getDb();
|
|
346
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
347
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
348
|
+
const listDecisions = getFunction(memoryModule, "listDecisions");
|
|
349
|
+
const listEntities = getFunction(memoryModule, "listEntities");
|
|
350
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
351
|
+
const chapterhouse = getScope("chapterhouse");
|
|
352
|
+
assert.ok(chapterhouse);
|
|
353
|
+
const proposals = [
|
|
354
|
+
{ kind: "observation", payload: { content: "Existing observation proposals still persist." } },
|
|
355
|
+
{ kind: "decision", payload: { title: "Keep auto-accept", rationale: "It is required for EOT memory promotion." } },
|
|
356
|
+
{ kind: "entity", payload: { name: "Bellonda", entity_kind: "agent", summary: "Infrastructure mentat." } },
|
|
357
|
+
].map((envelope) => db.prepare(`
|
|
358
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
359
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-existing-kinds', 'pending')
|
|
360
|
+
`).run(chapterhouse.id, JSON.stringify(envelope)));
|
|
361
|
+
await runEndOfTaskMemoryHook({
|
|
362
|
+
taskId: "task-eot-existing-kinds",
|
|
363
|
+
finalResult: "Completed with several proposal kinds.",
|
|
364
|
+
copilotClient: {},
|
|
365
|
+
callLLM: async () => JSON.stringify({
|
|
366
|
+
decisions: proposals.map((proposal) => ({
|
|
367
|
+
proposal_id: Number(proposal.lastInsertRowid),
|
|
368
|
+
decision: "accept",
|
|
369
|
+
reason: "Valid durable memory.",
|
|
370
|
+
})),
|
|
371
|
+
implicit_memories: [],
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Existing observation proposals still persist."), true);
|
|
375
|
+
assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Keep auto-accept" && row.rationale === "It is required for EOT memory promotion."), true);
|
|
376
|
+
assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "agent" }).some((row) => row.name === "Bellonda"), true);
|
|
377
|
+
});
|
|
197
378
|
test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
|
|
198
379
|
const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
|
|
199
380
|
const db = dbModule.getDb();
|
|
@@ -108,12 +108,17 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
|
|
|
108
108
|
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
109
109
|
dbModule.getDb();
|
|
110
110
|
const getScope = getFunction(memoryModule, "getScope");
|
|
111
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
111
112
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
112
113
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
113
114
|
const chapterhouse = getScope("chapterhouse");
|
|
114
|
-
const team =
|
|
115
|
+
const team = createScope({
|
|
116
|
+
slug: "team",
|
|
117
|
+
title: "Team",
|
|
118
|
+
description: "Team test scope",
|
|
119
|
+
keywords: ["team"],
|
|
120
|
+
});
|
|
115
121
|
assert.ok(chapterhouse);
|
|
116
|
-
assert.ok(team);
|
|
117
122
|
recordObservation({
|
|
118
123
|
scope_id: chapterhouse.id,
|
|
119
124
|
content: "Chapterhouse hot entry",
|
|
@@ -135,11 +140,16 @@ test("renderHotTierXML includes open active-scope action items in a bounded acti
|
|
|
135
140
|
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
136
141
|
dbModule.getDb();
|
|
137
142
|
const getScope = getFunction(memoryModule, "getScope");
|
|
143
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
138
144
|
const recordActionItem = getFunction(memoryModule, "recordActionItem");
|
|
139
145
|
const chapterhouse = getScope("chapterhouse");
|
|
140
|
-
const team =
|
|
146
|
+
const team = createScope({
|
|
147
|
+
slug: "team",
|
|
148
|
+
title: "Team",
|
|
149
|
+
description: "Team test scope",
|
|
150
|
+
keywords: ["team"],
|
|
151
|
+
});
|
|
141
152
|
assert.ok(chapterhouse);
|
|
142
|
-
assert.ok(team);
|
|
143
153
|
const urgent = recordActionItem({
|
|
144
154
|
scope_id: chapterhouse.id,
|
|
145
155
|
title: "Migrate <feature ideas>",
|
|
@@ -24,6 +24,15 @@ function getFunction(module, name) {
|
|
|
24
24
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
|
+
function createTestScope(memoryModule, slug) {
|
|
28
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
29
|
+
return createScope({
|
|
30
|
+
slug,
|
|
31
|
+
title: slug.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "),
|
|
32
|
+
description: `${slug} test scope`,
|
|
33
|
+
keywords: [slug],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
27
36
|
test.beforeEach(async () => {
|
|
28
37
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
29
38
|
dbModule.closeDb();
|
|
@@ -42,8 +51,8 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
|
|
|
42
51
|
const getScope = getFunction(memoryModule, "getScope");
|
|
43
52
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
44
53
|
const chapterhouse = getScope("chapterhouse");
|
|
45
|
-
const team =
|
|
46
|
-
assert.ok(chapterhouse
|
|
54
|
+
const team = createTestScope(memoryModule, "team");
|
|
55
|
+
assert.ok(chapterhouse);
|
|
47
56
|
const first = recordObservation({
|
|
48
57
|
scope_id: team.id,
|
|
49
58
|
content: "The worker event stream uses server sent events for live task output.",
|
|
@@ -88,9 +97,8 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
|
|
|
88
97
|
const getScope = getFunction(memoryModule, "getScope");
|
|
89
98
|
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
90
99
|
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
91
|
-
const team =
|
|
92
|
-
const infra =
|
|
93
|
-
assert.ok(team && infra);
|
|
100
|
+
const team = createTestScope(memoryModule, "team");
|
|
101
|
+
const infra = createTestScope(memoryModule, "infra");
|
|
94
102
|
const api = upsertEntity({ scope_id: team.id, kind: "component", name: "api" });
|
|
95
103
|
const web = upsertEntity({ scope_id: team.id, kind: "component", name: "web" });
|
|
96
104
|
const oldDecision = recordDecision({
|
|
@@ -139,8 +147,8 @@ test("orphanCleanupPass clears missing observation entity references without tou
|
|
|
139
147
|
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
140
148
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
141
149
|
const chapterhouse = getScope("chapterhouse");
|
|
142
|
-
const team =
|
|
143
|
-
assert.ok(chapterhouse
|
|
150
|
+
const team = createTestScope(memoryModule, "team");
|
|
151
|
+
assert.ok(chapterhouse);
|
|
144
152
|
const entity = upsertEntity({ scope_id: chapterhouse.id, kind: "tool", name: "sqlite" });
|
|
145
153
|
const valid = recordObservation({ scope_id: chapterhouse.id, entity_id: entity.id, content: "Valid entity reference", source: "test" });
|
|
146
154
|
const orphan = recordObservation({ scope_id: chapterhouse.id, content: "Will become orphaned", source: "test" });
|
|
@@ -164,8 +172,8 @@ test("decayPass archives old low-confidence observations only in scope and compa
|
|
|
164
172
|
const getScope = getFunction(memoryModule, "getScope");
|
|
165
173
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
166
174
|
const chapterhouse = getScope("chapterhouse");
|
|
167
|
-
const team =
|
|
168
|
-
assert.ok(chapterhouse
|
|
175
|
+
const team = createTestScope(memoryModule, "team");
|
|
176
|
+
assert.ok(chapterhouse);
|
|
169
177
|
const archiveMe = recordObservation({ scope_id: chapterhouse.id, content: "Old low confidence", source: "test", confidence: 0.2 });
|
|
170
178
|
const highConfidence = recordObservation({ scope_id: chapterhouse.id, content: "Old high confidence", source: "test", confidence: 0.9 });
|
|
171
179
|
const fresh = recordObservation({ scope_id: chapterhouse.id, content: "Fresh low confidence", source: "test", confidence: 0.2 });
|
|
@@ -201,8 +209,8 @@ test("runHousekeeping defaults to the active scope and can target all active sco
|
|
|
201
209
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
202
210
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
203
211
|
const chapterhouse = getScope("chapterhouse");
|
|
204
|
-
const team =
|
|
205
|
-
assert.ok(chapterhouse
|
|
212
|
+
const team = createTestScope(memoryModule, "team");
|
|
213
|
+
assert.ok(chapterhouse);
|
|
206
214
|
const chapterhouseOld = recordObservation({ scope_id: chapterhouse.id, content: "Chapterhouse old low", source: "test", confidence: 0.1 });
|
|
207
215
|
const teamOld = recordObservation({ scope_id: team.id, content: "Team old low", source: "test", confidence: 0.1 });
|
|
208
216
|
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-31 days') WHERE id IN (?, ?)`).run(chapterhouseOld.id, teamOld.id);
|
|
@@ -225,8 +233,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
|
|
|
225
233
|
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
226
234
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
227
235
|
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
228
|
-
const team =
|
|
229
|
-
assert.ok(team);
|
|
236
|
+
const team = createTestScope(memoryModule, "team");
|
|
230
237
|
const entity = upsertEntity({ scope_id: team.id, kind: "component", name: "memory", tier: "warm" });
|
|
231
238
|
const referencedObservation = recordObservation({
|
|
232
239
|
scope_id: team.id,
|
|
@@ -68,13 +68,6 @@ test("getDb seeds canonical memory scopes on first run", async () => {
|
|
|
68
68
|
ORDER BY slug
|
|
69
69
|
`).all();
|
|
70
70
|
assert.deepEqual(rows, [
|
|
71
|
-
{
|
|
72
|
-
slug: "brian",
|
|
73
|
-
title: "Brian",
|
|
74
|
-
description: "Brian's preferences, context, working style",
|
|
75
|
-
keywords: JSON.stringify(["brian"]),
|
|
76
|
-
active: 1,
|
|
77
|
-
},
|
|
78
71
|
{
|
|
79
72
|
slug: "chapterhouse",
|
|
80
73
|
title: "Chapterhouse",
|
|
@@ -89,20 +82,6 @@ test("getDb seeds canonical memory scopes on first run", async () => {
|
|
|
89
82
|
keywords: JSON.stringify(["everywhere", "general"]),
|
|
90
83
|
active: 1,
|
|
91
84
|
},
|
|
92
|
-
{
|
|
93
|
-
slug: "infra",
|
|
94
|
-
title: "Infra",
|
|
95
|
-
description: "Infrastructure, hosting, deployment, CI/CD",
|
|
96
|
-
keywords: JSON.stringify(["infra"]),
|
|
97
|
-
active: 1,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
slug: "team",
|
|
101
|
-
title: "Team",
|
|
102
|
-
description: "Team processes, rituals, OKRs",
|
|
103
|
-
keywords: JSON.stringify(["team"]),
|
|
104
|
-
active: 1,
|
|
105
|
-
},
|
|
106
85
|
]);
|
|
107
86
|
}
|
|
108
87
|
finally {
|
|
@@ -123,11 +102,8 @@ test("memory schema initialization is idempotent", async () => {
|
|
|
123
102
|
ORDER BY slug
|
|
124
103
|
`).all();
|
|
125
104
|
assert.deepEqual(counts, [
|
|
126
|
-
{ slug: "brian", count: 1 },
|
|
127
105
|
{ slug: "chapterhouse", count: 1 },
|
|
128
106
|
{ slug: "global", count: 1 },
|
|
129
|
-
{ slug: "infra", count: 1 },
|
|
130
|
-
{ slug: "team", count: 1 },
|
|
131
107
|
]);
|
|
132
108
|
reopened.dbModule.closeDb();
|
|
133
109
|
}
|
package/dist/store/db.js
CHANGED
|
@@ -216,24 +216,6 @@ const MEMORY_SCOPE_SEEDS = [
|
|
|
216
216
|
description: "Chapterhouse codebase, conventions, decisions, gotchas",
|
|
217
217
|
keywords: ["chapterhouse", "this repo", "this project", "the daemon"],
|
|
218
218
|
},
|
|
219
|
-
{
|
|
220
|
-
slug: "infra",
|
|
221
|
-
title: "Infra",
|
|
222
|
-
description: "Infrastructure, hosting, deployment, CI/CD",
|
|
223
|
-
keywords: ["infra"],
|
|
224
|
-
},
|
|
225
|
-
{
|
|
226
|
-
slug: "team",
|
|
227
|
-
title: "Team",
|
|
228
|
-
description: "Team processes, rituals, OKRs",
|
|
229
|
-
keywords: ["team"],
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
slug: "brian",
|
|
233
|
-
title: "Brian",
|
|
234
|
-
description: "Brian's preferences, context, working style",
|
|
235
|
-
keywords: ["brian"],
|
|
236
|
-
},
|
|
237
219
|
];
|
|
238
220
|
const CHAPTERHOUSE_WIKI_INDEX_SOURCE = "wiki:pages/projects/chapterhouse/index.md";
|
|
239
221
|
const CHAPTERHOUSE_WIKI_HOT_TIER_REASON = "P6 PR1 wiki migration hot-tier candidate";
|
package/package.json
CHANGED
package/agents/bellonda.agent.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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.
|
|
@@ -1,12 +0,0 @@
|
|
|
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.
|