chapterhouse 0.3.25 → 0.4.0
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-runtime.js +1 -1
- package/dist/api/server.js +13 -1
- package/dist/api/server.test.js +68 -54
- package/dist/api/sse.integration.test.js +4 -46
- package/dist/api/turn-sse.integration.test.js +20 -47
- package/dist/config.js +81 -1
- package/dist/config.test.js +123 -0
- package/dist/copilot/agents.js +27 -4
- package/dist/copilot/agents.test.js +7 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +228 -4
- package/dist/copilot/orchestrator.test.js +373 -1
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +24 -0
- package/dist/copilot/tools.agent.test.js +23 -0
- package/dist/copilot/tools.js +350 -4
- package/dist/copilot/tools.memory.test.js +248 -0
- package/dist/copilot/turn-event-log-env.test.js +19 -0
- package/dist/copilot/turn-event-log.js +22 -23
- package/dist/copilot/turn-event-log.test.js +61 -2
- package/dist/memory/active-scope.js +69 -0
- package/dist/memory/active-scope.test.js +76 -0
- package/dist/memory/checkpoint-prompt.js +71 -0
- package/dist/memory/checkpoint.js +257 -0
- package/dist/memory/checkpoint.test.js +255 -0
- package/dist/memory/decisions.js +53 -0
- package/dist/memory/decisions.test.js +92 -0
- package/dist/memory/entities.js +59 -0
- package/dist/memory/entities.test.js +65 -0
- package/dist/memory/eot.js +219 -0
- package/dist/memory/eot.test.js +263 -0
- package/dist/memory/hot-tier.js +187 -0
- package/dist/memory/hot-tier.test.js +197 -0
- package/dist/memory/housekeeping.js +352 -0
- package/dist/memory/housekeeping.test.js +280 -0
- package/dist/memory/inbox.js +73 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/observations.js +46 -0
- package/dist/memory/observations.test.js +86 -0
- package/dist/memory/recall.js +197 -0
- package/dist/memory/recall.test.js +196 -0
- package/dist/memory/scopes.js +89 -0
- package/dist/memory/scopes.test.js +201 -0
- package/dist/memory/tiering.js +193 -0
- package/dist/memory/types.js +2 -0
- package/dist/paths.js +7 -1
- package/dist/store/db.js +423 -17
- package/dist/store/db.test.js +94 -7
- package/dist/test/api-server.js +50 -0
- package/dist/test/api-server.test.js +57 -0
- package/dist/test/setup-env.js +25 -0
- package/dist/test/setup-env.test.js +38 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
- package/web/dist/assets/index-DmYLALt0.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Architecture decisions (from Mal #129):
|
|
5
5
|
* - Events accumulate in a per-turn ring buffer (200 events) during the turn.
|
|
6
|
-
* - A parallel per-session ring buffer
|
|
7
|
-
* -
|
|
6
|
+
* - A parallel per-session ring buffer feeds the hot SSE reconnect path.
|
|
7
|
+
* - Events are persisted to the `turn_events` SQLite table as they are emitted.
|
|
8
8
|
* - The per-turn buffer is cleared after a 30 s grace window so late reconnects
|
|
9
9
|
* can still replay from memory before SQLite is needed.
|
|
10
10
|
* - After the grace window, SSE replay falls back to SQLite.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - `emitTurnEvent(sessionKey, event)` — emit an event (called from orchestrator.ts)
|
|
14
14
|
* - `subscribeTurn(turnId, listener)` — per-turn subscribe with immediate replay
|
|
15
15
|
* - `subscribeSession(sessionKey, listener, afterSeq?)` — session SSE subscribe + replay
|
|
16
|
-
* - `persistTurnEvents(turnId, sessionKey)` —
|
|
16
|
+
* - `persistTurnEvents(turnId, sessionKey)` — compatibility no-op after eager persistence
|
|
17
17
|
* - `scheduleClearTurnLog(turnId)` — schedule buffer clear after grace window
|
|
18
18
|
* - `getSessionEventsFromDb(sessionKey, afterSeq)` — SQLite fallback for completed turns
|
|
19
19
|
*
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { childLogger } from "../util/logger.js";
|
|
23
23
|
import { getDb } from "../store/db.js";
|
|
24
|
+
import { config } from "../config.js";
|
|
24
25
|
import { RingBuffer } from "./ring-buffer.js";
|
|
25
26
|
const log = childLogger("turn-event-log");
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
@@ -29,7 +30,9 @@ const log = childLogger("turn-event-log");
|
|
|
29
30
|
/** Events retained per turn in memory (covers long turns with many tool calls). */
|
|
30
31
|
export const TURN_BUFFER_CAPACITY = 200;
|
|
31
32
|
/** Recent events retained per session for SSE reconnect replay. */
|
|
32
|
-
export const SESSION_BUFFER_CAPACITY =
|
|
33
|
+
export const SESSION_BUFFER_CAPACITY = config.sseBufferCapacity;
|
|
34
|
+
/** Maximum SQLite replay events returned for one session reconnect. */
|
|
35
|
+
export const SESSION_REPLAY_LIMIT = config.sseReplayLimit;
|
|
33
36
|
/** Grace window before per-turn buffer is cleared after turn completion (ms). */
|
|
34
37
|
export const TURN_CLEAR_GRACE_MS = 30_000;
|
|
35
38
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +61,7 @@ let globalSeq = 0;
|
|
|
58
61
|
*/
|
|
59
62
|
export function emitTurnEvent(sessionKey, event) {
|
|
60
63
|
const indexed = { ...event, _seq: ++globalSeq, _ts: Date.now() };
|
|
64
|
+
persistIndexedTurnEvent(sessionKey, indexed);
|
|
61
65
|
// Per-turn buffer --------------------------------------------------------
|
|
62
66
|
const turnId = event.turnId;
|
|
63
67
|
let entry = turnBuffers.get(turnId);
|
|
@@ -182,31 +186,26 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
|
|
|
182
186
|
// Persistence
|
|
183
187
|
// ---------------------------------------------------------------------------
|
|
184
188
|
/**
|
|
185
|
-
* Persist
|
|
186
|
-
*
|
|
187
|
-
* No-ops silently if the turn has no buffered events.
|
|
189
|
+
* Persist one indexed event to the `turn_events` SQLite table synchronously.
|
|
190
|
+
* SQLite is the replay source of truth; the in-memory rings are hot caches.
|
|
188
191
|
*/
|
|
189
|
-
|
|
190
|
-
const entry = turnBuffers.get(turnId);
|
|
191
|
-
if (!entry)
|
|
192
|
-
return;
|
|
193
|
-
const events = entry.buf.getAll();
|
|
194
|
-
if (events.length === 0)
|
|
195
|
-
return;
|
|
192
|
+
function persistIndexedTurnEvent(sessionKey, event) {
|
|
196
193
|
try {
|
|
197
194
|
const db = getDb();
|
|
198
|
-
const stmt = db.prepare(`INSERT
|
|
195
|
+
const stmt = db.prepare(`INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
|
|
199
196
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
200
|
-
|
|
201
|
-
for (const e of events) {
|
|
202
|
-
stmt.run(turnId, sessionKey, e._seq, e._ts, e.type, JSON.stringify(e));
|
|
203
|
-
}
|
|
204
|
-
})();
|
|
197
|
+
stmt.run(event.turnId, sessionKey, event._seq, event._ts, event.type, JSON.stringify(event));
|
|
205
198
|
}
|
|
206
199
|
catch (err) {
|
|
207
|
-
log.warn({ err: err instanceof Error ? err.message : err, turnId }, "turn-event-log: SQLite persist failed");
|
|
200
|
+
log.warn({ err: err instanceof Error ? err.message : err, turnId: event.turnId }, "turn-event-log: SQLite persist failed");
|
|
208
201
|
}
|
|
209
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Compatibility no-op for callers that still finalize turns through this API.
|
|
205
|
+
* Events are now persisted eagerly by emitTurnEvent(), including terminal events.
|
|
206
|
+
*/
|
|
207
|
+
export function persistTurnEvents(_turnId, _sessionKey) {
|
|
208
|
+
}
|
|
210
209
|
// ---------------------------------------------------------------------------
|
|
211
210
|
// Lifecycle — clear
|
|
212
211
|
// ---------------------------------------------------------------------------
|
|
@@ -272,8 +271,8 @@ export function getSessionEventsFromDb(sessionKey, afterSeq = 0) {
|
|
|
272
271
|
.prepare(`SELECT payload FROM turn_events
|
|
273
272
|
WHERE session_key = ? AND seq > ?
|
|
274
273
|
ORDER BY seq ASC
|
|
275
|
-
LIMIT
|
|
276
|
-
.all(sessionKey, afterSeq);
|
|
274
|
+
LIMIT ?`)
|
|
275
|
+
.all(sessionKey, afterSeq, SESSION_REPLAY_LIMIT);
|
|
277
276
|
return rows.map((r) => JSON.parse(r.payload));
|
|
278
277
|
}
|
|
279
278
|
catch (err) {
|
|
@@ -25,17 +25,21 @@
|
|
|
25
25
|
import { describe, it, afterEach } from "node:test";
|
|
26
26
|
import assert from "node:assert/strict";
|
|
27
27
|
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
|
|
28
|
-
import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
|
|
28
|
+
import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
|
|
29
|
+
import { getDb } from "../store/db.js";
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
// Helpers
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
let turnCounter = 0;
|
|
33
34
|
let sessionCounter = 0;
|
|
35
|
+
const usedSessionKeys = [];
|
|
34
36
|
function freshTurnId() {
|
|
35
37
|
return `turn-test-${++turnCounter}-${Date.now()}`;
|
|
36
38
|
}
|
|
37
39
|
function freshSessionKey() {
|
|
38
|
-
|
|
40
|
+
const sessionKey = `session-test-${++sessionCounter}-${Date.now()}`;
|
|
41
|
+
usedSessionKeys.push(sessionKey);
|
|
42
|
+
return sessionKey;
|
|
39
43
|
}
|
|
40
44
|
function makeStarted(turnId, sessionKey) {
|
|
41
45
|
return { type: "turn:started", turnId, sessionKey, prompt: "hello" };
|
|
@@ -63,6 +67,14 @@ afterEach(() => {
|
|
|
63
67
|
for (const id of usedTurnIds.splice(0)) {
|
|
64
68
|
clearTurnLog(id);
|
|
65
69
|
}
|
|
70
|
+
const sessions = usedSessionKeys.splice(0);
|
|
71
|
+
if (sessions.length > 0) {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
const deleteSessionEvents = db.prepare("DELETE FROM turn_events WHERE session_key = ?");
|
|
74
|
+
for (const sessionKey of sessions) {
|
|
75
|
+
deleteSessionEvents.run(sessionKey);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
66
78
|
});
|
|
67
79
|
function trackTurn(turnId) {
|
|
68
80
|
usedTurnIds.push(turnId);
|
|
@@ -98,6 +110,15 @@ describe("turn-event-log", () => {
|
|
|
98
110
|
const after = Date.now();
|
|
99
111
|
assert.ok(collected[0]._ts >= before && collected[0]._ts <= after);
|
|
100
112
|
});
|
|
113
|
+
it("persists each event to SQLite immediately", () => {
|
|
114
|
+
const session = freshSessionKey();
|
|
115
|
+
const turnId = trackTurn(freshTurnId());
|
|
116
|
+
emitTurnEvent(session, makeStarted(turnId, session));
|
|
117
|
+
const persisted = getSessionEventsFromDb(session);
|
|
118
|
+
assert.equal(persisted.length, 1);
|
|
119
|
+
assert.equal(persisted[0].type, "turn:started");
|
|
120
|
+
assert.equal(persisted[0].turnId, turnId);
|
|
121
|
+
});
|
|
101
122
|
});
|
|
102
123
|
describe("subscribeTurn", () => {
|
|
103
124
|
it("replays existing buffered events immediately on subscribe", () => {
|
|
@@ -224,6 +245,16 @@ describe("turn-event-log", () => {
|
|
|
224
245
|
assert.equal(recv1[0].turnId, turn1);
|
|
225
246
|
assert.equal(recv2[0].turnId, turn2);
|
|
226
247
|
});
|
|
248
|
+
it("SQLite fallback returns events from an in-flight turn", () => {
|
|
249
|
+
const session = freshSessionKey();
|
|
250
|
+
const turnId = trackTurn(freshTurnId());
|
|
251
|
+
emitTurnEvent(session, makeStarted(turnId, session));
|
|
252
|
+
emitTurnEvent(session, makeDelta(turnId, session, "still-running"));
|
|
253
|
+
const persisted = getSessionEventsFromDb(session);
|
|
254
|
+
assert.equal(persisted.length, 2);
|
|
255
|
+
assert.deepEqual(persisted.map((event) => event.type), ["turn:started", "turn:delta"]);
|
|
256
|
+
assert.equal(persisted[1].part.text, "still-running");
|
|
257
|
+
});
|
|
227
258
|
});
|
|
228
259
|
describe("clearTurnLog", () => {
|
|
229
260
|
it("removes the per-turn buffer immediately", () => {
|
|
@@ -321,6 +352,34 @@ describe("turn-event-log", () => {
|
|
|
321
352
|
trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
|
|
322
353
|
assert.equal(received.length, TURN_BUFFER_CAPACITY);
|
|
323
354
|
});
|
|
355
|
+
it("SQLite replay keeps events that were evicted from the session ring buffer", () => {
|
|
356
|
+
const session = freshSessionKey();
|
|
357
|
+
const turnId = trackTurn(freshTurnId());
|
|
358
|
+
const totalEvents = SESSION_BUFFER_CAPACITY + 3;
|
|
359
|
+
for (let i = 0; i < totalEvents; i++) {
|
|
360
|
+
emitTurnEvent(session, makeDelta(turnId, session, `chunk-${i}`));
|
|
361
|
+
}
|
|
362
|
+
const fromBuffer = [];
|
|
363
|
+
const unsubscribe = subscribeSession(session, (event) => fromBuffer.push(event), 0);
|
|
364
|
+
unsubscribe();
|
|
365
|
+
assert.equal(fromBuffer.length, SESSION_BUFFER_CAPACITY);
|
|
366
|
+
const fromDb = getSessionEventsFromDb(session);
|
|
367
|
+
assert.equal(fromDb.length, totalEvents);
|
|
368
|
+
assert.equal(fromDb[0].part.text, "chunk-0");
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
describe("persistTurnEvents", () => {
|
|
372
|
+
it("does not double-write events that were eagerly persisted by turn completion", () => {
|
|
373
|
+
const session = freshSessionKey();
|
|
374
|
+
const turnId = trackTurn(freshTurnId());
|
|
375
|
+
emitTurnEvent(session, makeStarted(turnId, session));
|
|
376
|
+
emitTurnEvent(session, makeComplete(turnId, session));
|
|
377
|
+
assert.equal(getSessionEventsFromDb(session).length, 2);
|
|
378
|
+
persistTurnEvents(turnId, session);
|
|
379
|
+
const persisted = getSessionEventsFromDb(session);
|
|
380
|
+
assert.equal(persisted.length, 2);
|
|
381
|
+
assert.deepEqual(persisted.map((event) => event.type), ["turn:started", "turn:complete"]);
|
|
382
|
+
});
|
|
324
383
|
});
|
|
325
384
|
});
|
|
326
385
|
//# sourceMappingURL=turn-event-log.test.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getDb } from "../store/db.js";
|
|
2
|
+
import { getScope, listScopes } from "./scopes.js";
|
|
3
|
+
const ACTIVE_SCOPE_KEY = "current_scope_slug";
|
|
4
|
+
function setSetting(key, value) {
|
|
5
|
+
getDb().prepare(`
|
|
6
|
+
INSERT INTO mem_settings (key, value)
|
|
7
|
+
VALUES (?, ?)
|
|
8
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
9
|
+
`).run(key, value);
|
|
10
|
+
}
|
|
11
|
+
function getSetting(key) {
|
|
12
|
+
const row = getDb().prepare(`
|
|
13
|
+
SELECT value
|
|
14
|
+
FROM mem_settings
|
|
15
|
+
WHERE key = ?
|
|
16
|
+
`).get(key);
|
|
17
|
+
return row?.value;
|
|
18
|
+
}
|
|
19
|
+
function clearSetting(key) {
|
|
20
|
+
getDb().prepare(`DELETE FROM mem_settings WHERE key = ?`).run(key);
|
|
21
|
+
}
|
|
22
|
+
export function getActiveScope() {
|
|
23
|
+
const slug = getSetting(ACTIVE_SCOPE_KEY);
|
|
24
|
+
if (!slug)
|
|
25
|
+
return null;
|
|
26
|
+
const scope = getScope(slug);
|
|
27
|
+
if (!scope) {
|
|
28
|
+
clearSetting(ACTIVE_SCOPE_KEY);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return scope;
|
|
32
|
+
}
|
|
33
|
+
export function setActiveScope(slug) {
|
|
34
|
+
if (slug === null) {
|
|
35
|
+
clearSetting(ACTIVE_SCOPE_KEY);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const scope = getScope(slug);
|
|
39
|
+
if (!scope) {
|
|
40
|
+
throw new Error(`Unknown memory scope '${slug}'.`);
|
|
41
|
+
}
|
|
42
|
+
setSetting(ACTIVE_SCOPE_KEY, scope.slug);
|
|
43
|
+
return scope;
|
|
44
|
+
}
|
|
45
|
+
export function inferScopeFromText(text) {
|
|
46
|
+
const lowered = text.toLowerCase();
|
|
47
|
+
const ranked = listScopes()
|
|
48
|
+
.filter((scope) => scope.active)
|
|
49
|
+
.map((scope) => {
|
|
50
|
+
const matchedKeywords = scope.keywords.filter((keyword, index, keywords) => lowered.includes(keyword.toLowerCase()) && keywords.indexOf(keyword) === index);
|
|
51
|
+
return { scope, matchedKeywords };
|
|
52
|
+
})
|
|
53
|
+
.filter((entry) => entry.matchedKeywords.length > 0)
|
|
54
|
+
.sort((a, b) => {
|
|
55
|
+
if (b.matchedKeywords.length !== a.matchedKeywords.length) {
|
|
56
|
+
return b.matchedKeywords.length - a.matchedKeywords.length;
|
|
57
|
+
}
|
|
58
|
+
return a.scope.slug.localeCompare(b.scope.slug);
|
|
59
|
+
});
|
|
60
|
+
const winner = ranked[0];
|
|
61
|
+
if (!winner)
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
scope_id: winner.scope.id,
|
|
65
|
+
confidence: winner.matchedKeywords.length / Math.max(winner.scope.keywords.length, 1),
|
|
66
|
+
matched_keywords: winner.matchedKeywords,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=active-scope.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
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-active-scope-${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
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
26
|
+
dbModule.closeDb();
|
|
27
|
+
resetSandbox();
|
|
28
|
+
});
|
|
29
|
+
test.after(async () => {
|
|
30
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
31
|
+
dbModule.closeDb();
|
|
32
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
test("active scope can be set, read, and cleared without changing scope activation status", async () => {
|
|
35
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
36
|
+
dbModule.getDb();
|
|
37
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
38
|
+
const getActiveScope = getFunction(memoryModule, "getActiveScope");
|
|
39
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
40
|
+
const deactivateScope = getFunction(memoryModule, "deactivateScope");
|
|
41
|
+
assert.equal(getActiveScope(), null);
|
|
42
|
+
assert.equal(setActiveScope("chapterhouse")?.slug, "chapterhouse");
|
|
43
|
+
assert.equal(getActiveScope()?.slug, "chapterhouse");
|
|
44
|
+
const team = getScope("team");
|
|
45
|
+
assert.ok(team);
|
|
46
|
+
const deactivated = deactivateScope(team.id);
|
|
47
|
+
assert.equal(deactivated.active, false);
|
|
48
|
+
assert.equal(getActiveScope()?.slug, "chapterhouse");
|
|
49
|
+
assert.equal(setActiveScope(null), null);
|
|
50
|
+
assert.equal(getActiveScope(), null);
|
|
51
|
+
});
|
|
52
|
+
test("inferScopeFromText prefers the highest keyword match count and breaks ties deterministically", async () => {
|
|
53
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
54
|
+
dbModule.getDb();
|
|
55
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
56
|
+
const inferScopeFromText = getFunction(memoryModule, "inferScopeFromText");
|
|
57
|
+
const alpha = createScope({
|
|
58
|
+
slug: "alpha-release",
|
|
59
|
+
title: "Alpha Release",
|
|
60
|
+
description: "Alpha release work",
|
|
61
|
+
keywords: ["release"],
|
|
62
|
+
});
|
|
63
|
+
const beta = createScope({
|
|
64
|
+
slug: "beta-deploy",
|
|
65
|
+
title: "Beta Deploy",
|
|
66
|
+
description: "Deployment work",
|
|
67
|
+
keywords: ["deploy", "release"],
|
|
68
|
+
});
|
|
69
|
+
const bestMatch = inferScopeFromText("Please deploy the release workflow today.");
|
|
70
|
+
assert.equal(bestMatch?.scope_id, beta.id);
|
|
71
|
+
assert.deepEqual(bestMatch?.matched_keywords.sort(), ["deploy", "release"]);
|
|
72
|
+
const tie = inferScopeFromText("This release needs triage.");
|
|
73
|
+
assert.equal(tie?.scope_id, alpha.id);
|
|
74
|
+
assert.deepEqual(tie?.matched_keywords, ["release"]);
|
|
75
|
+
});
|
|
76
|
+
//# sourceMappingURL=active-scope.test.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function renderTurns(turns) {
|
|
2
|
+
return turns
|
|
3
|
+
.map((turn, index) => [
|
|
4
|
+
`Turn ${index + 1}`,
|
|
5
|
+
`User: ${turn.user}`,
|
|
6
|
+
`Assistant: ${turn.assistant}`,
|
|
7
|
+
].join("\n"))
|
|
8
|
+
.join("\n\n");
|
|
9
|
+
}
|
|
10
|
+
function renderEntities(entities) {
|
|
11
|
+
if (entities.length === 0) {
|
|
12
|
+
return "- none";
|
|
13
|
+
}
|
|
14
|
+
return entities
|
|
15
|
+
.map((entity) => `- ${entity.name} (${entity.kind}): ${entity.summary ?? entity.name}`)
|
|
16
|
+
.join("\n");
|
|
17
|
+
}
|
|
18
|
+
function renderDecisions(decisions) {
|
|
19
|
+
if (decisions.length === 0) {
|
|
20
|
+
return "- none";
|
|
21
|
+
}
|
|
22
|
+
return decisions
|
|
23
|
+
.map((decision) => `- ${decision.title}: ${decision.rationale}`)
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
export function buildCheckpointSystemPrompt() {
|
|
27
|
+
return [
|
|
28
|
+
"You extract durable agent memory from the most recent orchestrator turns.",
|
|
29
|
+
"Only return valid JSON. No prose, no markdown, no code fences.",
|
|
30
|
+
"Remember durable items only:",
|
|
31
|
+
"- decisions (architecture, process, user preferences)",
|
|
32
|
+
"- durable facts learned about code, tools, people, or infrastructure",
|
|
33
|
+
"- reusable gotchas or lessons learned",
|
|
34
|
+
"- named entities only when they are durable and worth remembering as an observation",
|
|
35
|
+
"Skip greetings, ephemeral progress updates, temporary branch names, one-off commit wording, and off-topic chatter.",
|
|
36
|
+
"Prefer concrete entries over vague summaries.",
|
|
37
|
+
"Use kind='decision' only when a real decision was made; otherwise use kind='observation'.",
|
|
38
|
+
"If nothing is worth remembering, return {\"proposals\":[]}.",
|
|
39
|
+
"JSON schema:",
|
|
40
|
+
"{\"proposals\":[{\"kind\":\"observation|decision\",\"title\":\"required for decision\",\"content\":\"string\",\"scope\":\"optional scope slug; omit to use active scope\",\"confidence\":0.0}]}",
|
|
41
|
+
"Example good output:",
|
|
42
|
+
"{\"proposals\":[{\"kind\":\"decision\",\"title\":\"Use SQLite FTS5 for recall\",\"content\":\"Chapterhouse uses SQLite FTS5 for scoped memory recall in v1.\",\"confidence\":0.93}]}",
|
|
43
|
+
"{\"proposals\":[{\"kind\":\"observation\",\"content\":\"Only orchestrator turns count toward memory checkpoints.\",\"confidence\":0.88}]}",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
export function buildCheckpointUserPrompt(input) {
|
|
47
|
+
const scopeChangeBlock = input.scopeChangeContext
|
|
48
|
+
? [
|
|
49
|
+
`Scope-change context: the user is moving from scope ${input.scopeChangeContext.from} to ${input.scopeChangeContext.to}.`,
|
|
50
|
+
`Extract everything worth remembering about scope ${input.scopeChangeContext.from} from the recent turns BEFORE the context shifts.`,
|
|
51
|
+
"",
|
|
52
|
+
].join("\n")
|
|
53
|
+
: "";
|
|
54
|
+
return [
|
|
55
|
+
`Active scope: ${input.activeScope.slug} — ${input.activeScope.title}`,
|
|
56
|
+
`Scope description: ${input.activeScope.description}`,
|
|
57
|
+
"",
|
|
58
|
+
scopeChangeBlock,
|
|
59
|
+
"Known entities in the active scope:",
|
|
60
|
+
renderEntities(input.entities),
|
|
61
|
+
"",
|
|
62
|
+
"Known decisions in the active scope:",
|
|
63
|
+
renderDecisions(input.decisions),
|
|
64
|
+
"",
|
|
65
|
+
"Recent orchestrator turns:",
|
|
66
|
+
renderTurns(input.turns),
|
|
67
|
+
"",
|
|
68
|
+
"Return only the JSON object.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=checkpoint-prompt.js.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { runOneShotPrompt } from "../copilot/oneshot.js";
|
|
3
|
+
import { childLogger } from "../util/logger.js";
|
|
4
|
+
import { recordDecision } from "./decisions.js";
|
|
5
|
+
import { listEntities } from "./entities.js";
|
|
6
|
+
import { recordObservation, listObservations } from "./observations.js";
|
|
7
|
+
import { getScope } from "./scopes.js";
|
|
8
|
+
import { listDecisions } from "./decisions.js";
|
|
9
|
+
import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
|
|
10
|
+
const log = childLogger("memory.checkpoint");
|
|
11
|
+
const MAX_WRITES_PER_CHECKPOINT = 5;
|
|
12
|
+
const MIN_CONFIDENCE = 0.5;
|
|
13
|
+
const RECENT_MEMORY_LIMIT = 50;
|
|
14
|
+
const inFlightSessions = new Set();
|
|
15
|
+
function getInFlightKey(sessionKey) {
|
|
16
|
+
return sessionKey?.trim() || "__global__";
|
|
17
|
+
}
|
|
18
|
+
function normalizeText(value) {
|
|
19
|
+
return value
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
function calculateSimilarity(left, right) {
|
|
26
|
+
const normalizedLeft = normalizeText(left);
|
|
27
|
+
const normalizedRight = normalizeText(right);
|
|
28
|
+
if (!normalizedLeft || !normalizedRight) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (normalizedLeft === normalizedRight) {
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (normalizedLeft.includes(normalizedRight) || normalizedRight.includes(normalizedLeft)) {
|
|
35
|
+
return Math.min(normalizedLeft.length, normalizedRight.length) / Math.max(normalizedLeft.length, normalizedRight.length);
|
|
36
|
+
}
|
|
37
|
+
const leftTokens = new Set(normalizedLeft.split(" ").filter(Boolean));
|
|
38
|
+
const rightTokens = new Set(normalizedRight.split(" ").filter(Boolean));
|
|
39
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
let overlap = 0;
|
|
43
|
+
for (const token of leftTokens) {
|
|
44
|
+
if (rightTokens.has(token)) {
|
|
45
|
+
overlap++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return overlap / new Set([...leftTokens, ...rightTokens]).size;
|
|
49
|
+
}
|
|
50
|
+
function parseProposals(raw) {
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (!Array.isArray(parsed.proposals)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return parsed.proposals.flatMap((proposal) => {
|
|
56
|
+
if (!proposal || typeof proposal !== "object") {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const candidate = proposal;
|
|
60
|
+
if ((candidate.kind !== "observation" && candidate.kind !== "decision")
|
|
61
|
+
|| typeof candidate.content !== "string"
|
|
62
|
+
|| typeof candidate.confidence !== "number") {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [{
|
|
66
|
+
kind: candidate.kind,
|
|
67
|
+
title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
|
|
68
|
+
content: candidate.content.trim(),
|
|
69
|
+
scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
|
|
70
|
+
confidence: candidate.confidence,
|
|
71
|
+
decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
|
|
72
|
+
}];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function resolveProposalScope(proposal, activeScope) {
|
|
76
|
+
if (!proposal.scope || proposal.scope === activeScope.slug) {
|
|
77
|
+
return activeScope;
|
|
78
|
+
}
|
|
79
|
+
return getScope(proposal.scope) ?? null;
|
|
80
|
+
}
|
|
81
|
+
function isDuplicateObservation(content, scopeId, batchContents) {
|
|
82
|
+
const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
83
|
+
const combined = [...existing.map((observation) => observation.content), ...batchContents];
|
|
84
|
+
return combined.some((candidate) => calculateSimilarity(candidate, content) >= 0.85);
|
|
85
|
+
}
|
|
86
|
+
function isDuplicateDecision(proposal, scopeId) {
|
|
87
|
+
const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
88
|
+
return existing.some((decision) => {
|
|
89
|
+
const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
|
|
90
|
+
const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
|
|
91
|
+
return titleSimilarity >= 0.85 || rationaleSimilarity >= 0.85;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export class CheckpointTracker {
|
|
95
|
+
turnCountSinceLastFire = 0;
|
|
96
|
+
cadence;
|
|
97
|
+
enabled;
|
|
98
|
+
constructor(options = {}) {
|
|
99
|
+
const rawTurns = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS?.trim();
|
|
100
|
+
const envTurns = rawTurns && /^\d+$/.test(rawTurns) ? Number(rawTurns) : undefined;
|
|
101
|
+
const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
|
|
102
|
+
const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
|
|
103
|
+
this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
|
|
104
|
+
this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
|
|
105
|
+
}
|
|
106
|
+
tickOrchestratorTurn() {
|
|
107
|
+
this.turnCountSinceLastFire++;
|
|
108
|
+
}
|
|
109
|
+
shouldFire() {
|
|
110
|
+
return this.enabled && this.turnCountSinceLastFire >= this.cadence;
|
|
111
|
+
}
|
|
112
|
+
turnsSinceLastFire() {
|
|
113
|
+
return this.turnCountSinceLastFire;
|
|
114
|
+
}
|
|
115
|
+
markFired() {
|
|
116
|
+
this.turnCountSinceLastFire = 0;
|
|
117
|
+
}
|
|
118
|
+
markScopeChangeFire() {
|
|
119
|
+
this.turnCountSinceLastFire = 0;
|
|
120
|
+
}
|
|
121
|
+
reset() {
|
|
122
|
+
this.turnCountSinceLastFire = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function isCheckpointInFlight(sessionKey) {
|
|
126
|
+
if (sessionKey) {
|
|
127
|
+
return inFlightSessions.has(getInFlightKey(sessionKey));
|
|
128
|
+
}
|
|
129
|
+
return inFlightSessions.size > 0;
|
|
130
|
+
}
|
|
131
|
+
export async function runCheckpointExtraction(input) {
|
|
132
|
+
const model = input.model ?? config.copilotModel;
|
|
133
|
+
const inFlightKey = getInFlightKey(input.sessionKey);
|
|
134
|
+
const errors = [];
|
|
135
|
+
const trigger = input.trigger ?? "cadence";
|
|
136
|
+
if (!input.activeScope) {
|
|
137
|
+
log.info({ sessionKey: input.sessionKey ?? "default", reason: "no_scope" }, "memory.checkpoint.skip");
|
|
138
|
+
return { written: 0, skipped: 0, errors };
|
|
139
|
+
}
|
|
140
|
+
if (isCheckpointInFlight(input.sessionKey)) {
|
|
141
|
+
log.info({ sessionKey: input.sessionKey ?? "default" }, "memory.checkpoint.in_flight_skip");
|
|
142
|
+
return { written: 0, skipped: 0, errors };
|
|
143
|
+
}
|
|
144
|
+
inFlightSessions.add(inFlightKey);
|
|
145
|
+
try {
|
|
146
|
+
log.info({
|
|
147
|
+
turnCount: input.turns.length,
|
|
148
|
+
scope: input.activeScope.slug,
|
|
149
|
+
model,
|
|
150
|
+
sessionKey: input.sessionKey ?? "default",
|
|
151
|
+
trigger,
|
|
152
|
+
}, "memory.checkpoint.fire");
|
|
153
|
+
const entities = listEntities({ scope_id: input.activeScope.id, limit: 8 });
|
|
154
|
+
const decisions = listDecisions({ scope_id: input.activeScope.id, limit: 8 });
|
|
155
|
+
const system = buildCheckpointSystemPrompt();
|
|
156
|
+
const user = buildCheckpointUserPrompt({
|
|
157
|
+
turns: input.turns,
|
|
158
|
+
activeScope: input.activeScope,
|
|
159
|
+
entities,
|
|
160
|
+
decisions,
|
|
161
|
+
scopeChangeContext: input.scopeChangeContext ?? null,
|
|
162
|
+
});
|
|
163
|
+
const callLLM = input.callLLM
|
|
164
|
+
?? (async ({ system: systemPrompt, user: userPrompt, model: chosenModel }) => {
|
|
165
|
+
const result = await runOneShotPrompt({
|
|
166
|
+
client: input.copilotClient,
|
|
167
|
+
model: chosenModel,
|
|
168
|
+
system: systemPrompt,
|
|
169
|
+
user: userPrompt,
|
|
170
|
+
expectJson: true,
|
|
171
|
+
});
|
|
172
|
+
return result.content;
|
|
173
|
+
});
|
|
174
|
+
const rawResponse = await callLLM({ system, user, model });
|
|
175
|
+
const proposals = parseProposals(rawResponse)
|
|
176
|
+
.filter((proposal) => proposal.content.length > 0)
|
|
177
|
+
.sort((left, right) => right.confidence - left.confidence);
|
|
178
|
+
log.info({
|
|
179
|
+
count: proposals.length,
|
|
180
|
+
scope: input.activeScope.slug,
|
|
181
|
+
sessionKey: input.sessionKey ?? "default",
|
|
182
|
+
}, "memory.checkpoint.proposal_count");
|
|
183
|
+
let written = 0;
|
|
184
|
+
let skipped = 0;
|
|
185
|
+
const pendingObservationContents = [];
|
|
186
|
+
for (const proposal of proposals) {
|
|
187
|
+
if (proposal.confidence < MIN_CONFIDENCE) {
|
|
188
|
+
skipped++;
|
|
189
|
+
log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (written >= MAX_WRITES_PER_CHECKPOINT) {
|
|
193
|
+
skipped++;
|
|
194
|
+
log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const scope = resolveProposalScope(proposal, input.activeScope);
|
|
198
|
+
if (!scope) {
|
|
199
|
+
skipped++;
|
|
200
|
+
log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
|
|
204
|
+
skipped++;
|
|
205
|
+
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (proposal.kind === "decision") {
|
|
209
|
+
if (!proposal.title) {
|
|
210
|
+
skipped++;
|
|
211
|
+
log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (isDuplicateDecision(proposal, scope.id)) {
|
|
215
|
+
skipped++;
|
|
216
|
+
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const decision = recordDecision({
|
|
220
|
+
scope_id: scope.id,
|
|
221
|
+
title: proposal.title,
|
|
222
|
+
rationale: proposal.content,
|
|
223
|
+
decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
|
|
224
|
+
tier: "warm",
|
|
225
|
+
});
|
|
226
|
+
written++;
|
|
227
|
+
log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const observation = recordObservation({
|
|
231
|
+
scope_id: scope.id,
|
|
232
|
+
content: proposal.content,
|
|
233
|
+
source: "checkpoint:orchestrator",
|
|
234
|
+
tier: "warm",
|
|
235
|
+
confidence: proposal.confidence,
|
|
236
|
+
});
|
|
237
|
+
pendingObservationContents.push(proposal.content);
|
|
238
|
+
written++;
|
|
239
|
+
log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
|
|
240
|
+
}
|
|
241
|
+
return { written, skipped, errors };
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
+
errors.push(message);
|
|
246
|
+
log.error({
|
|
247
|
+
err: error,
|
|
248
|
+
sessionKey: input.sessionKey ?? "default",
|
|
249
|
+
scope: input.activeScope.slug,
|
|
250
|
+
}, "memory.checkpoint.error");
|
|
251
|
+
return { written: 0, skipped: 0, errors };
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
inFlightSessions.delete(inFlightKey);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=checkpoint.js.map
|