chapterhouse 0.4.1 → 0.4.2
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/orchestrator.js +4 -4
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/memory/eot.test.js +3 -3
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/store/db.js +336 -9
- package/dist/store/db.test.js +393 -7
- package/package.json +1 -1
- package/web/dist/assets/{index-DmYLALt0.js → index-B_cCSHan.js} +52 -52
- package/web/dist/assets/index-B_cCSHan.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -23,9 +23,9 @@ import { withWikiWrite } from "../wiki/lock.js";
|
|
|
23
23
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
24
24
|
import { restartDaemon } from "../daemon.js";
|
|
25
25
|
import { API_TOKEN_PATH } from "../paths.js";
|
|
26
|
-
import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
26
|
+
import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
27
27
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
28
|
-
import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
28
|
+
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
29
29
|
import { getStatus, onStatusChange } from "../status.js";
|
|
30
30
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
31
31
|
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
@@ -728,6 +728,7 @@ if (config.chatSseEnabled) {
|
|
|
728
728
|
const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
|
|
729
729
|
if (!sessionKey)
|
|
730
730
|
throw new BadRequestError("Missing session key");
|
|
731
|
+
const includeHistorical = req.query.include === "all";
|
|
731
732
|
res.setHeader("Content-Type", "text/event-stream");
|
|
732
733
|
res.setHeader("Cache-Control", "no-cache");
|
|
733
734
|
res.setHeader("Connection", "keep-alive");
|
|
@@ -738,6 +739,10 @@ if (config.chatSseEnabled) {
|
|
|
738
739
|
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
739
740
|
? parseInt(rawLastId.trim(), 10)
|
|
740
741
|
: undefined;
|
|
742
|
+
const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
|
|
743
|
+
const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
|
|
744
|
+
? 0
|
|
745
|
+
: lastSeq;
|
|
741
746
|
// Helper: send a named SSE event with an id: field
|
|
742
747
|
const sendEvent = (event, seq) => {
|
|
743
748
|
const payload = JSON.stringify(event);
|
|
@@ -745,13 +750,13 @@ if (config.chatSseEnabled) {
|
|
|
745
750
|
};
|
|
746
751
|
// If Last-Event-ID is present and the session ring buffer doesn't cover it,
|
|
747
752
|
// fall back to SQLite for replay of completed turns.
|
|
748
|
-
let replayHighSeq =
|
|
749
|
-
if (
|
|
753
|
+
let replayHighSeq = effectiveLastSeq;
|
|
754
|
+
if (effectiveLastSeq !== undefined) {
|
|
750
755
|
const oldestBuf = oldestSessionSeq(sessionKey);
|
|
751
|
-
const bufferMissesRange = oldestBuf === undefined || oldestBuf >
|
|
756
|
+
const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
|
|
752
757
|
if (bufferMissesRange) {
|
|
753
758
|
// Replay from SQLite (completed turns)
|
|
754
|
-
const dbEvents = getSessionEventsFromDb(sessionKey,
|
|
759
|
+
const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
|
|
755
760
|
for (const e of dbEvents) {
|
|
756
761
|
sendEvent(e, e._seq);
|
|
757
762
|
if (replayHighSeq === undefined || e._seq > replayHighSeq)
|
|
@@ -766,7 +771,7 @@ if (config.chatSseEnabled) {
|
|
|
766
771
|
sendEvent(e, e._seq);
|
|
767
772
|
}, replayHighSeq);
|
|
768
773
|
// Send connected event
|
|
769
|
-
res.write(`: connected session=${sessionKey}\n\n`);
|
|
774
|
+
res.write(`: connected session=${sessionKey} run=${getCurrentRunId()}\n\n`);
|
|
770
775
|
// Keep-alive every 15 s
|
|
771
776
|
const keepAlive = setInterval(() => {
|
|
772
777
|
res.write(`: keep-alive\n\n`);
|
|
@@ -1063,7 +1068,8 @@ app.get("/api/session/:sessionKey/messages", (req, res) => {
|
|
|
1063
1068
|
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
|
|
1064
1069
|
throw new BadRequestError("'limit' must be a positive integer");
|
|
1065
1070
|
}
|
|
1066
|
-
const
|
|
1071
|
+
const includeHistorical = req.query.include === "all";
|
|
1072
|
+
const messages = getSessionMessages(sessionKey, limit, { includeHistorical });
|
|
1067
1073
|
res.json({ sessionKey, messages });
|
|
1068
1074
|
});
|
|
1069
1075
|
app.use(apiNotFoundHandler);
|
package/dist/api/server.test.js
CHANGED
|
@@ -371,6 +371,36 @@ test("server worker detail returns the stored dispatched prompt", async () => {
|
|
|
371
371
|
assert.equal(body.completedAt, null);
|
|
372
372
|
});
|
|
373
373
|
});
|
|
374
|
+
test("server session message hydration returns current run by default and include=all returns history", async () => {
|
|
375
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
376
|
+
const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
|
|
377
|
+
try {
|
|
378
|
+
const currentRun = db.prepare(`SELECT run_id FROM daemon_runs LIMIT 1`).get();
|
|
379
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
380
|
+
VALUES ('user', ?, 'web', 'hydration-session', ?)`).run("previous run", "previous-run");
|
|
381
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
382
|
+
VALUES ('assistant', ?, 'web', 'hydration-session', ?)`).run("current run", currentRun.run_id);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
db.close();
|
|
386
|
+
}
|
|
387
|
+
const currentOnly = await fetch(`${baseUrl}/api/session/hydration-session/messages`, {
|
|
388
|
+
headers: { authorization: authHeader },
|
|
389
|
+
});
|
|
390
|
+
assert.equal(currentOnly.status, 200);
|
|
391
|
+
assert.deepEqual((await currentOnly.json()).messages.map((message) => message.content), [
|
|
392
|
+
"current run",
|
|
393
|
+
]);
|
|
394
|
+
const allRuns = await fetch(`${baseUrl}/api/session/hydration-session/messages?include=all`, {
|
|
395
|
+
headers: { authorization: authHeader },
|
|
396
|
+
});
|
|
397
|
+
assert.equal(allRuns.status, 200);
|
|
398
|
+
assert.deepEqual((await allRuns.json()).messages.map((message) => message.content), [
|
|
399
|
+
"previous run",
|
|
400
|
+
"current run",
|
|
401
|
+
]);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
374
404
|
test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
|
|
375
405
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
376
406
|
rmSync(join(testRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
|
|
@@ -1104,11 +1104,11 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1104
1104
|
}
|
|
1105
1105
|
catch { /* best-effort */ }
|
|
1106
1106
|
try {
|
|
1107
|
-
logConversation(logRole, prompt, sourceLabel, sessionKey);
|
|
1107
|
+
logConversation(logRole, prompt, sourceLabel, sessionKey, turnId);
|
|
1108
1108
|
}
|
|
1109
1109
|
catch { /* best-effort */ }
|
|
1110
1110
|
try {
|
|
1111
|
-
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
1111
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
|
|
1112
1112
|
}
|
|
1113
1113
|
catch { /* best-effort */ }
|
|
1114
1114
|
scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
|
|
@@ -1205,11 +1205,11 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
|
|
|
1205
1205
|
}
|
|
1206
1206
|
catch { /* best-effort */ }
|
|
1207
1207
|
try {
|
|
1208
|
-
logConversation("user", newPrompt, sourceLabel, sessionKey);
|
|
1208
|
+
logConversation("user", newPrompt, sourceLabel, sessionKey, turnId);
|
|
1209
1209
|
}
|
|
1210
1210
|
catch { /* best-effort */ }
|
|
1211
1211
|
try {
|
|
1212
|
-
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
1212
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
|
|
1213
1213
|
}
|
|
1214
1214
|
catch { /* best-effort */ }
|
|
1215
1215
|
scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
|
|
@@ -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/memory/eot.test.js
CHANGED
|
@@ -65,7 +65,7 @@ test("runEndOfTaskMemoryHook does nothing when CHAPTERHOUSE_MEMORY_EOT_HOOK_ENAB
|
|
|
65
65
|
},
|
|
66
66
|
});
|
|
67
67
|
assert.equal(llmCalls, 0);
|
|
68
|
-
assert.equal(listObservations({ scope_id: chapterhouse.id }).
|
|
68
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Disabled hooks must not persist memory."), false);
|
|
69
69
|
const row = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
70
70
|
assert.equal(row.status, "pending");
|
|
71
71
|
});
|
|
@@ -185,7 +185,7 @@ test("runEndOfTaskMemoryHook leaves reviewed proposals pending when CHAPTERHOUSE
|
|
|
185
185
|
implicit_memories: [],
|
|
186
186
|
}),
|
|
187
187
|
});
|
|
188
|
-
assert.equal(listObservations({ scope_id: chapterhouse.id }).
|
|
188
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Review can approve this, but auto-accept is disabled."), false);
|
|
189
189
|
const row = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
190
190
|
assert.equal(row.status, "pending");
|
|
191
191
|
});
|
|
@@ -214,7 +214,7 @@ test("runEndOfTaskMemoryHook rejects pending same-task proposals omitted by the
|
|
|
214
214
|
implicit_memories: [],
|
|
215
215
|
}),
|
|
216
216
|
});
|
|
217
|
-
assert.equal(listObservations({ scope_id: chapterhouse.id }).
|
|
217
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Reviewer omissions should not silently leave proposals pending."), false);
|
|
218
218
|
const row = db.prepare(`
|
|
219
219
|
SELECT status, resolution_reason
|
|
220
220
|
FROM mem_inbox
|
|
@@ -45,30 +45,30 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
|
|
|
45
45
|
const team = getScope("team");
|
|
46
46
|
assert.ok(chapterhouse && team);
|
|
47
47
|
const first = recordObservation({
|
|
48
|
-
scope_id:
|
|
48
|
+
scope_id: team.id,
|
|
49
49
|
content: "The worker event stream uses server sent events for live task output.",
|
|
50
50
|
source: "test",
|
|
51
51
|
confidence: 0.4,
|
|
52
52
|
});
|
|
53
53
|
const keeper = recordObservation({
|
|
54
|
-
scope_id:
|
|
54
|
+
scope_id: team.id,
|
|
55
55
|
content: "Worker event streams use server sent events for live task output.",
|
|
56
56
|
source: "test",
|
|
57
57
|
confidence: 0.9,
|
|
58
58
|
});
|
|
59
59
|
const third = recordObservation({
|
|
60
|
-
scope_id:
|
|
60
|
+
scope_id: team.id,
|
|
61
61
|
content: "The worker event stream uses server sent events for live task output today.",
|
|
62
62
|
source: "test",
|
|
63
63
|
confidence: 0.9,
|
|
64
64
|
});
|
|
65
65
|
const otherScope = recordObservation({
|
|
66
|
-
scope_id:
|
|
66
|
+
scope_id: chapterhouse.id,
|
|
67
67
|
content: "Worker event streams use server sent events for live task output.",
|
|
68
68
|
source: "test",
|
|
69
69
|
confidence: 0.1,
|
|
70
70
|
});
|
|
71
|
-
const summary = housekeepingModule.dedupObservationsPass(
|
|
71
|
+
const summary = housekeepingModule.dedupObservationsPass(team.id);
|
|
72
72
|
assert.equal(summary.pass, "dedupObservationsPass");
|
|
73
73
|
assert.equal(summary.examined, 3);
|
|
74
74
|
assert.equal(summary.modified, 2);
|
|
@@ -79,7 +79,7 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
|
|
|
79
79
|
{ id: third.id, superseded_by: keeper.id },
|
|
80
80
|
]);
|
|
81
81
|
assert.equal(db.prepare(`SELECT superseded_by FROM mem_observations WHERE id = ?`).get(otherScope.id).superseded_by, null);
|
|
82
|
-
const second = housekeepingModule.dedupObservationsPass(
|
|
82
|
+
const second = housekeepingModule.dedupObservationsPass(team.id);
|
|
83
83
|
assert.equal(second.modified, 0);
|
|
84
84
|
});
|
|
85
85
|
test("dedupDecisionsPass supersedes similar active decisions within scope and keeps the latest decision", async () => {
|
|
@@ -88,39 +88,39 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
|
|
|
88
88
|
const getScope = getFunction(memoryModule, "getScope");
|
|
89
89
|
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
90
90
|
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
91
|
-
const chapterhouse = getScope("chapterhouse");
|
|
92
91
|
const team = getScope("team");
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
92
|
+
const infra = getScope("infra");
|
|
93
|
+
assert.ok(team && infra);
|
|
94
|
+
const api = upsertEntity({ scope_id: team.id, kind: "component", name: "api" });
|
|
95
|
+
const web = upsertEntity({ scope_id: team.id, kind: "component", name: "web" });
|
|
96
96
|
const oldDecision = recordDecision({
|
|
97
|
-
scope_id:
|
|
97
|
+
scope_id: team.id,
|
|
98
98
|
entity_id: api.id,
|
|
99
99
|
title: "Use SQLite FTS5 for memory recall",
|
|
100
100
|
rationale: "Initial choice.",
|
|
101
101
|
decided_at: "2026-05-11",
|
|
102
102
|
});
|
|
103
103
|
const keeper = recordDecision({
|
|
104
|
-
scope_id:
|
|
104
|
+
scope_id: team.id,
|
|
105
105
|
entity_id: api.id,
|
|
106
106
|
title: "Use SQLite FTS5 for scoped memory recall",
|
|
107
107
|
rationale: "Latest choice.",
|
|
108
108
|
decided_at: "2026-05-13",
|
|
109
109
|
});
|
|
110
110
|
const otherEntity = recordDecision({
|
|
111
|
-
scope_id:
|
|
111
|
+
scope_id: team.id,
|
|
112
112
|
entity_id: web.id,
|
|
113
113
|
title: "Use SQLite FTS5 for memory recall",
|
|
114
114
|
rationale: "Same title, different entity context, but newer scope-level decision.",
|
|
115
115
|
decided_at: "2026-05-14",
|
|
116
116
|
});
|
|
117
117
|
const otherScope = recordDecision({
|
|
118
|
-
scope_id:
|
|
118
|
+
scope_id: infra.id,
|
|
119
119
|
title: "Use SQLite FTS5 for memory recall",
|
|
120
120
|
rationale: "Same title, different scope.",
|
|
121
121
|
decided_at: "2026-05-14",
|
|
122
122
|
});
|
|
123
|
-
const summary = housekeepingModule.dedupDecisionsPass(
|
|
123
|
+
const summary = housekeepingModule.dedupDecisionsPass(team.id);
|
|
124
124
|
assert.equal(summary.pass, "dedupDecisionsPass");
|
|
125
125
|
assert.equal(summary.examined, 3);
|
|
126
126
|
assert.equal(summary.modified, 2);
|
|
@@ -129,7 +129,7 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
|
|
|
129
129
|
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(keeper.id).superseded_by, otherEntity.id);
|
|
130
130
|
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherEntity.id).superseded_by, null);
|
|
131
131
|
assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherScope.id).superseded_by, null);
|
|
132
|
-
const second = housekeepingModule.dedupDecisionsPass(
|
|
132
|
+
const second = housekeepingModule.dedupDecisionsPass(team.id);
|
|
133
133
|
assert.equal(second.modified, 0);
|
|
134
134
|
});
|
|
135
135
|
test("orphanCleanupPass clears missing observation entity references without touching valid or out-of-scope rows", async () => {
|
|
@@ -225,18 +225,18 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
|
|
|
225
225
|
const upsertEntity = getFunction(memoryModule, "upsertEntity");
|
|
226
226
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
227
227
|
const recordDecision = getFunction(memoryModule, "recordDecision");
|
|
228
|
-
const
|
|
229
|
-
assert.ok(
|
|
230
|
-
const entity = upsertEntity({ scope_id:
|
|
228
|
+
const team = getScope("team");
|
|
229
|
+
assert.ok(team);
|
|
230
|
+
const entity = upsertEntity({ scope_id: team.id, kind: "component", name: "memory", tier: "warm" });
|
|
231
231
|
const referencedObservation = recordObservation({
|
|
232
|
-
scope_id:
|
|
232
|
+
scope_id: team.id,
|
|
233
233
|
entity_id: entity.id,
|
|
234
234
|
content: "Referenced by a recent decision through its entity.",
|
|
235
235
|
source: "test",
|
|
236
236
|
tier: "warm",
|
|
237
237
|
});
|
|
238
238
|
const recentDecision = recordDecision({
|
|
239
|
-
scope_id:
|
|
239
|
+
scope_id: team.id,
|
|
240
240
|
entity_id: entity.id,
|
|
241
241
|
title: "Restate memory tiering decision",
|
|
242
242
|
rationale: "Recent entity-linked decisions keep related observations hot.",
|
|
@@ -244,20 +244,20 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
|
|
|
244
244
|
tier: "warm",
|
|
245
245
|
});
|
|
246
246
|
const oldHot = recordObservation({
|
|
247
|
-
scope_id:
|
|
247
|
+
scope_id: team.id,
|
|
248
248
|
content: "Old hot row with no recall activity should cool down.",
|
|
249
249
|
source: "test",
|
|
250
250
|
tier: "hot",
|
|
251
251
|
});
|
|
252
252
|
const staleLowConfidence = recordObservation({
|
|
253
|
-
scope_id:
|
|
253
|
+
scope_id: team.id,
|
|
254
254
|
content: "Low confidence stale row should go cold.",
|
|
255
255
|
source: "test",
|
|
256
256
|
tier: "warm",
|
|
257
257
|
confidence: 0.2,
|
|
258
258
|
});
|
|
259
259
|
const archived = recordObservation({
|
|
260
|
-
scope_id:
|
|
260
|
+
scope_id: team.id,
|
|
261
261
|
content: "Archived row should always be cold.",
|
|
262
262
|
source: "test",
|
|
263
263
|
tier: "hot",
|
|
@@ -265,7 +265,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
|
|
|
265
265
|
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-45 days') WHERE id = ?`).run(oldHot.id);
|
|
266
266
|
db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-61 days') WHERE id = ?`).run(staleLowConfidence.id);
|
|
267
267
|
db.prepare(`UPDATE mem_observations SET archived_at = CURRENT_TIMESTAMP WHERE id = ?`).run(archived.id);
|
|
268
|
-
const summary = housekeepingModule.tieringPass(
|
|
268
|
+
const summary = housekeepingModule.tieringPass(team.id);
|
|
269
269
|
assert.equal(summary.pass, "tieringPass");
|
|
270
270
|
assert.equal(summary.modified, 5);
|
|
271
271
|
assert.deepEqual(summary.errors, []);
|
|
@@ -274,7 +274,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
|
|
|
274
274
|
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(oldHot.id).tier, "warm");
|
|
275
275
|
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(staleLowConfidence.id).tier, "cold");
|
|
276
276
|
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(archived.id).tier, "cold");
|
|
277
|
-
const second = housekeepingModule.tieringPass(
|
|
277
|
+
const second = housekeepingModule.tieringPass(team.id);
|
|
278
278
|
assert.equal(second.modified, 0);
|
|
279
279
|
});
|
|
280
280
|
//# sourceMappingURL=housekeeping.test.js.map
|