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.
@@ -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();
@@ -20,7 +20,7 @@
20
20
  * @module copilot/turn-event-log
21
21
  */
22
22
  import { childLogger } from "../util/logger.js";
23
- import { getDb } from "../store/db.js";
23
+ import { getCurrentRunId, getDb } from "../store/db.js";
24
24
  import { config } from "../config.js";
25
25
  import { RingBuffer } from "./ring-buffer.js";
26
26
  const log = childLogger("turn-event-log");
@@ -192,9 +192,9 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
192
192
  function persistIndexedTurnEvent(sessionKey, event) {
193
193
  try {
194
194
  const db = getDb();
195
- const stmt = db.prepare(`INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
196
- VALUES (?, ?, ?, ?, ?, ?)`);
197
- stmt.run(event.turnId, sessionKey, event._seq, event._ts, event.type, JSON.stringify(event));
195
+ const stmt = db.prepare(`INSERT INTO turn_events (turn_id, session_key, run_id, seq, ts, event_type, payload)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
197
+ stmt.run(event.turnId, sessionKey, getCurrentRunId(), event._seq, event._ts, event.type, JSON.stringify(event));
198
198
  }
199
199
  catch (err) {
200
200
  log.warn({ err: err instanceof Error ? err.message : err, turnId: event.turnId }, "turn-event-log: SQLite persist failed");
@@ -260,19 +260,24 @@ export function getTurnEventsFromDb(turnId, afterSeq = 0) {
260
260
  return [];
261
261
  }
262
262
  }
263
- /**
264
- * Return persisted events for a session from SQLite, after a given sequence number.
265
- * Used as SSE replay fallback when the session buffer doesn't cover the requested range.
266
- */
267
- export function getSessionEventsFromDb(sessionKey, afterSeq = 0) {
263
+ export function getSessionEventsFromDb(sessionKey, afterSeq = 0, options = {}) {
268
264
  try {
269
265
  const db = getDb();
270
- const rows = db
271
- .prepare(`SELECT payload FROM turn_events
272
- WHERE session_key = ? AND seq > ?
273
- ORDER BY seq ASC
274
- LIMIT ?`)
275
- .all(sessionKey, afterSeq, SESSION_REPLAY_LIMIT);
266
+ const includeHistorical = options.includeHistorical ?? false;
267
+ const runId = options.runId ?? getCurrentRunId();
268
+ const rows = includeHistorical
269
+ ? db
270
+ .prepare(`SELECT payload FROM turn_events
271
+ WHERE session_key = ? AND seq > ?
272
+ ORDER BY id ASC
273
+ LIMIT ?`)
274
+ .all(sessionKey, afterSeq, SESSION_REPLAY_LIMIT)
275
+ : db
276
+ .prepare(`SELECT payload FROM turn_events
277
+ WHERE session_key = ? AND run_id = ? AND seq > ?
278
+ ORDER BY seq ASC
279
+ LIMIT ?`)
280
+ .all(sessionKey, runId, afterSeq, SESSION_REPLAY_LIMIT);
276
281
  return rows.map((r) => JSON.parse(r.payload));
277
282
  }
278
283
  catch (err) {
@@ -280,6 +285,21 @@ export function getSessionEventsFromDb(sessionKey, afterSeq = 0) {
280
285
  return [];
281
286
  }
282
287
  }
288
+ export function getSessionMaxSeqFromDb(sessionKey, options = {}) {
289
+ try {
290
+ const db = getDb();
291
+ const includeHistorical = options.includeHistorical ?? false;
292
+ const runId = options.runId ?? getCurrentRunId();
293
+ const row = includeHistorical
294
+ ? db.prepare(`SELECT MAX(seq) AS max_seq FROM turn_events WHERE session_key = ?`).get(sessionKey)
295
+ : db.prepare(`SELECT MAX(seq) AS max_seq FROM turn_events WHERE session_key = ? AND run_id = ?`).get(sessionKey, runId);
296
+ return row?.max_seq ?? undefined;
297
+ }
298
+ catch (err) {
299
+ log.warn({ err: err instanceof Error ? err.message : err, sessionKey }, "turn-event-log: SQLite session max-seq read failed");
300
+ return undefined;
301
+ }
302
+ }
283
303
  // ---------------------------------------------------------------------------
284
304
  // Diagnostics
285
305
  // ---------------------------------------------------------------------------
@@ -119,6 +119,37 @@ describe("turn-event-log", () => {
119
119
  assert.equal(persisted[0].type, "turn:started");
120
120
  assert.equal(persisted[0].turnId, turnId);
121
121
  });
122
+ it("replays persisted session events from the current daemon run by default and can include historical runs", () => {
123
+ const session = freshSessionKey();
124
+ const db = getDb();
125
+ const columns = db.prepare("PRAGMA table_info(turn_events)").all();
126
+ if (!columns.some((column) => column.name === "run_id")) {
127
+ db.exec("ALTER TABLE turn_events ADD COLUMN run_id TEXT");
128
+ }
129
+ const insert = db.prepare(`INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload, run_id)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
131
+ const previous = {
132
+ type: "turn:complete",
133
+ turnId: "previous-turn",
134
+ sessionKey: session,
135
+ finalMessage: "previous",
136
+ _seq: 10,
137
+ _ts: 10,
138
+ };
139
+ const current = {
140
+ type: "turn:complete",
141
+ turnId: "current-turn",
142
+ sessionKey: session,
143
+ finalMessage: "current",
144
+ _seq: 11,
145
+ _ts: 11,
146
+ };
147
+ insert.run(previous.turnId, session, previous._seq, previous._ts, previous.type, JSON.stringify(previous), "previous-run");
148
+ insert.run(current.turnId, session, current._seq, current._ts, current.type, JSON.stringify(current), "current-run");
149
+ const getEvents = getSessionEventsFromDb;
150
+ assert.deepEqual(getEvents(session, 0, { runId: "current-run" }).map((event) => event.turnId), ["current-turn"]);
151
+ assert.deepEqual(getEvents(session, 0, { runId: "current-run", includeHistorical: true }).map((event) => event.turnId), ["previous-turn", "current-turn"]);
152
+ });
122
153
  });
123
154
  describe("subscribeTurn", () => {
124
155
  it("replays existing buffered events immediately on subscribe", () => {
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
  }
@@ -0,0 +1,100 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toActionItem(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ entityId: row.entity_id ?? undefined,
7
+ title: row.title,
8
+ detail: row.detail ?? undefined,
9
+ status: row.status,
10
+ dueAt: row.due_at ?? undefined,
11
+ snoozeUntil: row.snooze_until ?? undefined,
12
+ source: row.source ?? undefined,
13
+ tier: row.tier,
14
+ tierPinnedAt: row.tier_pinned_at ?? undefined,
15
+ tierReason: row.tier_reason ?? undefined,
16
+ lastRecalledAt: row.last_recalled_at ?? undefined,
17
+ createdAt: row.created_at,
18
+ updatedAt: row.updated_at,
19
+ resolvedAt: row.resolved_at ?? undefined,
20
+ resolutionReason: row.resolution_reason ?? undefined,
21
+ };
22
+ }
23
+ const ACTION_ITEM_COLUMNS = `
24
+ id, scope_id, entity_id, title, detail, status, due_at, snooze_until, source,
25
+ created_at, updated_at, resolved_at, resolution_reason, tier, tier_pinned_at,
26
+ tier_reason, last_recalled_at
27
+ `;
28
+ export function recordActionItem(input) {
29
+ const result = getDb().prepare(`
30
+ INSERT INTO mem_action_items (
31
+ scope_id, entity_id, title, detail, due_at, source, tier, created_at, updated_at
32
+ )
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
34
+ `).run(input.scope_id, input.entity_id ?? null, input.title, input.detail ?? null, input.due_at ?? null, input.source ?? null, input.tier ?? "warm");
35
+ return getActionItem(Number(result.lastInsertRowid));
36
+ }
37
+ export function getActionItem(id) {
38
+ const row = getDb().prepare(`
39
+ SELECT ${ACTION_ITEM_COLUMNS}
40
+ FROM mem_action_items
41
+ WHERE id = ?
42
+ `).get(id);
43
+ return row ? toActionItem(row) : undefined;
44
+ }
45
+ export function listActionItems(input = {}) {
46
+ const rows = getDb().prepare(`
47
+ SELECT ${ACTION_ITEM_COLUMNS}
48
+ FROM mem_action_items
49
+ WHERE (? IS NULL OR scope_id = ?)
50
+ AND (
51
+ ? IS NOT NULL
52
+ OR status = 'open'
53
+ OR (status = 'snoozed' AND snooze_until IS NOT NULL AND datetime(snooze_until) <= datetime('now'))
54
+ )
55
+ AND (? IS NULL OR status = ?)
56
+ AND (? IS NULL OR due_at IS NOT NULL AND datetime(due_at) <= datetime(?))
57
+ AND (? = 1 OR tier != 'cold')
58
+ ORDER BY
59
+ CASE WHEN due_at IS NULL THEN 1 ELSE 0 END ASC,
60
+ datetime(due_at) ASC,
61
+ datetime(created_at) DESC,
62
+ id DESC
63
+ LIMIT ? OFFSET ?
64
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.status ?? null, input.status ?? null, input.status ?? null, input.due_before ?? null, input.due_before ?? null, input.includeArchived ? 1 : 0, input.limit ?? 50, input.offset ?? 0);
65
+ return rows.map(toActionItem);
66
+ }
67
+ function resolveActionItem(id, status, reason) {
68
+ const result = getDb().prepare(`
69
+ UPDATE mem_action_items
70
+ SET status = ?,
71
+ resolved_at = CURRENT_TIMESTAMP,
72
+ resolution_reason = ?,
73
+ updated_at = CURRENT_TIMESTAMP
74
+ WHERE id = ?
75
+ `).run(status, reason ?? null, id);
76
+ if (result.changes === 0) {
77
+ throw new Error(`Unknown action item id '${id}'.`);
78
+ }
79
+ return getActionItem(id);
80
+ }
81
+ export function completeActionItem(id, resolutionReason) {
82
+ return resolveActionItem(id, "done", resolutionReason);
83
+ }
84
+ export function dropActionItem(id, reason) {
85
+ return resolveActionItem(id, "dropped", reason);
86
+ }
87
+ export function snoozeActionItem(id, snoozeUntil) {
88
+ const result = getDb().prepare(`
89
+ UPDATE mem_action_items
90
+ SET status = 'snoozed',
91
+ snooze_until = ?,
92
+ updated_at = CURRENT_TIMESTAMP
93
+ WHERE id = ?
94
+ `).run(snoozeUntil, id);
95
+ if (result.changes === 0) {
96
+ throw new Error(`Unknown action item id '${id}'.`);
97
+ }
98
+ return getActionItem(id);
99
+ }
100
+ //# sourceMappingURL=action-items.js.map
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-action-items-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
26
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
27
+ dbModule.closeDb();
28
+ resetSandbox();
29
+ });
30
+ test.after(async () => {
31
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
32
+ dbModule.closeDb();
33
+ rmSync(sandboxRoot, { recursive: true, force: true });
34
+ });
35
+ test("action items round-trip through add, list, and complete with done hidden by default", async () => {
36
+ const { dbModule, memoryModule } = await loadModules();
37
+ dbModule.getDb();
38
+ const getScope = getFunction(memoryModule, "getScope");
39
+ const recordActionItem = getFunction(memoryModule, "recordActionItem");
40
+ const listActionItems = getFunction(memoryModule, "listActionItems");
41
+ const completeActionItem = getFunction(memoryModule, "completeActionItem");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse);
44
+ const added = recordActionItem({
45
+ scope_id: chapterhouse.id,
46
+ title: "Migrate feature ideas into memory",
47
+ detail: "Move feature-ideas.md into mem_action_items once the schema exists.",
48
+ due_at: "2026-05-15T12:00:00.000Z",
49
+ source: "test",
50
+ });
51
+ assert.equal(added.status, "open");
52
+ assert.equal(added.title, "Migrate feature ideas into memory");
53
+ assert.equal(added.detail, "Move feature-ideas.md into mem_action_items once the schema exists.");
54
+ assert.equal(added.dueAt, "2026-05-15T12:00:00.000Z");
55
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.id), [added.id]);
56
+ const completed = completeActionItem(added.id, "Migrated successfully.");
57
+ assert.equal(completed.status, "done");
58
+ assert.ok(completed.resolvedAt);
59
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.id), []);
60
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id, status: "done" }).map((item) => item.id), [added.id]);
61
+ });
62
+ test("snoozed action items are hidden by default until snooze_until passes", async () => {
63
+ const { dbModule, memoryModule } = await loadModules();
64
+ dbModule.getDb();
65
+ const getScope = getFunction(memoryModule, "getScope");
66
+ const recordActionItem = getFunction(memoryModule, "recordActionItem");
67
+ const snoozeActionItem = getFunction(memoryModule, "snoozeActionItem");
68
+ const listActionItems = getFunction(memoryModule, "listActionItems");
69
+ const chapterhouse = getScope("chapterhouse");
70
+ assert.ok(chapterhouse);
71
+ const future = recordActionItem({ scope_id: chapterhouse.id, title: "Remind infra later", source: "test" });
72
+ const expired = recordActionItem({ scope_id: chapterhouse.id, title: "Reappears now", source: "test" });
73
+ assert.equal(snoozeActionItem(future.id, "2999-01-01T00:00:00.000Z").status, "snoozed");
74
+ assert.equal(snoozeActionItem(expired.id, "2000-01-01T00:00:00.000Z").status, "snoozed");
75
+ const defaults = listActionItems({ scope_id: chapterhouse.id });
76
+ assert.equal(defaults.some((item) => item.id === future.id), false);
77
+ assert.equal(defaults.some((item) => item.id === expired.id), true);
78
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id, status: "snoozed" }).map((item) => item.id).sort(), [
79
+ expired.id,
80
+ future.id,
81
+ ].sort());
82
+ });
83
+ //# sourceMappingURL=action-items.test.js.map
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
5
5
  import { upsertEntity } from "./entities.js";
6
6
  import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
7
7
  import { recordObservation } from "./observations.js";
8
+ import { recordActionItem } from "./action-items.js";
8
9
  import { getScope } from "./scopes.js";
9
10
  const log = childLogger("memory.eot");
10
11
  function isEndOfTaskHookEnabled() {
@@ -30,6 +31,8 @@ function buildReviewerSystemPrompt() {
30
31
  "Optionally extract additional implicit durable memories from the task summary.",
31
32
  "Return JSON only with keys: decisions, implicit_memories.",
32
33
  "Each decision must include proposal_id, decision, reason.",
34
+ "Supported kinds are observation, decision, entity, and action_item.",
35
+ "Entity payloads must include name and entity_kind.",
33
36
  "Each implicit memory must include kind, scope_slug, payload, and may include confidence/reason.",
34
37
  ].join("\n");
35
38
  }
@@ -48,7 +51,10 @@ function parseEnvelope(raw) {
48
51
  if (!parsed || typeof parsed !== "object") {
49
52
  throw new Error("Invalid memory proposal payload.");
50
53
  }
51
- if (parsed.kind !== "observation" && parsed.kind !== "decision" && parsed.kind !== "entity") {
54
+ if (parsed.kind !== "observation"
55
+ && parsed.kind !== "decision"
56
+ && parsed.kind !== "entity"
57
+ && parsed.kind !== "action_item") {
52
58
  throw new Error("Invalid proposal kind.");
53
59
  }
54
60
  if (!parsed.payload || typeof parsed.payload !== "object") {
@@ -93,7 +99,10 @@ function parseReviewerResponse(raw) {
93
99
  return [];
94
100
  }
95
101
  const candidate = entry;
96
- if (candidate.kind !== "observation" && candidate.kind !== "decision" && candidate.kind !== "entity") {
102
+ if (candidate.kind !== "observation"
103
+ && candidate.kind !== "decision"
104
+ && candidate.kind !== "entity"
105
+ && candidate.kind !== "action_item") {
97
106
  return [];
98
107
  }
99
108
  if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
@@ -136,10 +145,26 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
136
145
  });
137
146
  return;
138
147
  }
148
+ if (kind === "action_item") {
149
+ const actionItem = payload;
150
+ recordActionItem({
151
+ scope_id: scope.id,
152
+ entity_id: actionItem.entity_id,
153
+ title: actionItem.title,
154
+ detail: actionItem.detail,
155
+ due_at: actionItem.due_at,
156
+ source: actionItem.source ?? source,
157
+ });
158
+ return;
159
+ }
139
160
  const entity = payload;
161
+ const entityKind = entity.entity_kind ?? entity.kind;
162
+ if (!entityKind) {
163
+ throw new Error("Entity proposal payload requires entity_kind.");
164
+ }
140
165
  upsertEntity({
141
166
  scope_id: scope.id,
142
- kind: entity.kind,
167
+ kind: entityKind,
143
168
  name: entity.name,
144
169
  summary: entity.summary,
145
170
  confidence,