chapterhouse 0.4.2 → 0.4.3

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.
@@ -227,7 +227,7 @@ function getAgentBasePrompt() {
227
227
  You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
228
228
 
229
229
  ### Agent Memory
230
- Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, and a named system/tool/person can be proposed as an \`entity\`.
230
+ Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\` and \`memory_list_action_items\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, a named system/tool/person can be proposed as an \`entity\`, and a reminder/follow-up can be proposed as an \`action_item\`.
231
231
 
232
232
  ### Shared Wiki
233
233
  All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
@@ -275,7 +275,7 @@ export function buildAgentRoster() {
275
275
  const WIKI_TOOL_NAMES = new Set([
276
276
  "wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
277
277
  "wiki_ingest", "wiki_lint", "wiki_rebuild_index",
278
- "memory_recall", "memory_propose",
278
+ "memory_recall", "memory_propose", "memory_list_action_items",
279
279
  ]);
280
280
  // Management tools that only @chapterhouse should have
281
281
  const MANAGEMENT_TOOL_NAMES = new Set([
@@ -285,6 +285,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
285
285
  "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
286
286
  "list_machine_sessions", "attach_machine_session",
287
287
  "memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
288
+ "memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
288
289
  ]);
289
290
  export function getCurrentToolAgentSlug() {
290
291
  return toolAgentContext.getStore();
@@ -380,13 +380,49 @@ export function feedAgentResult(taskId, agentSlug, result) {
380
380
  log.error({ err: error, taskId }, "memory.eot.error");
381
381
  });
382
382
  }
383
- const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
384
- const sessionKey = getTaskSessionKey(taskId);
383
+ const sessionKey = getTaskSessionKey(taskId) || "default";
384
+ const agentTurnId = randomUUID();
385
+ const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
386
+ try {
387
+ emitTurnEvent(sessionKey, {
388
+ type: "turn:started",
389
+ turnId: agentTurnId,
390
+ sessionKey,
391
+ prompt: "",
392
+ agentSlug,
393
+ agentDisplayName,
394
+ });
395
+ const chunkSize = 500;
396
+ const chunks = result.length === 0 ? [""] : Array.from({ length: Math.ceil(result.length / chunkSize) }, (_, index) => result.slice(index * chunkSize, (index + 1) * chunkSize));
397
+ for (const chunk of chunks) {
398
+ emitTurnEvent(sessionKey, {
399
+ type: "turn:delta",
400
+ turnId: agentTurnId,
401
+ sessionKey,
402
+ part: { type: "text", text: chunk },
403
+ });
404
+ }
405
+ finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId: agentTurnId, finalMessage: result });
406
+ }
407
+ catch (err) {
408
+ log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to emit synthetic agent reply turn");
409
+ }
410
+ try {
411
+ logConversation("agent_completion", result, "background", sessionKey, {
412
+ agentSlug,
413
+ agentDisplayName,
414
+ turnId: agentTurnId,
415
+ });
416
+ }
417
+ catch (err) {
418
+ log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
419
+ }
420
+ const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
385
421
  sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
386
422
  if (done && proactiveNotifyFn) {
387
423
  proactiveNotifyFn(text);
388
424
  }
389
- });
425
+ }, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
390
426
  }
391
427
  function sleep(ms) {
392
428
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1034,7 +1070,7 @@ function isRecoverableError(err) {
1034
1070
  return false;
1035
1071
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
1036
1072
  }
1037
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
1073
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
1038
1074
  updateUserContext(source);
1039
1075
  updateRequestContext(source);
1040
1076
  // Use the externally-supplied turnId if provided (POST→SSE path needs the ID
@@ -1103,12 +1139,14 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1103
1139
  logMessage("out", sourceLabel, finalContent);
1104
1140
  }
1105
1141
  catch { /* best-effort */ }
1106
- try {
1107
- logConversation(logRole, prompt, sourceLabel, sessionKey, turnId);
1142
+ if (!options?.suppressPromptLog) {
1143
+ try {
1144
+ logConversation(logRole, prompt, sourceLabel, sessionKey, { turnId });
1145
+ }
1146
+ catch { /* best-effort */ }
1108
1147
  }
1109
- catch { /* best-effort */ }
1110
1148
  try {
1111
- logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
1149
+ logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1112
1150
  }
1113
1151
  catch { /* best-effort */ }
1114
1152
  scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
@@ -1205,11 +1243,11 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1205
1243
  }
1206
1244
  catch { /* best-effort */ }
1207
1245
  try {
1208
- logConversation("user", newPrompt, sourceLabel, sessionKey, turnId);
1246
+ logConversation("user", newPrompt, sourceLabel, sessionKey, { turnId });
1209
1247
  }
1210
1248
  catch { /* best-effort */ }
1211
1249
  try {
1212
- logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
1250
+ logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1213
1251
  }
1214
1252
  catch { /* best-effort */ }
1215
1253
  scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
@@ -249,8 +249,14 @@ async function loadOrchestratorModule(t, overrides = {}) {
249
249
  });
250
250
  t.mock.module("../store/db.js", {
251
251
  namedExports: {
252
- logConversation: (role, content, source) => {
253
- state.dbLogs.push({ role, content, source });
252
+ logConversation: (role, content, source, sessionKey, metadata) => {
253
+ state.dbLogs.push({
254
+ role,
255
+ content,
256
+ source,
257
+ ...(sessionKey && sessionKey !== "default" ? { sessionKey } : {}),
258
+ ...metadata,
259
+ });
254
260
  },
255
261
  getState: (key) => state.store.get(key),
256
262
  setState: (key, value) => {
@@ -559,9 +565,11 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
559
565
  { direction: "in", source: "web", text: "Summarize the deployment" },
560
566
  { direction: "out", source: "web", text: "All green" },
561
567
  ]);
568
+ const loggedTurnId = state.dbLogs[0]?.turnId;
569
+ assert.equal(typeof loggedTurnId, "string");
562
570
  assert.deepEqual(state.dbLogs, [
563
- { role: "user", content: "Summarize the deployment", source: "web" },
564
- { role: "assistant", content: "All green", source: "web" },
571
+ { role: "user", content: "Summarize the deployment", source: "web", turnId: loggedTurnId },
572
+ { role: "assistant", content: "All green", source: "web", turnId: loggedTurnId },
565
573
  ]);
566
574
  assert.equal(state.episodeWrites, 1);
567
575
  });
@@ -999,7 +1007,7 @@ test("@mentions route through the orchestrator session without invoking the mode
999
1007
  assert.deepEqual(state.routerArgs, []);
1000
1008
  assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
1001
1009
  });
1002
- test("feedAgentResult injects a background completion turn and proactively notifies listeners", async (t) => {
1010
+ test("feedAgentResult emits an attributed agent reply turn and sends only a short orchestrator prompt", async (t) => {
1003
1011
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1004
1012
  config: {
1005
1013
  copilotModel: "claude-sonnet-4.6",
@@ -1016,25 +1024,62 @@ test("feedAgentResult injects a background completion turn and proactively notif
1016
1024
  orchestrator.feedAgentResult("task-9", "coder", "Fixed the flaky test");
1017
1025
  assert.equal(await notified, "Agent complete");
1018
1026
  assert.deepEqual(state.sessionPrompts, [{
1019
- prompt: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
1027
+ prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
1020
1028
  }]);
1029
+ assert.equal(state.sessionPrompts[0]?.prompt.includes("Fixed the flaky test"), false, "orchestrator notification must not include the full agent reply body");
1030
+ const started = events.filter((event) => event.type === "turn:started");
1031
+ const deltas = events.filter((event) => event.type === "turn:delta");
1032
+ const completed = events.filter((event) => event.type === "turn:complete");
1033
+ assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
1034
+ assert.equal(deltas.length, 1, "agent reply should stream as one or more deltas");
1035
+ assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
1036
+ assert.equal(started[0]?.agentSlug, "coder");
1037
+ assert.equal(started[0]?.agentDisplayName, "Kaylee");
1038
+ assert.equal(started[0]?.prompt, "");
1021
1039
  assert.deepEqual(state.dbLogs, [
1022
1040
  {
1023
1041
  role: "agent_completion",
1024
- content: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
1042
+ content: "Fixed the flaky test",
1025
1043
  source: "background",
1044
+ sessionKey: "chat:bg-lifecycle",
1045
+ agentSlug: "coder",
1046
+ agentDisplayName: "Kaylee",
1047
+ turnId: started[0]?.turnId,
1026
1048
  },
1027
1049
  {
1028
1050
  role: "assistant",
1029
1051
  content: "Agent complete",
1030
1052
  source: "background",
1053
+ sessionKey: "chat:bg-lifecycle",
1054
+ turnId: started[1]?.turnId,
1031
1055
  },
1032
1056
  ]);
1033
- const started = events.filter((event) => event.type === "turn:started");
1034
- const completed = events.filter((event) => event.type === "turn:complete");
1035
- assert.equal(started.length, 1, "background turn should emit one turn:started event");
1036
- assert.equal(completed.length, 1, "background turn should emit one turn:complete event");
1037
- assert.equal(started[0]?.turnId, completed[0]?.turnId, "background lifecycle events must share the same turnId");
1057
+ assert.equal(deltas[0]?.turnId, started[0]?.turnId);
1058
+ assert.deepEqual(deltas[0]?.part, { type: "text", text: "Fixed the flaky test" });
1059
+ assert.equal(completed[0]?.turnId, started[0]?.turnId);
1060
+ assert.equal(completed[0]?.finalMessage, "Fixed the flaky test");
1061
+ assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
1062
+ });
1063
+ test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
1064
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
1065
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
1066
+ sendResult: "Acknowledged",
1067
+ taskSessionKeys: new Map([["task-empty", "chat:bg-empty"]]),
1068
+ });
1069
+ await orchestrator.initOrchestrator(client);
1070
+ const events = captureSessionEvents(t, "chat:bg-empty");
1071
+ const notified = new Promise((resolve) => {
1072
+ orchestrator.setProactiveNotify(resolve);
1073
+ });
1074
+ orchestrator.feedAgentResult("task-empty", "coder", "");
1075
+ assert.equal(await notified, "Acknowledged");
1076
+ const agentStarted = events.find((event) => event.type === "turn:started" && event.agentSlug === "coder");
1077
+ assert.ok(agentStarted, "agent reply should emit a started event");
1078
+ const deltas = events
1079
+ .filter((event) => event.type === "turn:delta")
1080
+ .filter((event) => event.turnId === agentStarted.turnId);
1081
+ assert.equal(deltas.length, 1);
1082
+ assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
1038
1083
  });
1039
1084
  test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
1040
1085
  const { orchestrator, client } = await loadOrchestratorModule(t, {
@@ -1227,11 +1272,11 @@ test("feedAgentResult routes to a non-default session when the task's session_ke
1227
1272
  // A second createSession call proves the orchestrator opened a fresh non-default session
1228
1273
  // rather than reusing the already-open default session.
1229
1274
  assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
1230
- // The prompt must reference the task and agent
1275
+ // The prompt must reference the task and agent but not include the full reply body.
1231
1276
  const prompt = state.sessionPrompts.at(-1);
1232
1277
  assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
1233
1278
  assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
1234
- assert.ok(prompt?.prompt.includes("Feature done"), "prompt should include the result text");
1279
+ assert.equal(prompt?.prompt.includes("Feature done"), false, "prompt should not include the result text");
1235
1280
  });
1236
1281
  test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
1237
1282
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
@@ -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) {
@@ -106,11 +106,31 @@ const decisionProposalPayloadSchema = z.object({
106
106
  });
107
107
  const entityProposalPayloadSchema = z.object({
108
108
  name: z.string(),
109
- kind: z.string(),
109
+ entity_kind: z.string().optional(),
110
+ kind: z.string().optional(),
110
111
  summary: z.string().optional(),
112
+ }).superRefine((value, context) => {
113
+ if (!value.entity_kind && !value.kind) {
114
+ context.addIssue({
115
+ code: z.ZodIssueCode.custom,
116
+ message: "entity_kind is required for entity proposals.",
117
+ path: ["entity_kind"],
118
+ });
119
+ }
120
+ }).transform((value) => ({
121
+ name: value.name,
122
+ entity_kind: value.entity_kind ?? value.kind,
123
+ summary: value.summary,
124
+ }));
125
+ const actionItemProposalPayloadSchema = z.object({
126
+ title: z.string(),
127
+ detail: z.string().optional(),
128
+ due_at: z.string().optional(),
129
+ source: z.string().optional(),
130
+ entity_id: z.number().int().positive().optional(),
111
131
  });
112
132
  const memoryProposeArgsSchema = z.object({
113
- kind: z.enum(["observation", "decision", "entity"]),
133
+ kind: z.enum(["observation", "decision", "entity", "action_item"]),
114
134
  scope_slug: z.string().optional(),
115
135
  payload: z.record(z.string(), z.unknown()),
116
136
  confidence: z.number().min(0).max(1).optional(),
@@ -120,7 +140,9 @@ const memoryProposeArgsSchema = z.object({
120
140
  ? observationProposalPayloadSchema
121
141
  : value.kind === "decision"
122
142
  ? decisionProposalPayloadSchema
123
- : entityProposalPayloadSchema;
143
+ : value.kind === "entity"
144
+ ? entityProposalPayloadSchema
145
+ : actionItemProposalPayloadSchema;
124
146
  const parsed = schema.safeParse(value.payload);
125
147
  if (!parsed.success) {
126
148
  for (const issue of parsed.error.issues) {
@@ -132,7 +154,7 @@ const memoryProposeArgsSchema = z.object({
132
154
  }
133
155
  }
134
156
  });
135
- const memoryTierTableSchema = z.enum(["observation", "decision", "entity"]);
157
+ const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
136
158
  function getCurrentQuarter(now = new Date()) {
137
159
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
138
160
  }
@@ -849,7 +871,9 @@ export function createTools(deps) {
849
871
  ? observationProposalPayloadSchema.parse(parsedArgs.payload)
850
872
  : parsedArgs.kind === "decision"
851
873
  ? decisionProposalPayloadSchema.parse(parsedArgs.payload)
852
- : entityProposalPayloadSchema.parse(parsedArgs.payload);
874
+ : parsedArgs.kind === "entity"
875
+ ? entityProposalPayloadSchema.parse(parsedArgs.payload)
876
+ : actionItemProposalPayloadSchema.parse(parsedArgs.payload);
853
877
  const proposal = queueMemoryProposal({
854
878
  kind: parsedArgs.kind,
855
879
  scopeSlug: parsedArgs.scope_slug,
@@ -872,12 +896,164 @@ export function createTools(deps) {
872
896
  }
873
897
  },
874
898
  }),
899
+ defineTool("memory_add_action_item", {
900
+ description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
901
+ parameters: z.object({
902
+ scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope, then keyword inference."),
903
+ title: z.string().min(1).describe("Short action item title."),
904
+ detail: z.string().optional().describe("Longer action item detail."),
905
+ due_at: z.string().optional().describe("Optional ISO due timestamp."),
906
+ entity_name: z.string().optional().describe("Optional entity name to attach. Auto-upserts the entity if provided."),
907
+ entity_kind: z.string().optional().describe("Required when entity_name is provided."),
908
+ source: z.string().optional().describe("Action item source, e.g. manual, subagent_proposal, external."),
909
+ }),
910
+ handler: async (args) => {
911
+ const denied = requireOrchestratorMemoryWrite();
912
+ if (denied)
913
+ return denied;
914
+ if (args.entity_name && !args.entity_kind) {
915
+ return "entity_kind is required when entity_name is provided.";
916
+ }
917
+ try {
918
+ const { scopeId, scopeSlug } = resolveMemoryScopeForWrite(args.scope, `${args.title}\n${args.detail ?? ""}`);
919
+ const entity = args.entity_name
920
+ ? upsertEntity({
921
+ scope_id: scopeId,
922
+ kind: args.entity_kind,
923
+ name: args.entity_name,
924
+ })
925
+ : undefined;
926
+ const actionItem = recordActionItem({
927
+ scope_id: scopeId,
928
+ entity_id: entity?.id,
929
+ title: args.title,
930
+ detail: args.detail,
931
+ due_at: args.due_at,
932
+ source: args.source ?? "manual",
933
+ });
934
+ return {
935
+ ok: true,
936
+ id: actionItem.id,
937
+ scope: scopeSlug,
938
+ status: actionItem.status,
939
+ entity_id: entity?.id,
940
+ };
941
+ }
942
+ catch (err) {
943
+ return err instanceof Error ? err.message : String(err);
944
+ }
945
+ },
946
+ }),
947
+ defineTool("memory_complete_action_item", {
948
+ description: "Complete a memory action item. Orchestrator-only write tool.",
949
+ parameters: z.object({
950
+ id: z.number().int().positive(),
951
+ resolution_reason: z.string().optional(),
952
+ }),
953
+ handler: async (args) => {
954
+ const denied = requireOrchestratorMemoryWrite();
955
+ if (denied)
956
+ return denied;
957
+ try {
958
+ const actionItem = completeActionItem(args.id, args.resolution_reason);
959
+ return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
960
+ }
961
+ catch (err) {
962
+ return err instanceof Error ? err.message : String(err);
963
+ }
964
+ },
965
+ }),
966
+ defineTool("memory_drop_action_item", {
967
+ description: "Drop a memory action item with a reason. Orchestrator-only write tool.",
968
+ parameters: z.object({
969
+ id: z.number().int().positive(),
970
+ reason: z.string().min(1),
971
+ }),
972
+ handler: async (args) => {
973
+ const denied = requireOrchestratorMemoryWrite();
974
+ if (denied)
975
+ return denied;
976
+ try {
977
+ const actionItem = dropActionItem(args.id, args.reason);
978
+ return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
979
+ }
980
+ catch (err) {
981
+ return err instanceof Error ? err.message : String(err);
982
+ }
983
+ },
984
+ }),
985
+ defineTool("memory_snooze_action_item", {
986
+ description: "Snooze a memory action item until an ISO timestamp. Orchestrator-only write tool.",
987
+ parameters: z.object({
988
+ id: z.number().int().positive(),
989
+ snooze_until: z.string().min(1),
990
+ }),
991
+ handler: async (args) => {
992
+ const denied = requireOrchestratorMemoryWrite();
993
+ if (denied)
994
+ return denied;
995
+ try {
996
+ const actionItem = snoozeActionItem(args.id, args.snooze_until);
997
+ return {
998
+ ok: true,
999
+ id: actionItem.id,
1000
+ status: actionItem.status,
1001
+ snooze_until: actionItem.snoozeUntil,
1002
+ };
1003
+ }
1004
+ catch (err) {
1005
+ return err instanceof Error ? err.message : String(err);
1006
+ }
1007
+ },
1008
+ }),
1009
+ defineTool("memory_list_action_items", {
1010
+ description: "List scoped memory action items/reminders. Defaults to currently actionable open items.",
1011
+ parameters: z.object({
1012
+ scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope when available."),
1013
+ status: z.enum(["open", "done", "dropped", "snoozed"]).optional().describe("Optional status filter."),
1014
+ due_before: z.string().optional().describe("Optional ISO timestamp; only include items due at or before this time."),
1015
+ includeArchived: z.boolean().optional().describe("Include cold-tier items. Defaults to false."),
1016
+ }),
1017
+ handler: async (args) => {
1018
+ const requestedScope = args.scope ? getMemoryScope(args.scope) : undefined;
1019
+ if (args.scope && !requestedScope) {
1020
+ return `Unknown memory scope '${args.scope}'.`;
1021
+ }
1022
+ const activeScope = getMemoryActiveScope();
1023
+ const scope = requestedScope ?? activeScope ?? undefined;
1024
+ const actionItems = listActionItems({
1025
+ scope_id: scope?.id,
1026
+ status: args.status,
1027
+ due_before: args.due_before,
1028
+ includeArchived: args.includeArchived,
1029
+ });
1030
+ return {
1031
+ active_scope: activeScope ? { slug: activeScope.slug, title: activeScope.title } : null,
1032
+ scope: scope ? { slug: scope.slug, title: scope.title } : null,
1033
+ action_items: actionItems.map((item) => ({
1034
+ id: item.id,
1035
+ scope_id: item.scopeId,
1036
+ entity_id: item.entityId,
1037
+ title: item.title,
1038
+ detail: item.detail,
1039
+ status: item.status,
1040
+ due_at: item.dueAt,
1041
+ snooze_until: item.snoozeUntil,
1042
+ source: item.source,
1043
+ created_at: item.createdAt,
1044
+ updated_at: item.updatedAt,
1045
+ resolved_at: item.resolvedAt,
1046
+ resolution_reason: item.resolutionReason,
1047
+ })),
1048
+ };
1049
+ },
1050
+ }),
875
1051
  defineTool("memory_recall", {
876
1052
  description: "Search scoped agent memory with FTS-backed recall. Use this for the new agent-memory store, not the wiki-backed recall tool.",
877
1053
  parameters: z.object({
878
1054
  query: z.string().describe("Query text to search for."),
879
1055
  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()
1056
+ kinds: z.array(z.enum(["observation", "decision", "entity", "action_item"])).optional()
881
1057
  .describe("Optional filter for memory entry kinds."),
882
1058
  limit: z.number().int().positive().optional().describe("Maximum number of ranked hits to return. Defaults to 10."),
883
1059
  includeSuperseded: z.boolean().optional().describe("Include superseded observations and decisions. Defaults to false."),
@@ -194,6 +194,130 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
194
194
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
195
195
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
196
196
  });
197
+ test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
198
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
199
+ const tools = toolsModule.createTools({
200
+ client: { async listModels() { return []; } },
201
+ onAgentTaskComplete: () => { },
202
+ });
203
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
204
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
205
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
206
+ const coderTools = bindToolsToAgent("coder", tools, "task-entity-propose");
207
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
208
+ const proposed = await findTool(coderTools, "memory_propose").handler({
209
+ kind: "entity",
210
+ payload: {
211
+ name: "truenas",
212
+ entity_kind: "host",
213
+ summary: "NAS host used by Bellonda.",
214
+ },
215
+ confidence: 0.8,
216
+ }, {});
217
+ assert.equal(proposed.status, "queued");
218
+ const row = dbModule.getDb().prepare(`
219
+ SELECT payload
220
+ FROM mem_inbox
221
+ WHERE id = ?
222
+ `).get(proposed.proposal_id);
223
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
224
+ const payload = JSON.parse(row.payload);
225
+ assert.equal(payload.kind, "entity");
226
+ assert.equal(payload.scope_slug, "chapterhouse");
227
+ assert.deepEqual(payload.payload, {
228
+ name: "truenas",
229
+ entity_kind: "host",
230
+ summary: "NAS host used by Bellonda.",
231
+ });
232
+ });
233
+ test("action item memory tools add, list, complete, drop, and snooze action items", async () => {
234
+ const { toolsModule, agentsModule } = await loadModules();
235
+ const tools = toolsModule.createTools({
236
+ client: { async listModels() { return []; } },
237
+ onAgentTaskComplete: () => { },
238
+ });
239
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
240
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
241
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
242
+ const coderTools = bindToolsToAgent("coder", tools);
243
+ const memoryAddActionItem = findTool(chapterhouseTools, "memory_add_action_item");
244
+ const memoryListActionItems = findTool(coderTools, "memory_list_action_items");
245
+ const memoryCompleteActionItem = findTool(chapterhouseTools, "memory_complete_action_item");
246
+ const memoryDropActionItem = findTool(chapterhouseTools, "memory_drop_action_item");
247
+ const memorySnoozeActionItem = findTool(chapterhouseTools, "memory_snooze_action_item");
248
+ const visibleToCoder = agentsModule.filterToolsForAgent({
249
+ slug: "coder",
250
+ name: "Coder",
251
+ description: "Software engineer",
252
+ model: "gpt-5.4",
253
+ systemMessage: "test",
254
+ }, tools);
255
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_list_action_items"), true);
256
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_add_action_item"), false);
257
+ const added = await memoryAddActionItem.handler({
258
+ scope: "chapterhouse",
259
+ title: "Migrate feature ideas",
260
+ detail: "Move the parked feature-ideas.md page into mem_action_items.",
261
+ due_at: "2026-05-15T12:00:00.000Z",
262
+ entity_name: "Chapterhouse",
263
+ entity_kind: "project",
264
+ source: "test",
265
+ }, {});
266
+ assert.equal(added.ok, true);
267
+ const addedId = added.id;
268
+ const listed = await memoryListActionItems.handler({ scope: "chapterhouse" }, {});
269
+ assert.equal((listed.action_items).some((item) => item.id === addedId), true);
270
+ const completed = await memoryCompleteActionItem.handler({ id: addedId, resolution_reason: "Done." }, {});
271
+ assert.equal(completed.ok, true);
272
+ assert.equal(completed.status, "done");
273
+ const afterComplete = await memoryListActionItems.handler({ scope: "chapterhouse" }, {});
274
+ assert.equal((afterComplete.action_items).some((item) => item.id === addedId), false);
275
+ const dropped = await memoryAddActionItem.handler({ scope: "chapterhouse", title: "Drop me" }, {});
276
+ const droppedResult = await memoryDropActionItem.handler({
277
+ id: dropped.id,
278
+ reason: "No longer needed.",
279
+ }, {});
280
+ assert.equal(droppedResult.status, "dropped");
281
+ const snoozed = await memoryAddActionItem.handler({ scope: "chapterhouse", title: "Snooze me" }, {});
282
+ const snoozedResult = await memorySnoozeActionItem.handler({
283
+ id: snoozed.id,
284
+ snooze_until: "2999-01-01T00:00:00.000Z",
285
+ }, {});
286
+ assert.equal(snoozedResult.status, "snoozed");
287
+ });
288
+ test("memory_propose accepts action_item proposals with a resolvable payload shape", async () => {
289
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
290
+ const tools = toolsModule.createTools({
291
+ client: { async listModels() { return []; } },
292
+ onAgentTaskComplete: () => { },
293
+ });
294
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
295
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
296
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
297
+ const coderTools = bindToolsToAgent("coder", tools, "task-action-propose");
298
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
299
+ const proposed = await findTool(coderTools, "memory_propose").handler({
300
+ kind: "action_item",
301
+ payload: {
302
+ title: "Remind infra about high disk usage",
303
+ detail: "Next time disk exceeds 85%, notify Bellonda.",
304
+ due_at: "2026-05-15T12:00:00.000Z",
305
+ source: "test",
306
+ },
307
+ confidence: 0.8,
308
+ }, {});
309
+ assert.equal(proposed.status, "queued");
310
+ const row = dbModule.getDb().prepare(`
311
+ SELECT payload
312
+ FROM mem_inbox
313
+ WHERE id = ?
314
+ `).get(proposed.proposal_id);
315
+ const payload = JSON.parse(row.payload);
316
+ assert.equal(payload.kind, "action_item");
317
+ assert.equal(payload.scope_slug, "chapterhouse");
318
+ assert.equal(payload.payload.title, "Remind infra about high disk usage");
319
+ assert.equal(payload.payload.detail, "Next time disk exceeds 85%, notify Bellonda.");
320
+ });
197
321
  test("memory_propose rejects invalid proposal kinds", async () => {
198
322
  const { toolsModule } = await loadModules();
199
323
  const tools = toolsModule.createTools({
@@ -205,7 +329,7 @@ test("memory_propose rejects invalid proposal kinds", async () => {
205
329
  kind: "pattern",
206
330
  payload: { content: "invalid kind" },
207
331
  }, {});
208
- assert.match(String(result), /observation|decision|entity/i);
332
+ assert.match(String(result), /observation|decision|entity|action_item/i);
209
333
  });
210
334
  test("memory_housekeep is orchestrator-only and returns housekeeping summaries", async () => {
211
335
  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
  }