chapterhouse 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +65 -2
- package/dist/api/server.test.js +63 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +13 -2
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +146 -29
- package/dist/copilot/orchestrator.test.js +232 -14
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +149 -13
- package/dist/copilot/tools.memory.test.js +139 -2
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.test.js +20 -13
- package/dist/memory/index.js +1 -1
- package/dist/memory/scopes.test.js +0 -24
- package/dist/store/db.js +27 -19
- package/package.json +1 -1
- package/web/dist/assets/{index-D4-uRAi6.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/index-BfHqP3-C.js.map +1 -0
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BTI_m0OE.css +0 -10
- package/web/dist/assets/index-D4-uRAi6.js.map +0 -1
package/dist/copilot/tools.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from "os";
|
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
9
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
10
|
-
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
10
|
+
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
11
11
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
12
12
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
13
13
|
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
@@ -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) {
|
|
@@ -65,6 +65,21 @@ function requireOrchestratorMemoryWrite() {
|
|
|
65
65
|
}
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
|
+
function resolveProposalScopeSlug(requestedScopeSlug) {
|
|
69
|
+
if (requestedScopeSlug) {
|
|
70
|
+
return requestedScopeSlug;
|
|
71
|
+
}
|
|
72
|
+
const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
|
|
73
|
+
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
74
|
+
return getMemoryActiveScope()?.slug;
|
|
75
|
+
}
|
|
76
|
+
const agent = getAgent(agentSlug);
|
|
77
|
+
const boundScope = agent?.scope ?? loadAgents().find((entry) => entry.slug === agentSlug)?.scope;
|
|
78
|
+
if (boundScope && getMemoryScope(boundScope)) {
|
|
79
|
+
return boundScope;
|
|
80
|
+
}
|
|
81
|
+
return getMemoryActiveScope()?.slug;
|
|
82
|
+
}
|
|
68
83
|
function resolveMemoryScopeForWrite(explicitScope, content) {
|
|
69
84
|
const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
|
|
70
85
|
if (explicitScope && !explicit) {
|
|
@@ -123,11 +138,28 @@ const entityProposalPayloadSchema = z.object({
|
|
|
123
138
|
summary: value.summary,
|
|
124
139
|
}));
|
|
125
140
|
const actionItemProposalPayloadSchema = z.object({
|
|
126
|
-
title: z.string(),
|
|
141
|
+
title: z.string().trim().min(1, "title is required for action_item proposals."),
|
|
127
142
|
detail: z.string().optional(),
|
|
128
143
|
due_at: z.string().optional(),
|
|
129
144
|
source: z.string().optional(),
|
|
130
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
|
+
}
|
|
131
163
|
});
|
|
132
164
|
const memoryProposeArgsSchema = z.object({
|
|
133
165
|
kind: z.enum(["observation", "decision", "entity", "action_item"]),
|
|
@@ -210,15 +242,6 @@ export function createTools(deps) {
|
|
|
210
242
|
}
|
|
211
243
|
const delegatedSlug = agent.slug;
|
|
212
244
|
const taskId = createTaskId();
|
|
213
|
-
let session;
|
|
214
|
-
try {
|
|
215
|
-
const allTools = createTools(deps);
|
|
216
|
-
session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
-
return `Failed to create session for @${delegatedSlug}: ${msg}`;
|
|
221
|
-
}
|
|
222
245
|
const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
|
|
223
246
|
const activeProjectRules = getCurrentActiveProjectRules();
|
|
224
247
|
const warningLines = activeProjectRules
|
|
@@ -232,6 +255,71 @@ export function createTools(deps) {
|
|
|
232
255
|
const db = getDb();
|
|
233
256
|
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
|
|
234
257
|
VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
|
|
258
|
+
if (agent.persistent) {
|
|
259
|
+
(async () => {
|
|
260
|
+
try {
|
|
261
|
+
const output = await sendToAgentSession(delegatedSlug, taskPrompt, task.taskId);
|
|
262
|
+
completeTask(task.taskId, output);
|
|
263
|
+
updateTaskResult(task.taskId, "completed", output);
|
|
264
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
|
|
265
|
+
if (statusEvent) {
|
|
266
|
+
void agentEventBus.emit({
|
|
267
|
+
type: "session:tool_call",
|
|
268
|
+
sessionId: task.taskId,
|
|
269
|
+
payload: {
|
|
270
|
+
toolName: "",
|
|
271
|
+
toolArgs: {},
|
|
272
|
+
_kind: statusEvent.kind,
|
|
273
|
+
_seq: statusEvent.seq,
|
|
274
|
+
_ts: statusEvent.ts,
|
|
275
|
+
_summary: statusEvent.summary,
|
|
276
|
+
_text: statusEvent.text,
|
|
277
|
+
_status: statusEvent.status,
|
|
278
|
+
},
|
|
279
|
+
timestamp: new Date(statusEvent.ts),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
286
|
+
failTask(task.taskId, msg);
|
|
287
|
+
updateTaskResult(task.taskId, "error", msg);
|
|
288
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
|
|
289
|
+
if (statusEvent) {
|
|
290
|
+
void agentEventBus.emit({
|
|
291
|
+
type: "session:tool_call",
|
|
292
|
+
sessionId: task.taskId,
|
|
293
|
+
payload: {
|
|
294
|
+
toolName: "",
|
|
295
|
+
toolArgs: {},
|
|
296
|
+
_kind: statusEvent.kind,
|
|
297
|
+
_seq: statusEvent.seq,
|
|
298
|
+
_ts: statusEvent.ts,
|
|
299
|
+
_summary: statusEvent.summary,
|
|
300
|
+
_text: statusEvent.text,
|
|
301
|
+
_status: statusEvent.status,
|
|
302
|
+
},
|
|
303
|
+
timestamp: new Date(statusEvent.ts),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
const model = (args.model_override && args.model_override.length > 0)
|
|
310
|
+
? args.model_override
|
|
311
|
+
: (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
|
|
312
|
+
return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
|
|
313
|
+
}
|
|
314
|
+
let session;
|
|
315
|
+
try {
|
|
316
|
+
const allTools = createTools(deps);
|
|
317
|
+
session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
321
|
+
return `Failed to create session for @${delegatedSlug}: ${msg}`;
|
|
322
|
+
}
|
|
235
323
|
// Capture the parent's activity callback so the child session can stream
|
|
236
324
|
// its events back to the originating SSE connection. This survives past
|
|
237
325
|
// the parent assistant turn — the child runs long after the parent's
|
|
@@ -876,7 +964,7 @@ export function createTools(deps) {
|
|
|
876
964
|
: actionItemProposalPayloadSchema.parse(parsedArgs.payload);
|
|
877
965
|
const proposal = queueMemoryProposal({
|
|
878
966
|
kind: parsedArgs.kind,
|
|
879
|
-
scopeSlug: parsedArgs.scope_slug,
|
|
967
|
+
scopeSlug: resolveProposalScopeSlug(parsedArgs.scope_slug),
|
|
880
968
|
payload,
|
|
881
969
|
confidence: parsedArgs.confidence ?? 0.5,
|
|
882
970
|
reason: parsedArgs.reason,
|
|
@@ -896,6 +984,54 @@ export function createTools(deps) {
|
|
|
896
984
|
}
|
|
897
985
|
},
|
|
898
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
|
+
}),
|
|
899
1035
|
defineTool("memory_add_action_item", {
|
|
900
1036
|
description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
|
|
901
1037
|
parameters: z.object({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test from "node:test";
|
|
@@ -46,6 +46,7 @@ test("memory_set_scope invalidates the orchestrator session after scheduling the
|
|
|
46
46
|
invalidateOrchestratorSession: (sessionKey) => {
|
|
47
47
|
events.push(`invalidate:${sessionKey}`);
|
|
48
48
|
},
|
|
49
|
+
sendToAgentSession: async () => "",
|
|
49
50
|
switchSessionModel: async () => { },
|
|
50
51
|
},
|
|
51
52
|
});
|
|
@@ -84,6 +85,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
84
85
|
const memoryRemember = findTool(tools, "memory_remember");
|
|
85
86
|
const memoryRecall = findTool(tools, "memory_recall");
|
|
86
87
|
const memorySetScope = findTool(tools, "memory_set_scope");
|
|
88
|
+
const memoryCreateScope = findTool(tools, "memory_create_scope");
|
|
87
89
|
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
88
90
|
assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
|
|
89
91
|
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
@@ -120,6 +122,30 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
120
122
|
assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
|
|
121
123
|
const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
|
|
122
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);
|
|
123
149
|
const implicitScopeRemember = await chapterhouseRemember.handler({
|
|
124
150
|
content: "Implicit active-scope writes should route to chapterhouse.",
|
|
125
151
|
kind: "observation",
|
|
@@ -150,7 +176,7 @@ test("memory tools remember, recall, set scope, and enforce orchestrator-only wr
|
|
|
150
176
|
}, {});
|
|
151
177
|
assert.equal(typeof coderRecalled, "object");
|
|
152
178
|
assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
|
|
153
|
-
assert.ok(memoryRemember && memoryRecall && memorySetScope);
|
|
179
|
+
assert.ok(memoryRemember && memoryRecall && memorySetScope && memoryCreateScope);
|
|
154
180
|
});
|
|
155
181
|
test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
|
|
156
182
|
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
@@ -194,6 +220,97 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
|
|
|
194
220
|
assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
|
|
195
221
|
assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
|
|
196
222
|
});
|
|
223
|
+
test("memory_propose from a persistent agent honors an explicit scope_slug", async () => {
|
|
224
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
225
|
+
assert.ok(home, "test home should be set");
|
|
226
|
+
const { AGENTS_DIR: agentsDir } = await import("../paths.js");
|
|
227
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
228
|
+
writeFileSync(join(agentsDir, "bellonda.agent.md"), [
|
|
229
|
+
"---",
|
|
230
|
+
"name: Bellonda",
|
|
231
|
+
"description: Mentat of the infrastructure domain",
|
|
232
|
+
"model: claude-sonnet-4.6",
|
|
233
|
+
"persistent: true",
|
|
234
|
+
"scope: infra",
|
|
235
|
+
"---",
|
|
236
|
+
"",
|
|
237
|
+
"You are Bellonda.",
|
|
238
|
+
].join("\n"));
|
|
239
|
+
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
240
|
+
agentsModule.loadAgents();
|
|
241
|
+
const tools = toolsModule.createTools({
|
|
242
|
+
client: { async listModels() { return []; } },
|
|
243
|
+
onAgentTaskComplete: () => { },
|
|
244
|
+
});
|
|
245
|
+
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
246
|
+
assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
|
|
247
|
+
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
248
|
+
const bellondaTools = bindToolsToAgent("bellonda", tools, "task-persistent-scope-001");
|
|
249
|
+
await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
|
|
250
|
+
const memoryModule = await import("../memory/index.js");
|
|
251
|
+
const proposed = await memoryModule.withActiveScope("infra", () => findTool(bellondaTools, "memory_propose").handler({
|
|
252
|
+
kind: "observation",
|
|
253
|
+
scope_slug: "chapterhouse",
|
|
254
|
+
payload: {
|
|
255
|
+
content: "Persistent agents can explicitly route proposals to another scope when needed.",
|
|
256
|
+
},
|
|
257
|
+
}, {}));
|
|
258
|
+
assert.equal(proposed.status, "queued");
|
|
259
|
+
const row = dbModule.getDb().prepare(`
|
|
260
|
+
SELECT source_agent, source_task_id, payload
|
|
261
|
+
FROM mem_inbox
|
|
262
|
+
WHERE id = ?
|
|
263
|
+
`).get(proposed.proposal_id);
|
|
264
|
+
assert.ok(row, "memory_propose should insert a mem_inbox row");
|
|
265
|
+
assert.equal(row.source_agent, "bellonda");
|
|
266
|
+
assert.equal(row.source_task_id, "task-persistent-scope-001");
|
|
267
|
+
const payload = JSON.parse(row.payload);
|
|
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");
|
|
313
|
+
});
|
|
197
314
|
test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
|
|
198
315
|
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
199
316
|
const tools = toolsModule.createTools({
|
|
@@ -318,6 +435,26 @@ test("memory_propose accepts action_item proposals with a resolvable payload sha
|
|
|
318
435
|
assert.equal(payload.payload.title, "Remind infra about high disk usage");
|
|
319
436
|
assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
|
|
320
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
|
+
});
|
|
321
458
|
test("memory_propose rejects invalid proposal kinds", async () => {
|
|
322
459
|
const { toolsModule } = await loadModules();
|
|
323
460
|
const tools = toolsModule.createTools({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { getDb } from "../store/db.js";
|
|
2
3
|
import { getScope, listScopes } from "./scopes.js";
|
|
3
4
|
const ACTIVE_SCOPE_KEY = "current_scope_slug";
|
|
5
|
+
const activeScopeOverride = new AsyncLocalStorage();
|
|
4
6
|
function setSetting(key, value) {
|
|
5
7
|
getDb().prepare(`
|
|
6
8
|
INSERT INTO mem_settings (key, value)
|
|
@@ -20,6 +22,10 @@ function clearSetting(key) {
|
|
|
20
22
|
getDb().prepare(`DELETE FROM mem_settings WHERE key = ?`).run(key);
|
|
21
23
|
}
|
|
22
24
|
export function getActiveScope() {
|
|
25
|
+
const override = activeScopeOverride.getStore();
|
|
26
|
+
if (override !== undefined) {
|
|
27
|
+
return override === null ? null : (getScope(override) ?? null);
|
|
28
|
+
}
|
|
23
29
|
const slug = getSetting(ACTIVE_SCOPE_KEY);
|
|
24
30
|
if (!slug)
|
|
25
31
|
return null;
|
|
@@ -30,6 +36,9 @@ export function getActiveScope() {
|
|
|
30
36
|
}
|
|
31
37
|
return scope;
|
|
32
38
|
}
|
|
39
|
+
export function withActiveScope(slug, fn) {
|
|
40
|
+
return activeScopeOverride.run(slug, fn);
|
|
41
|
+
}
|
|
33
42
|
export function setActiveScope(slug) {
|
|
34
43
|
if (slug === null) {
|
|
35
44
|
clearSetting(ACTIVE_SCOPE_KEY);
|
|
@@ -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
|
}
|