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.
- package/dist/api/routes/agents.js +45 -41
- package/dist/api/routes/memory.js +195 -19
- package/dist/api/routes/memory.test.js +108 -0
- package/dist/api/routes/sessions.js +56 -56
- package/dist/api/sse-hub.js +46 -7
- package/dist/api/sse-hub.test.js +75 -0
- package/dist/shared/api-schemas.js +1 -0
- package/dist/status.js +14 -6
- package/dist/status.test.js +18 -0
- package/package.json +1 -1
- package/web/dist/assets/{WikiEdit-CqVRnrcr.js → WikiEdit-BZYsJt-4.js} +2 -2
- package/web/dist/assets/{WikiEdit-CqVRnrcr.js.map → WikiEdit-BZYsJt-4.js.map} +1 -1
- package/web/dist/assets/{WikiGraph-C7LtaHSL.js → WikiGraph-B7ZZWSa3.js} +2 -2
- package/web/dist/assets/{WikiGraph-C7LtaHSL.js.map → WikiGraph-B7ZZWSa3.js.map} +1 -1
- package/web/dist/assets/{index-BK1ZbptD.js → index-gV0gH0Oi.js} +50 -50
- package/web/dist/assets/index-gV0gH0Oi.js.map +1 -0
- package/web/dist/assets/{index-Benag_dz.css → index-v0vtknrU.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BK1ZbptD.js.map +0 -1
|
@@ -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
|
|
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 =
|
|
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
|
|
250
|
+
sendError(res, 404, `Inbox item '${id}' not found`);
|
|
125
251
|
return;
|
|
126
252
|
}
|
|
127
253
|
if (item.status !== "pending") {
|
|
128
|
-
res
|
|
254
|
+
sendError(res, 409, `Inbox item '${id}' is already resolved`);
|
|
129
255
|
return;
|
|
130
256
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|