chapterhouse 0.11.2 → 0.11.4

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.
@@ -268,10 +268,6 @@ export function createAgentsRouter(options) {
268
268
  res.setHeader("Connection", "keep-alive");
269
269
  res.setHeader("X-Accel-Buffering", "no");
270
270
  res.flushHeaders();
271
- const rawLastId = req.headers["last-event-id"];
272
- const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
273
- ? parseInt(rawLastId.trim(), 10)
274
- : undefined;
275
271
  const sendEvent = (event) => {
276
272
  let payload;
277
273
  if (event.kind === "output_delta") {
@@ -303,41 +299,48 @@ export function createAgentsRouter(options) {
303
299
  }
304
300
  res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
305
301
  };
306
- let replayHighSeq = lastSeq;
307
- if (lastSeq !== undefined) {
308
- const bufferedEvents = getTaskLogEvents(taskId);
309
- const oldestBufferedSeq = bufferedEvents[0]?.seq;
310
- const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
311
- if (bufferMissesRange) {
312
- const dbEvents = getTaskEvents(taskId, lastSeq);
313
- for (const event of dbEvents) {
314
- sendEvent(event);
315
- if (replayHighSeq === undefined || event.seq > replayHighSeq) {
316
- replayHighSeq = event.seq;
317
- }
318
- }
319
- }
320
- }
321
- const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
322
- const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
323
- for (const event of backlog) {
324
- sendEvent(event);
325
- }
326
302
  const isTerminal = () => {
327
303
  const row = getDb()
328
304
  .prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
329
305
  .get(taskId);
330
306
  return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
331
307
  };
332
- res.write(`: connected task=${taskId}\n\n`);
333
- if (isTerminal()) {
334
- res.end();
335
- return;
336
- }
337
- const heartbeat = setInterval(() => {
338
- res.write(`: keep-alive\n\n`);
339
- }, 15_000);
340
308
  setupSseCleanup((registerCleanup, cleanupNow) => {
309
+ req.on("close", cleanupNow);
310
+ res.on("close", cleanupNow);
311
+ res.on("error", cleanupNow);
312
+ const rawLastId = req.headers["last-event-id"];
313
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
314
+ ? parseInt(rawLastId.trim(), 10)
315
+ : undefined;
316
+ let replayHighSeq = lastSeq;
317
+ if (lastSeq !== undefined) {
318
+ const bufferedEvents = getTaskLogEvents(taskId);
319
+ const oldestBufferedSeq = bufferedEvents[0]?.seq;
320
+ const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
321
+ if (bufferMissesRange) {
322
+ const dbEvents = getTaskEvents(taskId, lastSeq);
323
+ for (const event of dbEvents) {
324
+ sendEvent(event);
325
+ if (replayHighSeq === undefined || event.seq > replayHighSeq) {
326
+ replayHighSeq = event.seq;
327
+ }
328
+ }
329
+ }
330
+ }
331
+ const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
332
+ const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
333
+ for (const event of backlog) {
334
+ sendEvent(event);
335
+ }
336
+ res.write(`: connected task=${taskId}\n\n`);
337
+ if (isTerminal()) {
338
+ res.end();
339
+ return;
340
+ }
341
+ const heartbeat = setInterval(() => {
342
+ res.write(`: keep-alive\n\n`);
343
+ }, 15_000);
341
344
  registerCleanup(() => clearInterval(heartbeat));
342
345
  registerCleanup(subscribeTaskLog(taskId, (event) => {
343
346
  sendEvent(event);
@@ -359,7 +362,6 @@ export function createAgentsRouter(options) {
359
362
  res.end();
360
363
  }
361
364
  }));
362
- req.on("close", cleanupNow);
363
365
  });
364
366
  });
365
367
  // ---------------------------------------------------------------------------
@@ -370,19 +372,21 @@ export function createAgentsRouter(options) {
370
372
  // Chat-specific events (delta, message, queued) are NOT emitted here.
371
373
  // ---------------------------------------------------------------------------
372
374
  router.get("/api/agents/stream", (req, res) => {
373
- res.writeHead(200, {
374
- "Content-Type": "text/event-stream",
375
- "Cache-Control": "no-cache",
376
- Connection: "keep-alive",
377
- });
378
- res.write(formatSseData({ type: "connected" }));
379
- const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
380
375
  setupSseCleanup((registerCleanup, cleanupNow) => {
376
+ req.on("close", cleanupNow);
377
+ res.on("close", cleanupNow);
378
+ res.on("error", cleanupNow);
379
+ res.writeHead(200, {
380
+ "Content-Type": "text/event-stream",
381
+ "Cache-Control": "no-cache",
382
+ Connection: "keep-alive",
383
+ });
384
+ res.write(formatSseData({ type: "connected" }));
385
+ const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
381
386
  registerCleanup(() => clearInterval(heartbeat));
382
387
  registerCleanup(agentEventBus.subscribeAll((event) => {
383
388
  res.write(formatSseData({ type: "agent_event", agentEvent: event }));
384
389
  }));
385
- req.on("close", cleanupNow);
386
390
  });
387
391
  });
388
392
  router.post("/api/agents/:slug/reload-confirm", async (req, res) => {
@@ -1,6 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import { getActiveScope, setActiveScope } from "../../memory/active-scope.js";
4
+ import { recordActionItem } from "../../memory/action-items.js";
4
5
  import { recordDecision } from "../../memory/decisions.js";
5
6
  import { upsertEntity } from "../../memory/entities.js";
6
7
  import { handleGitCommitHook, handlePrMergeHook } from "../../memory/hooks.js";
@@ -24,7 +25,8 @@ const setActiveScopeSchema = z.object({
24
25
  scope: z.string().nullable(),
25
26
  });
26
27
  const memoryEntriesQuerySchema = z.object({
27
- store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
28
+ store: z.enum(["observations", "decisions", "entities", "action_items", "patterns"]).optional(),
29
+ kind: z.enum(["observation", "decision", "entity", "action_item", "pattern"]).optional(),
28
30
  tier: z.enum(["hot", "warm", "cold"]).optional(),
29
31
  cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
30
32
  limit: z.coerce.number().int().min(1).max(100).optional(),
@@ -43,6 +45,9 @@ const inboxRouteSchema = z.object({
43
45
  reason: z.string().optional(),
44
46
  target_scope: z.string().optional(),
45
47
  });
48
+ const inboxRejectSchema = z.object({
49
+ reason: requiredString("Missing 'reason' in request body"),
50
+ });
46
51
  const gitCommitHookSchema = z.object({
47
52
  message: requiredString("Missing 'message' in request body"),
48
53
  stat: z.string().optional(),
@@ -53,6 +58,130 @@ const prMergeHookSchema = z.object({
53
58
  body: z.string().optional(),
54
59
  files_changed: z.array(z.string()).optional(),
55
60
  });
61
+ function sendError(res, status, message) {
62
+ res.status(status).type("application/json").send(JSON.stringify({ error: message }));
63
+ }
64
+ function scopeSlugForInboxItem(item) {
65
+ const envelope = JSON.parse(item.payload);
66
+ if (typeof envelope.scope_slug === "string" && envelope.scope_slug.trim()) {
67
+ return envelope.scope_slug;
68
+ }
69
+ if (item.scopeId) {
70
+ const scope = getScope(item.scopeId);
71
+ if (scope) {
72
+ return scope.slug;
73
+ }
74
+ }
75
+ const activeScope = getActiveScope();
76
+ if (activeScope) {
77
+ return activeScope.slug;
78
+ }
79
+ const fallback = getScope("chapterhouse");
80
+ if (fallback) {
81
+ return fallback.slug;
82
+ }
83
+ throw new Error("No memory scope could be resolved for this proposal.");
84
+ }
85
+ function rememberAcceptedProposal(kind, scopeSlug, payload, source, confidence, sourceAgent) {
86
+ const scope = getScope(scopeSlug);
87
+ if (!scope) {
88
+ throw new Error(`Unknown memory scope '${scopeSlug}'.`);
89
+ }
90
+ const payloadRecord = payload;
91
+ if (kind === "observation") {
92
+ const content = typeof payloadRecord.content === "string" ? payloadRecord.content.trim() : "";
93
+ if (!content) {
94
+ throw new Error("Observation proposal payload requires content.");
95
+ }
96
+ recordObservation({
97
+ scope_id: scope.id,
98
+ entity_id: typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined,
99
+ content,
100
+ source: typeof payloadRecord.source === "string" ? payloadRecord.source : source,
101
+ confidence,
102
+ });
103
+ return;
104
+ }
105
+ if (kind === "decision") {
106
+ const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
107
+ if (!title) {
108
+ throw new Error("Decision proposal payload requires title.");
109
+ }
110
+ recordDecision({
111
+ scope_id: scope.id,
112
+ title,
113
+ rationale: typeof payloadRecord.rationale === "string" ? payloadRecord.rationale : title,
114
+ decided_at: typeof payloadRecord.decided_at === "string" ? payloadRecord.decided_at : undefined,
115
+ });
116
+ return;
117
+ }
118
+ if (kind === "action_item") {
119
+ const title = typeof payloadRecord.title === "string" ? payloadRecord.title.trim() : "";
120
+ if (!title) {
121
+ throw new Error("Action item proposal payload requires title.");
122
+ }
123
+ const entity = typeof payloadRecord.entity_name === "string" && typeof payloadRecord.entity_kind === "string"
124
+ ? upsertEntity({
125
+ scope_id: scope.id,
126
+ kind: payloadRecord.entity_kind,
127
+ name: payloadRecord.entity_name,
128
+ confidence,
129
+ })
130
+ : undefined;
131
+ recordActionItem({
132
+ scope_id: scope.id,
133
+ entity_id: entity?.id ?? (typeof payloadRecord.entity_id === "number" ? payloadRecord.entity_id : undefined),
134
+ title,
135
+ detail: typeof payloadRecord.detail === "string" ? payloadRecord.detail : undefined,
136
+ due_at: typeof payloadRecord.due_at === "string" ? payloadRecord.due_at : undefined,
137
+ source: sourceAgent ? `subagent_proposal:${sourceAgent}` : source,
138
+ });
139
+ return;
140
+ }
141
+ const entityKind = typeof payloadRecord.entity_kind === "string" ? payloadRecord.entity_kind :
142
+ typeof payloadRecord.kind === "string" ? payloadRecord.kind :
143
+ "";
144
+ const name = typeof payloadRecord.name === "string" ? payloadRecord.name.trim() : "";
145
+ if (!entityKind || !name) {
146
+ throw new Error("Entity proposal payload requires entity_kind and name.");
147
+ }
148
+ upsertEntity({
149
+ scope_id: scope.id,
150
+ kind: entityKind,
151
+ name,
152
+ summary: typeof payloadRecord.summary === "string" ? payloadRecord.summary : undefined,
153
+ confidence,
154
+ });
155
+ }
156
+ function acceptInboxItem(id) {
157
+ const item = getInboxItem(id);
158
+ if (!item) {
159
+ throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
160
+ }
161
+ if (item.status !== "pending") {
162
+ throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
163
+ }
164
+ const envelope = JSON.parse(item.payload);
165
+ rememberAcceptedProposal(envelope.kind, scopeSlugForInboxItem(item), envelope.payload, `agent:${item.sourceAgent}`, envelope.confidence, item.sourceAgent);
166
+ resolveInboxItem(id, "accepted", "Accepted via web UI");
167
+ }
168
+ function rejectInboxItem(id, reason) {
169
+ const item = getInboxItem(id);
170
+ if (!item) {
171
+ throw Object.assign(new Error(`Inbox item '${id}' not found`), { statusCode: 404 });
172
+ }
173
+ if (item.status !== "pending") {
174
+ throw Object.assign(new Error(`Inbox item '${id}' is already resolved`), { statusCode: 409 });
175
+ }
176
+ resolveInboxItem(id, "rejected", reason);
177
+ }
178
+ function parseInboxId(raw) {
179
+ const id = Number(Array.isArray(raw) ? raw[0] : raw);
180
+ if (!Number.isInteger(id) || id <= 0) {
181
+ throw Object.assign(new Error("Invalid inbox item id"), { statusCode: 400 });
182
+ }
183
+ return id;
184
+ }
56
185
  export function createMemoryRouter(options) {
57
186
  const { authMiddleware } = options;
58
187
  const router = Router();
@@ -74,7 +203,7 @@ export function createMemoryRouter(options) {
74
203
  sendJson(res, SetActiveScopeResponseSchema, { ok: true, scope: scope?.slug ?? null });
75
204
  }
76
205
  catch (err) {
77
- res.status(404).json({ error: err instanceof Error ? err.message : String(err) });
206
+ sendError(res, 404, err instanceof Error ? err.message : String(err));
78
207
  }
79
208
  });
80
209
  router.get("/api/memory/scopes", (_req, res) => {
@@ -87,6 +216,7 @@ export function createMemoryRouter(options) {
87
216
  decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
88
217
  entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
89
218
  action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
219
+ patterns: db.prepare(`SELECT COUNT(*) AS count FROM mem_patterns WHERE scope_id = ?`).get(scope.id).count,
90
220
  };
91
221
  return {
92
222
  slug: scope.slug,
@@ -113,36 +243,69 @@ export function createMemoryRouter(options) {
113
243
  sendJson(res, MemoryInboxSchema, { items: result, total: result.length });
114
244
  });
115
245
  router.post("/api/memory/inbox/:id/route", (req, res) => {
116
- const id = Number(req.params.id);
117
- if (!Number.isInteger(id) || id <= 0) {
118
- res.status(400).json({ error: "Invalid inbox item id" });
119
- return;
120
- }
246
+ const id = parseInboxId(req.params.id);
121
247
  const body = parseRequest(inboxRouteSchema, req.body ?? {});
122
248
  const item = getInboxItem(id);
123
249
  if (!item) {
124
- res.status(404).json({ error: `Inbox item '${id}' not found` });
250
+ sendError(res, 404, `Inbox item '${id}' not found`);
125
251
  return;
126
252
  }
127
253
  if (item.status !== "pending") {
128
- res.status(409).json({ error: `Inbox item '${id}' is already resolved` });
254
+ sendError(res, 409, `Inbox item '${id}' is already resolved`);
129
255
  return;
130
256
  }
131
- const status = body.action === "accept" ? "accepted" : "rejected";
132
- const reason = body.reason ?? (body.action === "accept" ? "Accepted via web UI" : "Rejected via web UI");
133
- resolveInboxItem(id, status, reason);
257
+ if (body.action === "accept") {
258
+ acceptInboxItem(id);
259
+ }
260
+ else {
261
+ rejectInboxItem(id, body.reason ?? "Rejected via web UI");
262
+ }
134
263
  log.info({ id, action: body.action }, "inbox item routed via web UI");
135
264
  sendJson(res, InboxRouteResponseSchema, { ok: true });
136
265
  });
266
+ router.post("/api/memory/inbox/:id/accept", (req, res) => {
267
+ try {
268
+ const id = parseInboxId(req.params.id);
269
+ acceptInboxItem(id);
270
+ log.info({ id }, "inbox item accepted via web UI");
271
+ sendJson(res, InboxRouteResponseSchema, { ok: true });
272
+ }
273
+ catch (err) {
274
+ const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
275
+ ? err.statusCode
276
+ : 400;
277
+ sendError(res, statusCode, err instanceof Error ? err.message : String(err));
278
+ }
279
+ });
280
+ router.post("/api/memory/inbox/:id/reject", (req, res) => {
281
+ const body = parseRequest(inboxRejectSchema, req.body ?? {});
282
+ try {
283
+ const id = parseInboxId(req.params.id);
284
+ rejectInboxItem(id, body.reason);
285
+ log.info({ id }, "inbox item rejected via web UI");
286
+ sendJson(res, InboxRouteResponseSchema, { ok: true });
287
+ }
288
+ catch (err) {
289
+ const statusCode = typeof err === "object" && err !== null && "statusCode" in err && typeof err.statusCode === "number"
290
+ ? err.statusCode
291
+ : 400;
292
+ sendError(res, statusCode, err instanceof Error ? err.message : String(err));
293
+ }
294
+ });
137
295
  router.get("/api/memory/:scope", (req, res) => {
138
296
  const scopeSlug = String(req.params.scope);
139
297
  const scope = getScope(scopeSlug);
140
298
  if (!scope) {
141
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
299
+ sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
142
300
  return;
143
301
  }
144
302
  const query = parseRequest(memoryEntriesQuerySchema, req.query);
145
- const store = query.store ?? "observations";
303
+ const store = query.store ?? (query.kind === "observation" ? "observations" :
304
+ query.kind === "decision" ? "decisions" :
305
+ query.kind === "entity" ? "entities" :
306
+ query.kind === "action_item" ? "action_items" :
307
+ query.kind === "pattern" ? "patterns" :
308
+ "observations");
146
309
  const tier = query.tier;
147
310
  const cursor = query.cursor ? Number(query.cursor) : undefined;
148
311
  const limit = query.limit ?? 100;
@@ -189,7 +352,7 @@ export function createMemoryRouter(options) {
189
352
  total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
190
353
  }
191
354
  }
192
- else {
355
+ else if (store === "action_items") {
193
356
  const cursorClause = cursor ? "AND id < ?" : "";
194
357
  const params = tier ? [scope.id, tier] : [scope.id];
195
358
  const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
@@ -202,6 +365,19 @@ export function createMemoryRouter(options) {
202
365
  total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
203
366
  }
204
367
  }
368
+ else {
369
+ const cursorClause = cursor ? "AND id < ?" : "";
370
+ const params = tier ? [scope.id, tier] : [scope.id];
371
+ const pageParams = cursor ? [...params, cursor, limit + 1] : [...params, limit + 1];
372
+ if (tier) {
373
+ entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? AND tier = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
374
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
375
+ }
376
+ else {
377
+ entries = db.prepare(`SELECT id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated FROM mem_patterns WHERE scope_id = ? ${cursorClause} ORDER BY id DESC LIMIT ?`).all(...pageParams);
378
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_patterns WHERE scope_id = ?`).get(scope.id).n;
379
+ }
380
+ }
205
381
  if (entries.length > limit) {
206
382
  const extra = entries.pop();
207
383
  const last = entries[entries.length - 1];
@@ -213,12 +389,12 @@ export function createMemoryRouter(options) {
213
389
  const scopeSlug = String(req.params.scope);
214
390
  const scope = getScope(scopeSlug);
215
391
  if (!scope) {
216
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
392
+ sendError(res, 404, `Memory scope '${scopeSlug}' not found`);
217
393
  return;
218
394
  }
219
395
  const body = parseRequest(memoryRememberSchema, req.body ?? {});
220
396
  if (body.entity_name && !body.entity_kind) {
221
- res.status(400).json({ error: "entity_kind is required when entity_name is provided" });
397
+ sendError(res, 400, "entity_kind is required when entity_name is provided");
222
398
  return;
223
399
  }
224
400
  const kind = body.kind ?? "observation";
@@ -232,7 +408,7 @@ export function createMemoryRouter(options) {
232
408
  : undefined;
233
409
  if (kind === "decision") {
234
410
  if (!body.title) {
235
- res.status(400).json({ error: "title is required when kind='decision'" });
411
+ sendError(res, 400, "title is required when kind='decision'");
236
412
  return;
237
413
  }
238
414
  const decision = recordDecision({
@@ -277,7 +453,7 @@ export function createMemoryRouter(options) {
277
453
  router.post("/api/scopes", (req, res) => {
278
454
  const body = parseRequest(scopeCreateSchema, req.body ?? {});
279
455
  if (getScope(body.slug)) {
280
- res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
456
+ sendError(res, 409, `Memory scope '${body.slug}' already exists`);
281
457
  return;
282
458
  }
283
459
  const scope = createScope({
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import express from "express";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ const repoRoot = process.cwd();
7
+ const sandboxRoot = join(repoRoot, ".test-work", `api-memory-${process.pid}`);
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ async function withMemoryApi(run) {
10
+ const { createMemoryRouter } = await import(new URL(`./memory.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
11
+ const authMiddleware = (_req, _res, next) => next();
12
+ const app = express();
13
+ app.use(express.json());
14
+ app.use(createMemoryRouter({ authMiddleware }));
15
+ const server = app.listen(0, "127.0.0.1");
16
+ await new Promise((resolve) => server.once("listening", resolve));
17
+ const address = server.address();
18
+ assert.ok(address && typeof address === "object");
19
+ try {
20
+ await run(`http://127.0.0.1:${address.port}`);
21
+ }
22
+ finally {
23
+ await new Promise((resolve, reject) => {
24
+ server.close((error) => (error ? reject(error) : resolve()));
25
+ });
26
+ }
27
+ }
28
+ async function loadDbModule() {
29
+ return await import(new URL(`../../store/db.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
30
+ }
31
+ test.beforeEach(async () => {
32
+ const dbModule = await loadDbModule();
33
+ dbModule.closeDb();
34
+ rmSync(sandboxRoot, { recursive: true, force: true });
35
+ mkdirSync(sandboxRoot, { recursive: true });
36
+ });
37
+ test.after(async () => {
38
+ const dbModule = await loadDbModule();
39
+ dbModule.closeDb();
40
+ rmSync(sandboxRoot, { recursive: true, force: true });
41
+ });
42
+ test("memory API lists pattern entries and accepts PRD kind filters", async () => {
43
+ const { getDb } = await loadDbModule();
44
+ const db = getDb();
45
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
46
+ db.prepare(`
47
+ INSERT INTO mem_entities (scope_id, kind, name, summary, tier, created_at, updated_at)
48
+ VALUES (?, 'project', 'Memory API Test Entity', 'Team AI assistant', 'hot', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
49
+ `).run(scope.id);
50
+ db.prepare(`
51
+ INSERT INTO mem_patterns (scope_id, title, summary, source_observation_ids, confidence, tier, created_at, last_updated)
52
+ VALUES (?, 'Agents prefer scoped memory', 'Repeated observations show agents should stay scoped.', '[1,2,3]', 0.82, 'warm', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
53
+ `).run(scope.id);
54
+ await withMemoryApi(async (baseUrl) => {
55
+ const entityResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=entity&tier=hot`);
56
+ assert.equal(entityResponse.status, 200);
57
+ const entityPayload = await entityResponse.json();
58
+ assert.equal(entityPayload.total, 1);
59
+ assert.equal(entityPayload.entries[0]?.name, "Memory API Test Entity");
60
+ const patternResponse = await fetch(`${baseUrl}/api/memory/chapterhouse?kind=pattern`);
61
+ assert.equal(patternResponse.status, 200);
62
+ const patternPayload = await patternResponse.json();
63
+ assert.equal(patternPayload.total, 1);
64
+ assert.equal(patternPayload.entries[0]?.title, "Agents prefer scoped memory");
65
+ assert.equal(patternPayload.entries[0]?.summary, "Repeated observations show agents should stay scoped.");
66
+ });
67
+ });
68
+ test("memory API accepts and rejects inbox proposals through explicit endpoints", async () => {
69
+ const { getDb } = await loadDbModule();
70
+ const db = getDb();
71
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
72
+ const first = db.prepare(`
73
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
74
+ VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"keep"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
75
+ `).run(scope.id).lastInsertRowid;
76
+ const second = db.prepare(`
77
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, status, created_at)
78
+ VALUES (?, 'memory_proposal', '{"kind":"observation","payload":{"content":"drop"}}', 'coder', 'pending', CURRENT_TIMESTAMP)
79
+ `).run(scope.id).lastInsertRowid;
80
+ await withMemoryApi(async (baseUrl) => {
81
+ const acceptResponse = await fetch(`${baseUrl}/api/memory/inbox/${first}/accept`, { method: "POST" });
82
+ assert.equal(acceptResponse.status, 200);
83
+ assert.deepEqual(await acceptResponse.json(), { ok: true });
84
+ const rejectResponse = await fetch(`${baseUrl}/api/memory/inbox/${second}/reject`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ reason: "Not durable enough" }),
88
+ });
89
+ assert.equal(rejectResponse.status, 200);
90
+ assert.deepEqual(await rejectResponse.json(), { ok: true });
91
+ });
92
+ const rows = db.prepare(`
93
+ SELECT id, status, resolution_reason
94
+ FROM mem_inbox
95
+ ORDER BY id
96
+ `).all();
97
+ const acceptedObservation = db.prepare(`
98
+ SELECT content, source
99
+ FROM mem_observations
100
+ WHERE content = 'keep'
101
+ `).get();
102
+ assert.deepEqual(rows, [
103
+ { id: Number(first), status: "accepted", resolution_reason: "Accepted via web UI" },
104
+ { id: Number(second), status: "rejected", resolution_reason: "Not durable enough" },
105
+ ]);
106
+ assert.deepEqual(acceptedObservation, { content: "keep", source: "agent:coder" });
107
+ });
108
+ //# sourceMappingURL=memory.test.js.map