chapterhouse 0.4.1 → 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.
@@ -23,9 +23,9 @@ import { withWikiWrite } from "../wiki/lock.js";
23
23
  import { listSkills, removeSkill } from "../copilot/skills.js";
24
24
  import { restartDaemon } from "../daemon.js";
25
25
  import { API_TOKEN_PATH } from "../paths.js";
26
- import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
26
+ import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
27
27
  import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
28
- import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
28
+ import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
29
29
  import { getStatus, onStatusChange } from "../status.js";
30
30
  import { formatSseData, formatSseEvent } from "./sse.js";
31
31
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
@@ -728,6 +728,7 @@ if (config.chatSseEnabled) {
728
728
  const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
729
729
  if (!sessionKey)
730
730
  throw new BadRequestError("Missing session key");
731
+ const includeHistorical = req.query.include === "all";
731
732
  res.setHeader("Content-Type", "text/event-stream");
732
733
  res.setHeader("Cache-Control", "no-cache");
733
734
  res.setHeader("Connection", "keep-alive");
@@ -738,6 +739,10 @@ if (config.chatSseEnabled) {
738
739
  const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
739
740
  ? parseInt(rawLastId.trim(), 10)
740
741
  : undefined;
742
+ const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
743
+ const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
744
+ ? 0
745
+ : lastSeq;
741
746
  // Helper: send a named SSE event with an id: field
742
747
  const sendEvent = (event, seq) => {
743
748
  const payload = JSON.stringify(event);
@@ -745,13 +750,13 @@ if (config.chatSseEnabled) {
745
750
  };
746
751
  // If Last-Event-ID is present and the session ring buffer doesn't cover it,
747
752
  // fall back to SQLite for replay of completed turns.
748
- let replayHighSeq = lastSeq;
749
- if (lastSeq !== undefined) {
753
+ let replayHighSeq = effectiveLastSeq;
754
+ if (effectiveLastSeq !== undefined) {
750
755
  const oldestBuf = oldestSessionSeq(sessionKey);
751
- const bufferMissesRange = oldestBuf === undefined || oldestBuf > lastSeq + 1;
756
+ const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
752
757
  if (bufferMissesRange) {
753
758
  // Replay from SQLite (completed turns)
754
- const dbEvents = getSessionEventsFromDb(sessionKey, lastSeq);
759
+ const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
755
760
  for (const e of dbEvents) {
756
761
  sendEvent(e, e._seq);
757
762
  if (replayHighSeq === undefined || e._seq > replayHighSeq)
@@ -766,7 +771,7 @@ if (config.chatSseEnabled) {
766
771
  sendEvent(e, e._seq);
767
772
  }, replayHighSeq);
768
773
  // Send connected event
769
- res.write(`: connected session=${sessionKey}\n\n`);
774
+ res.write(`: connected session=${sessionKey} run=${getCurrentRunId()}\n\n`);
770
775
  // Keep-alive every 15 s
771
776
  const keepAlive = setInterval(() => {
772
777
  res.write(`: keep-alive\n\n`);
@@ -1063,7 +1068,8 @@ app.get("/api/session/:sessionKey/messages", (req, res) => {
1063
1068
  if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
1064
1069
  throw new BadRequestError("'limit' must be a positive integer");
1065
1070
  }
1066
- const messages = getSessionMessages(sessionKey, limit);
1071
+ const includeHistorical = req.query.include === "all";
1072
+ const messages = getSessionMessages(sessionKey, limit, { includeHistorical });
1067
1073
  res.json({ sessionKey, messages });
1068
1074
  });
1069
1075
  app.use(apiNotFoundHandler);
@@ -371,6 +371,36 @@ test("server worker detail returns the stored dispatched prompt", async () => {
371
371
  assert.equal(body.completedAt, null);
372
372
  });
373
373
  });
374
+ test("server session message hydration returns current run by default and include=all returns history", async () => {
375
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
376
+ const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
377
+ try {
378
+ const currentRun = db.prepare(`SELECT run_id FROM daemon_runs LIMIT 1`).get();
379
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
380
+ VALUES ('user', ?, 'web', 'hydration-session', ?)`).run("previous run", "previous-run");
381
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
382
+ VALUES ('assistant', ?, 'web', 'hydration-session', ?)`).run("current run", currentRun.run_id);
383
+ }
384
+ finally {
385
+ db.close();
386
+ }
387
+ const currentOnly = await fetch(`${baseUrl}/api/session/hydration-session/messages`, {
388
+ headers: { authorization: authHeader },
389
+ });
390
+ assert.equal(currentOnly.status, 200);
391
+ assert.deepEqual((await currentOnly.json()).messages.map((message) => message.content), [
392
+ "current run",
393
+ ]);
394
+ const allRuns = await fetch(`${baseUrl}/api/session/hydration-session/messages?include=all`, {
395
+ headers: { authorization: authHeader },
396
+ });
397
+ assert.equal(allRuns.status, 200);
398
+ assert.deepEqual((await allRuns.json()).messages.map((message) => message.content), [
399
+ "previous run",
400
+ "current run",
401
+ ]);
402
+ });
403
+ });
374
404
  test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
375
405
  await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
376
406
  rmSync(join(testRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
@@ -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);
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);
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);
1246
+ logConversation("user", newPrompt, sourceLabel, sessionKey, { turnId });
1209
1247
  }
1210
1248
  catch { /* best-effort */ }
1211
1249
  try {
1212
- logConversation("assistant", finalContent, sourceLabel, sessionKey);
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."),