chapterhouse 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/agents/bellonda.agent.md +11 -0
  2. package/agents/hwi-noree.agent.md +12 -0
  3. package/dist/api/server.js +39 -2
  4. package/dist/api/server.test.js +20 -0
  5. package/dist/api/turn-sse.integration.test.js +12 -0
  6. package/dist/copilot/agents.js +16 -4
  7. package/dist/copilot/agents.test.js +43 -1
  8. package/dist/copilot/orchestrator.js +173 -32
  9. package/dist/copilot/orchestrator.test.js +236 -20
  10. package/dist/copilot/session-manager.js +11 -2
  11. package/dist/copilot/session-manager.test.js +25 -0
  12. package/dist/copilot/tools.agent.test.js +52 -4
  13. package/dist/copilot/tools.js +265 -18
  14. package/dist/copilot/tools.memory.test.js +175 -2
  15. package/dist/daemon.js +6 -0
  16. package/dist/memory/action-items.js +100 -0
  17. package/dist/memory/action-items.test.js +83 -0
  18. package/dist/memory/active-scope.js +9 -0
  19. package/dist/memory/eot.js +28 -3
  20. package/dist/memory/eot.test.js +108 -0
  21. package/dist/memory/hot-tier.js +60 -1
  22. package/dist/memory/hot-tier.test.js +38 -0
  23. package/dist/memory/housekeeping-scheduler.js +152 -0
  24. package/dist/memory/housekeeping-scheduler.test.js +187 -0
  25. package/dist/memory/index.js +2 -1
  26. package/dist/memory/recall.js +59 -0
  27. package/dist/memory/recall.test.js +27 -0
  28. package/dist/memory/tiering.js +33 -3
  29. package/dist/store/db.js +130 -17
  30. package/dist/store/db.test.js +61 -5
  31. package/package.json +1 -1
  32. package/web/dist/assets/{index-B_cCSHan.js → index-BfHqP3-C.js} +87 -87
  33. package/web/dist/assets/{index-B_cCSHan.js.map → index-BfHqP3-C.js.map} +1 -1
  34. package/web/dist/assets/index-_O6AoWOS.css +10 -0
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/assets/index-DhY5yWmC.css +0 -10
@@ -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, demoteToCold, demoteToWarm, queueMemoryProposal, recall as recallMemory, recordDecision, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, promoteToHot, upsertEntity, } from "../memory/index.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";
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
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
70
+ if (!agentSlug || agentSlug === "chapterhouse") {
71
+ return requestedScopeSlug;
72
+ }
73
+ const activeScope = getMemoryActiveScope();
74
+ if (activeScope) {
75
+ return activeScope.slug;
76
+ }
77
+ const agent = getAgent(agentSlug);
78
+ if (agent?.persistent && agent.scope) {
79
+ return agent.scope;
80
+ }
81
+ return requestedScopeSlug;
82
+ }
68
83
  function resolveMemoryScopeForWrite(explicitScope, content) {
69
84
  const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
70
85
  if (explicitScope && !explicit) {
@@ -106,11 +121,31 @@ const decisionProposalPayloadSchema = z.object({
106
121
  });
107
122
  const entityProposalPayloadSchema = z.object({
108
123
  name: z.string(),
109
- kind: z.string(),
124
+ entity_kind: z.string().optional(),
125
+ kind: z.string().optional(),
110
126
  summary: z.string().optional(),
127
+ }).superRefine((value, context) => {
128
+ if (!value.entity_kind && !value.kind) {
129
+ context.addIssue({
130
+ code: z.ZodIssueCode.custom,
131
+ message: "entity_kind is required for entity proposals.",
132
+ path: ["entity_kind"],
133
+ });
134
+ }
135
+ }).transform((value) => ({
136
+ name: value.name,
137
+ entity_kind: value.entity_kind ?? value.kind,
138
+ summary: value.summary,
139
+ }));
140
+ const actionItemProposalPayloadSchema = z.object({
141
+ title: z.string(),
142
+ detail: z.string().optional(),
143
+ due_at: z.string().optional(),
144
+ source: z.string().optional(),
145
+ entity_id: z.number().int().positive().optional(),
111
146
  });
112
147
  const memoryProposeArgsSchema = z.object({
113
- kind: z.enum(["observation", "decision", "entity"]),
148
+ kind: z.enum(["observation", "decision", "entity", "action_item"]),
114
149
  scope_slug: z.string().optional(),
115
150
  payload: z.record(z.string(), z.unknown()),
116
151
  confidence: z.number().min(0).max(1).optional(),
@@ -120,7 +155,9 @@ const memoryProposeArgsSchema = z.object({
120
155
  ? observationProposalPayloadSchema
121
156
  : value.kind === "decision"
122
157
  ? decisionProposalPayloadSchema
123
- : entityProposalPayloadSchema;
158
+ : value.kind === "entity"
159
+ ? entityProposalPayloadSchema
160
+ : actionItemProposalPayloadSchema;
124
161
  const parsed = schema.safeParse(value.payload);
125
162
  if (!parsed.success) {
126
163
  for (const issue of parsed.error.issues) {
@@ -132,7 +169,7 @@ const memoryProposeArgsSchema = z.object({
132
169
  }
133
170
  }
134
171
  });
135
- const memoryTierTableSchema = z.enum(["observation", "decision", "entity"]);
172
+ const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
136
173
  function getCurrentQuarter(now = new Date()) {
137
174
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
138
175
  }
@@ -188,15 +225,6 @@ export function createTools(deps) {
188
225
  }
189
226
  const delegatedSlug = agent.slug;
190
227
  const taskId = createTaskId();
191
- let session;
192
- try {
193
- const allTools = createTools(deps);
194
- session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
195
- }
196
- catch (err) {
197
- const msg = err instanceof Error ? err.message : String(err);
198
- return `Failed to create session for @${delegatedSlug}: ${msg}`;
199
- }
200
228
  const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
201
229
  const activeProjectRules = getCurrentActiveProjectRules();
202
230
  const warningLines = activeProjectRules
@@ -210,6 +238,71 @@ export function createTools(deps) {
210
238
  const db = getDb();
211
239
  db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
212
240
  VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
241
+ if (agent.persistent) {
242
+ (async () => {
243
+ try {
244
+ const output = await sendToAgentSession(delegatedSlug, taskPrompt, task.taskId);
245
+ completeTask(task.taskId, output);
246
+ updateTaskResult(task.taskId, "completed", output);
247
+ const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
248
+ if (statusEvent) {
249
+ void agentEventBus.emit({
250
+ type: "session:tool_call",
251
+ sessionId: task.taskId,
252
+ payload: {
253
+ toolName: "",
254
+ toolArgs: {},
255
+ _kind: statusEvent.kind,
256
+ _seq: statusEvent.seq,
257
+ _ts: statusEvent.ts,
258
+ _summary: statusEvent.summary,
259
+ _text: statusEvent.text,
260
+ _status: statusEvent.status,
261
+ },
262
+ timestamp: new Date(statusEvent.ts),
263
+ });
264
+ }
265
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
266
+ }
267
+ catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ failTask(task.taskId, msg);
270
+ updateTaskResult(task.taskId, "error", msg);
271
+ const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
272
+ if (statusEvent) {
273
+ void agentEventBus.emit({
274
+ type: "session:tool_call",
275
+ sessionId: task.taskId,
276
+ payload: {
277
+ toolName: "",
278
+ toolArgs: {},
279
+ _kind: statusEvent.kind,
280
+ _seq: statusEvent.seq,
281
+ _ts: statusEvent.ts,
282
+ _summary: statusEvent.summary,
283
+ _text: statusEvent.text,
284
+ _status: statusEvent.status,
285
+ },
286
+ timestamp: new Date(statusEvent.ts),
287
+ });
288
+ }
289
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
290
+ }
291
+ })();
292
+ const model = (args.model_override && args.model_override.length > 0)
293
+ ? args.model_override
294
+ : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
295
+ return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
296
+ }
297
+ let session;
298
+ try {
299
+ const allTools = createTools(deps);
300
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
301
+ }
302
+ catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ return `Failed to create session for @${delegatedSlug}: ${msg}`;
305
+ }
213
306
  // Capture the parent's activity callback so the child session can stream
214
307
  // its events back to the originating SSE connection. This survives past
215
308
  // the parent assistant turn — the child runs long after the parent's
@@ -849,10 +942,12 @@ export function createTools(deps) {
849
942
  ? observationProposalPayloadSchema.parse(parsedArgs.payload)
850
943
  : parsedArgs.kind === "decision"
851
944
  ? decisionProposalPayloadSchema.parse(parsedArgs.payload)
852
- : entityProposalPayloadSchema.parse(parsedArgs.payload);
945
+ : parsedArgs.kind === "entity"
946
+ ? entityProposalPayloadSchema.parse(parsedArgs.payload)
947
+ : actionItemProposalPayloadSchema.parse(parsedArgs.payload);
853
948
  const proposal = queueMemoryProposal({
854
949
  kind: parsedArgs.kind,
855
- scopeSlug: parsedArgs.scope_slug,
950
+ scopeSlug: resolveProposalScopeSlug(parsedArgs.scope_slug),
856
951
  payload,
857
952
  confidence: parsedArgs.confidence ?? 0.5,
858
953
  reason: parsedArgs.reason,
@@ -872,12 +967,164 @@ export function createTools(deps) {
872
967
  }
873
968
  },
874
969
  }),
970
+ defineTool("memory_add_action_item", {
971
+ description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
972
+ parameters: z.object({
973
+ scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope, then keyword inference."),
974
+ title: z.string().min(1).describe("Short action item title."),
975
+ detail: z.string().optional().describe("Longer action item detail."),
976
+ due_at: z.string().optional().describe("Optional ISO due timestamp."),
977
+ entity_name: z.string().optional().describe("Optional entity name to attach. Auto-upserts the entity if provided."),
978
+ entity_kind: z.string().optional().describe("Required when entity_name is provided."),
979
+ source: z.string().optional().describe("Action item source, e.g. manual, subagent_proposal, external."),
980
+ }),
981
+ handler: async (args) => {
982
+ const denied = requireOrchestratorMemoryWrite();
983
+ if (denied)
984
+ return denied;
985
+ if (args.entity_name && !args.entity_kind) {
986
+ return "entity_kind is required when entity_name is provided.";
987
+ }
988
+ try {
989
+ const { scopeId, scopeSlug } = resolveMemoryScopeForWrite(args.scope, `${args.title}\n${args.detail ?? ""}`);
990
+ const entity = args.entity_name
991
+ ? upsertEntity({
992
+ scope_id: scopeId,
993
+ kind: args.entity_kind,
994
+ name: args.entity_name,
995
+ })
996
+ : undefined;
997
+ const actionItem = recordActionItem({
998
+ scope_id: scopeId,
999
+ entity_id: entity?.id,
1000
+ title: args.title,
1001
+ detail: args.detail,
1002
+ due_at: args.due_at,
1003
+ source: args.source ?? "manual",
1004
+ });
1005
+ return {
1006
+ ok: true,
1007
+ id: actionItem.id,
1008
+ scope: scopeSlug,
1009
+ status: actionItem.status,
1010
+ entity_id: entity?.id,
1011
+ };
1012
+ }
1013
+ catch (err) {
1014
+ return err instanceof Error ? err.message : String(err);
1015
+ }
1016
+ },
1017
+ }),
1018
+ defineTool("memory_complete_action_item", {
1019
+ description: "Complete a memory action item. Orchestrator-only write tool.",
1020
+ parameters: z.object({
1021
+ id: z.number().int().positive(),
1022
+ resolution_reason: z.string().optional(),
1023
+ }),
1024
+ handler: async (args) => {
1025
+ const denied = requireOrchestratorMemoryWrite();
1026
+ if (denied)
1027
+ return denied;
1028
+ try {
1029
+ const actionItem = completeActionItem(args.id, args.resolution_reason);
1030
+ return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
1031
+ }
1032
+ catch (err) {
1033
+ return err instanceof Error ? err.message : String(err);
1034
+ }
1035
+ },
1036
+ }),
1037
+ defineTool("memory_drop_action_item", {
1038
+ description: "Drop a memory action item with a reason. Orchestrator-only write tool.",
1039
+ parameters: z.object({
1040
+ id: z.number().int().positive(),
1041
+ reason: z.string().min(1),
1042
+ }),
1043
+ handler: async (args) => {
1044
+ const denied = requireOrchestratorMemoryWrite();
1045
+ if (denied)
1046
+ return denied;
1047
+ try {
1048
+ const actionItem = dropActionItem(args.id, args.reason);
1049
+ return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
1050
+ }
1051
+ catch (err) {
1052
+ return err instanceof Error ? err.message : String(err);
1053
+ }
1054
+ },
1055
+ }),
1056
+ defineTool("memory_snooze_action_item", {
1057
+ description: "Snooze a memory action item until an ISO timestamp. Orchestrator-only write tool.",
1058
+ parameters: z.object({
1059
+ id: z.number().int().positive(),
1060
+ snooze_until: z.string().min(1),
1061
+ }),
1062
+ handler: async (args) => {
1063
+ const denied = requireOrchestratorMemoryWrite();
1064
+ if (denied)
1065
+ return denied;
1066
+ try {
1067
+ const actionItem = snoozeActionItem(args.id, args.snooze_until);
1068
+ return {
1069
+ ok: true,
1070
+ id: actionItem.id,
1071
+ status: actionItem.status,
1072
+ snooze_until: actionItem.snoozeUntil,
1073
+ };
1074
+ }
1075
+ catch (err) {
1076
+ return err instanceof Error ? err.message : String(err);
1077
+ }
1078
+ },
1079
+ }),
1080
+ defineTool("memory_list_action_items", {
1081
+ description: "List scoped memory action items/reminders. Defaults to currently actionable open items.",
1082
+ parameters: z.object({
1083
+ scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope when available."),
1084
+ status: z.enum(["open", "done", "dropped", "snoozed"]).optional().describe("Optional status filter."),
1085
+ due_before: z.string().optional().describe("Optional ISO timestamp; only include items due at or before this time."),
1086
+ includeArchived: z.boolean().optional().describe("Include cold-tier items. Defaults to false."),
1087
+ }),
1088
+ handler: async (args) => {
1089
+ const requestedScope = args.scope ? getMemoryScope(args.scope) : undefined;
1090
+ if (args.scope && !requestedScope) {
1091
+ return `Unknown memory scope '${args.scope}'.`;
1092
+ }
1093
+ const activeScope = getMemoryActiveScope();
1094
+ const scope = requestedScope ?? activeScope ?? undefined;
1095
+ const actionItems = listActionItems({
1096
+ scope_id: scope?.id,
1097
+ status: args.status,
1098
+ due_before: args.due_before,
1099
+ includeArchived: args.includeArchived,
1100
+ });
1101
+ return {
1102
+ active_scope: activeScope ? { slug: activeScope.slug, title: activeScope.title } : null,
1103
+ scope: scope ? { slug: scope.slug, title: scope.title } : null,
1104
+ action_items: actionItems.map((item) => ({
1105
+ id: item.id,
1106
+ scope_id: item.scopeId,
1107
+ entity_id: item.entityId,
1108
+ title: item.title,
1109
+ detail: item.detail,
1110
+ status: item.status,
1111
+ due_at: item.dueAt,
1112
+ snooze_until: item.snoozeUntil,
1113
+ source: item.source,
1114
+ created_at: item.createdAt,
1115
+ updated_at: item.updatedAt,
1116
+ resolved_at: item.resolvedAt,
1117
+ resolution_reason: item.resolutionReason,
1118
+ })),
1119
+ };
1120
+ },
1121
+ }),
875
1122
  defineTool("memory_recall", {
876
1123
  description: "Search scoped agent memory with FTS-backed recall. Use this for the new agent-memory store, not the wiki-backed recall tool.",
877
1124
  parameters: z.object({
878
1125
  query: z.string().describe("Query text to search for."),
879
1126
  scope: z.string().optional().describe("Optional scope slug. Defaults to the active scope when available."),
880
- kinds: z.array(z.enum(["observation", "decision", "entity"])).optional()
1127
+ kinds: z.array(z.enum(["observation", "decision", "entity", "action_item"])).optional()
881
1128
  .describe("Optional filter for memory entry kinds."),
882
1129
  limit: z.number().int().positive().optional().describe("Maximum number of ranked hits to return. Defaults to 10."),
883
1130
  includeSuperseded: z.boolean().optional().describe("Include superseded observations and decisions. Defaults to false."),
@@ -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
  });
@@ -194,6 +195,178 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
194
195
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
195
196
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
196
197
  });
198
+ test("memory_propose from a persistent agent is bound to that agent's scope", async () => {
199
+ const home = process.env.CHAPTERHOUSE_HOME;
200
+ assert.ok(home, "test home should be set");
201
+ const chapterhouseHome = home.endsWith(".chapterhouse") ? home : join(home, ".chapterhouse");
202
+ const agentsDir = join(chapterhouseHome, "agents");
203
+ mkdirSync(agentsDir, { recursive: true });
204
+ writeFileSync(join(agentsDir, "bellonda.agent.md"), [
205
+ "---",
206
+ "name: Bellonda",
207
+ "description: Mentat of the infrastructure domain",
208
+ "model: claude-sonnet-4.6",
209
+ "persistent: true",
210
+ "scope: infra",
211
+ "---",
212
+ "",
213
+ "You are Bellonda.",
214
+ ].join("\n"));
215
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
216
+ agentsModule.loadAgents();
217
+ const tools = toolsModule.createTools({
218
+ client: { async listModels() { return []; } },
219
+ onAgentTaskComplete: () => { },
220
+ });
221
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
222
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
223
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
224
+ const bellondaTools = bindToolsToAgent("bellonda", tools, "task-persistent-scope-001");
225
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
226
+ const memoryModule = await import("../memory/index.js");
227
+ const proposed = await memoryModule.withActiveScope("infra", () => findTool(bellondaTools, "memory_propose").handler({
228
+ kind: "observation",
229
+ scope_slug: "chapterhouse",
230
+ payload: {
231
+ content: "Persistent agents should not be able to write proposals outside their bound scope.",
232
+ },
233
+ }, {}));
234
+ assert.equal(proposed.status, "queued");
235
+ const row = dbModule.getDb().prepare(`
236
+ SELECT source_agent, source_task_id, payload
237
+ FROM mem_inbox
238
+ WHERE id = ?
239
+ `).get(proposed.proposal_id);
240
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
241
+ assert.equal(row.source_agent, "bellonda");
242
+ assert.equal(row.source_task_id, "task-persistent-scope-001");
243
+ const payload = JSON.parse(row.payload);
244
+ assert.equal(payload.scope_slug, "infra");
245
+ });
246
+ test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
247
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
248
+ const tools = toolsModule.createTools({
249
+ client: { async listModels() { return []; } },
250
+ onAgentTaskComplete: () => { },
251
+ });
252
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
253
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
254
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
255
+ const coderTools = bindToolsToAgent("coder", tools, "task-entity-propose");
256
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
257
+ const proposed = await findTool(coderTools, "memory_propose").handler({
258
+ kind: "entity",
259
+ payload: {
260
+ name: "truenas",
261
+ entity_kind: "host",
262
+ summary: "NAS host used by Bellonda.",
263
+ },
264
+ confidence: 0.8,
265
+ }, {});
266
+ assert.equal(proposed.status, "queued");
267
+ const row = dbModule.getDb().prepare(`
268
+ SELECT payload
269
+ FROM mem_inbox
270
+ WHERE id = ?
271
+ `).get(proposed.proposal_id);
272
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
273
+ const payload = JSON.parse(row.payload);
274
+ assert.equal(payload.kind, "entity");
275
+ assert.equal(payload.scope_slug, "chapterhouse");
276
+ assert.deepEqual(payload.payload, {
277
+ name: "truenas",
278
+ entity_kind: "host",
279
+ summary: "NAS host used by Bellonda.",
280
+ });
281
+ });
282
+ test("action item memory tools add, list, complete, drop, and snooze action items", async () => {
283
+ const { toolsModule, agentsModule } = await loadModules();
284
+ const tools = toolsModule.createTools({
285
+ client: { async listModels() { return []; } },
286
+ onAgentTaskComplete: () => { },
287
+ });
288
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
289
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
290
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
291
+ const coderTools = bindToolsToAgent("coder", tools);
292
+ const memoryAddActionItem = findTool(chapterhouseTools, "memory_add_action_item");
293
+ const memoryListActionItems = findTool(coderTools, "memory_list_action_items");
294
+ const memoryCompleteActionItem = findTool(chapterhouseTools, "memory_complete_action_item");
295
+ const memoryDropActionItem = findTool(chapterhouseTools, "memory_drop_action_item");
296
+ const memorySnoozeActionItem = findTool(chapterhouseTools, "memory_snooze_action_item");
297
+ const visibleToCoder = agentsModule.filterToolsForAgent({
298
+ slug: "coder",
299
+ name: "Coder",
300
+ description: "Software engineer",
301
+ model: "gpt-5.4",
302
+ systemMessage: "test",
303
+ }, tools);
304
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_list_action_items"), true);
305
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_add_action_item"), false);
306
+ const added = await memoryAddActionItem.handler({
307
+ scope: "chapterhouse",
308
+ title: "Migrate feature ideas",
309
+ detail: "Move the parked feature-ideas.md page into mem_action_items.",
310
+ due_at: "2026-05-15T12:00:00.000Z",
311
+ entity_name: "Chapterhouse",
312
+ entity_kind: "project",
313
+ source: "test",
314
+ }, {});
315
+ assert.equal(added.ok, true);
316
+ const addedId = added.id;
317
+ const listed = await memoryListActionItems.handler({ scope: "chapterhouse" }, {});
318
+ assert.equal((listed.action_items).some((item) => item.id === addedId), true);
319
+ const completed = await memoryCompleteActionItem.handler({ id: addedId, resolution_reason: "Done." }, {});
320
+ assert.equal(completed.ok, true);
321
+ assert.equal(completed.status, "done");
322
+ const afterComplete = await memoryListActionItems.handler({ scope: "chapterhouse" }, {});
323
+ assert.equal((afterComplete.action_items).some((item) => item.id === addedId), false);
324
+ const dropped = await memoryAddActionItem.handler({ scope: "chapterhouse", title: "Drop me" }, {});
325
+ const droppedResult = await memoryDropActionItem.handler({
326
+ id: dropped.id,
327
+ reason: "No longer needed.",
328
+ }, {});
329
+ assert.equal(droppedResult.status, "dropped");
330
+ const snoozed = await memoryAddActionItem.handler({ scope: "chapterhouse", title: "Snooze me" }, {});
331
+ const snoozedResult = await memorySnoozeActionItem.handler({
332
+ id: snoozed.id,
333
+ snooze_until: "2999-01-01T00:00:00.000Z",
334
+ }, {});
335
+ assert.equal(snoozedResult.status, "snoozed");
336
+ });
337
+ test("memory_propose accepts action_item proposals with a resolvable payload shape", async () => {
338
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
339
+ const tools = toolsModule.createTools({
340
+ client: { async listModels() { return []; } },
341
+ onAgentTaskComplete: () => { },
342
+ });
343
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
344
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
345
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
346
+ const coderTools = bindToolsToAgent("coder", tools, "task-action-propose");
347
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
348
+ const proposed = await findTool(coderTools, "memory_propose").handler({
349
+ kind: "action_item",
350
+ payload: {
351
+ title: "Remind infra about high disk usage",
352
+ detail: "Next time disk exceeds 85%, notify Bellonda.",
353
+ due_at: "2026-05-15T12:00:00.000Z",
354
+ source: "test",
355
+ },
356
+ confidence: 0.8,
357
+ }, {});
358
+ assert.equal(proposed.status, "queued");
359
+ const row = dbModule.getDb().prepare(`
360
+ SELECT payload
361
+ FROM mem_inbox
362
+ WHERE id = ?
363
+ `).get(proposed.proposal_id);
364
+ const payload = JSON.parse(row.payload);
365
+ assert.equal(payload.kind, "action_item");
366
+ assert.equal(payload.scope_slug, "chapterhouse");
367
+ assert.equal(payload.payload.title, "Remind infra about high disk usage");
368
+ assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
369
+ });
197
370
  test("memory_propose rejects invalid proposal kinds", async () => {
198
371
  const { toolsModule } = await loadModules();
199
372
  const tools = toolsModule.createTools({
@@ -205,7 +378,7 @@ test("memory_propose rejects invalid proposal kinds", async () => {
205
378
  kind: "pattern",
206
379
  payload: { content: "invalid kind" },
207
380
  }, {});
208
- assert.match(String(result), /observation|decision|entity/i);
381
+ assert.match(String(result), /observation|decision|entity|action_item/i);
209
382
  });
210
383
  test("memory_housekeep is orchestrator-only and returns housekeeping summaries", async () => {
211
384
  const { toolsModule, agentsModule, dbModule } = await loadModules();
package/dist/daemon.js CHANGED
@@ -19,7 +19,9 @@ import { registerShutdownSignals } from "./shutdown-signals.js";
19
19
  import { logger } from "./util/logger.js";
20
20
  import { CHAPTERHOUSE_VERSION } from "./version.js";
21
21
  import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
22
+ import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
22
23
  const log = logger.child({ module: "daemon" });
24
+ let memoryHousekeepingScheduler;
23
25
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
24
26
  /**
25
27
  * How long the daemon waits for in-flight work to finish before forcing an exit.
@@ -149,6 +151,8 @@ async function main() {
149
151
  });
150
152
  // Start HTTP API + serve the web UI
151
153
  await startApiServer();
154
+ memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
155
+ memoryHousekeepingScheduler.start();
152
156
  if (config.chapterhouseMode === "personal" && (config.adoPat || config.teamChapterhouseUrl)) {
153
157
  new StandupScheduler().schedule();
154
158
  }
@@ -202,6 +206,7 @@ async function shutdown() {
202
206
  forceTimer.unref();
203
207
  // Destroy all active agent sessions
204
208
  await shutdownAgents();
209
+ await memoryHousekeepingScheduler?.stop();
205
210
  try {
206
211
  stopEpisodeWriter();
207
212
  }
@@ -223,6 +228,7 @@ export async function restartDaemon() {
223
228
  }
224
229
  // Destroy all active agent sessions
225
230
  await shutdownAgents();
231
+ await memoryHousekeepingScheduler?.stop();
226
232
  try {
227
233
  stopEpisodeWriter();
228
234
  }