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.
- package/dist/api/server.js +14 -8
- package/dist/api/server.test.js +30 -0
- package/dist/copilot/agents.js +3 -2
- package/dist/copilot/orchestrator.js +48 -10
- package/dist/copilot/orchestrator.test.js +59 -14
- package/dist/copilot/tools.js +183 -7
- package/dist/copilot/tools.memory.test.js +125 -1
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +111 -3
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/memory/index.js +1 -0
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +430 -16
- package/dist/store/db.test.js +452 -10
- package/package.json +1 -1
- package/web/dist/assets/index-BTI_m0OE.css +10 -0
- package/web/dist/assets/{index-DmYLALt0.js → index-D4-uRAi6.js} +52 -52
- package/web/dist/assets/index-D4-uRAi6.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
|
@@ -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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
package/dist/memory/eot.js
CHANGED
|
@@ -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"
|
|
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"
|
|
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:
|
|
167
|
+
kind: entityKind,
|
|
143
168
|
name: entity.name,
|
|
144
169
|
summary: entity.summary,
|
|
145
170
|
confidence,
|