chapterhouse 0.3.26 → 0.4.1

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.
Files changed (53) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -0,0 +1,297 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ async function loadModules() {
7
+ const nonce = `${Date.now()}-${Math.random()}`;
8
+ const toolsModule = await import(new URL(`./tools.js?case=${nonce}`, import.meta.url).href);
9
+ const agentsModule = await import(new URL("./agents.js", import.meta.url).href);
10
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
11
+ return { toolsModule, agentsModule, dbModule };
12
+ }
13
+ function findTool(tools, name) {
14
+ const tool = tools.find((entry) => entry.name === name);
15
+ assert.ok(tool, `${name} tool should be registered`);
16
+ return tool;
17
+ }
18
+ test.beforeEach(() => {
19
+ process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-memory-"));
20
+ });
21
+ test.afterEach(async () => {
22
+ const home = process.env.CHAPTERHOUSE_HOME;
23
+ if (home) {
24
+ const dbModule = await import("../store/db.js");
25
+ dbModule.closeDb();
26
+ rmSync(home, { recursive: true, force: true });
27
+ }
28
+ });
29
+ test("memory_set_scope invalidates the orchestrator session after scheduling the scope-change checkpoint", async (t) => {
30
+ const events = [];
31
+ t.mock.module("./orchestrator.js", {
32
+ namedExports: {
33
+ getCurrentSourceChannel: () => "web",
34
+ getCurrentActivityCallback: () => undefined,
35
+ getCurrentActiveProjectRules: () => null,
36
+ getCurrentAuthenticatedUser: () => undefined,
37
+ getLastAuthenticatedUser: () => undefined,
38
+ getCurrentAuthorizationHeader: () => undefined,
39
+ getCurrentSessionKey: () => "session-test",
40
+ maybeScheduleScopeChangeCheckpoint: (sessionKey, previousScope, nextScope) => {
41
+ events.push(`checkpoint:${sessionKey}:${previousScope?.slug ?? "null"}->${nextScope?.slug ?? "null"}`);
42
+ },
43
+ resetCheckpointSessionState: (sessionKey) => {
44
+ events.push(`reset:${sessionKey}`);
45
+ },
46
+ invalidateOrchestratorSession: (sessionKey) => {
47
+ events.push(`invalidate:${sessionKey}`);
48
+ },
49
+ switchSessionModel: async () => { },
50
+ },
51
+ });
52
+ const { toolsModule } = await loadModules();
53
+ const tools = toolsModule.createTools({
54
+ client: { async listModels() { return []; } },
55
+ onAgentTaskComplete: () => { },
56
+ });
57
+ const memoryRemember = findTool(tools, "memory_remember");
58
+ const memorySetScope = findTool(tools, "memory_set_scope");
59
+ const remembered = await memoryRemember.handler({
60
+ content: "Scope changes should refresh hot-tier memory on the next turn.",
61
+ scope: "chapterhouse",
62
+ kind: "observation",
63
+ }, {});
64
+ assert.equal(remembered.ok, true);
65
+ events.length = 0;
66
+ const result = await memorySetScope.handler({ slug: "chapterhouse" }, {});
67
+ assert.equal(result.active_scope?.slug, "chapterhouse");
68
+ assert.deepEqual(events, [
69
+ "checkpoint:session-test:null->chapterhouse",
70
+ "invalidate:session-test",
71
+ "reset:session-test",
72
+ ]);
73
+ events.length = 0;
74
+ const unchangedResult = await memorySetScope.handler({ slug: "chapterhouse" }, {});
75
+ assert.equal(unchangedResult.active_scope?.slug, "chapterhouse");
76
+ assert.deepEqual(events, []);
77
+ });
78
+ test("memory tools remember, recall, set scope, and enforce orchestrator-only writes", async () => {
79
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
80
+ const tools = toolsModule.createTools({
81
+ client: { async listModels() { return []; } },
82
+ onAgentTaskComplete: () => { },
83
+ });
84
+ const memoryRemember = findTool(tools, "memory_remember");
85
+ const memoryRecall = findTool(tools, "memory_recall");
86
+ const memorySetScope = findTool(tools, "memory_set_scope");
87
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
88
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
89
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
90
+ const coderTools = bindToolsToAgent("coder", tools);
91
+ const chapterhouseRemember = findTool(chapterhouseTools, "memory_remember");
92
+ const chapterhouseRecall = findTool(chapterhouseTools, "memory_recall");
93
+ const chapterhouseSetScope = findTool(chapterhouseTools, "memory_set_scope");
94
+ const remembered = await chapterhouseRemember.handler({
95
+ content: "Use SQLite FTS5 to recall scoped memory entries.",
96
+ scope: "chapterhouse",
97
+ kind: "observation",
98
+ }, {});
99
+ assert.equal(typeof remembered, "object");
100
+ assert.equal(remembered.ok, true);
101
+ const db = dbModule.getDb();
102
+ const row = db.prepare(`
103
+ SELECT id, content
104
+ FROM mem_observations
105
+ WHERE content = ?
106
+ `).get("Use SQLite FTS5 to recall scoped memory entries.");
107
+ assert.ok(row, "memory_remember should write an observation row");
108
+ const ftsHits = db.prepare(`
109
+ SELECT rowid
110
+ FROM mem_observations_fts
111
+ WHERE mem_observations_fts MATCH 'SQLite'
112
+ `).all();
113
+ assert.equal(ftsHits.some((hit) => hit.rowid === row.id), true);
114
+ const recalled = await chapterhouseRecall.handler({
115
+ query: "SQLite FTS5 scoped memory",
116
+ scope: "chapterhouse",
117
+ limit: 10,
118
+ }, {});
119
+ assert.equal(typeof recalled, "object");
120
+ assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
121
+ const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
122
+ assert.equal(scopeResult.active_scope?.slug, "chapterhouse");
123
+ const implicitScopeRemember = await chapterhouseRemember.handler({
124
+ content: "Implicit active-scope writes should route to chapterhouse.",
125
+ kind: "observation",
126
+ }, {});
127
+ assert.equal(implicitScopeRemember.ok, true);
128
+ const coderVisibleTools = agentsModule.filterToolsForAgent({
129
+ slug: "coder",
130
+ name: "Coder",
131
+ description: "Software engineer",
132
+ model: "gpt-5.4",
133
+ systemMessage: "test",
134
+ }, tools);
135
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_remember"), false);
136
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_set_scope"), false);
137
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_recall"), true);
138
+ const coderRemember = findTool(coderTools, "memory_remember");
139
+ const rejected = await coderRemember.handler({
140
+ content: "Subagents should not be able to write memory directly.",
141
+ scope: "chapterhouse",
142
+ kind: "observation",
143
+ }, {});
144
+ assert.match(String(rejected), /orchestrator-only|memory_propose/i);
145
+ const coderRecall = findTool(coderVisibleTools, "memory_recall");
146
+ const coderRecalled = await coderRecall.handler({
147
+ query: "SQLite",
148
+ scope: "chapterhouse",
149
+ limit: 10,
150
+ }, {});
151
+ assert.equal(typeof coderRecalled, "object");
152
+ assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
153
+ assert.ok(memoryRemember && memoryRecall && memorySetScope);
154
+ });
155
+ test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
156
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
157
+ const tools = toolsModule.createTools({
158
+ client: { async listModels() { return []; } },
159
+ onAgentTaskComplete: () => { },
160
+ });
161
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
162
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
163
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
164
+ const coderTools = bindToolsToAgent("coder", tools, "task-propose-001");
165
+ const memorySetScope = findTool(chapterhouseTools, "memory_set_scope");
166
+ const memoryPropose = findTool(coderTools, "memory_propose");
167
+ await memorySetScope.handler({ slug: "chapterhouse" }, {});
168
+ const proposed = await memoryPropose.handler({
169
+ kind: "observation",
170
+ payload: {
171
+ content: "Subagents can queue durable observations for orchestrator review.",
172
+ source: "final task summary",
173
+ },
174
+ confidence: 0.9,
175
+ reason: "The user explicitly asked for the new proposal path.",
176
+ }, {});
177
+ assert.equal(proposed.status, "queued");
178
+ assert.equal(typeof proposed.proposal_id, "number");
179
+ const db = dbModule.getDb();
180
+ const row = db.prepare(`
181
+ SELECT kind, status, source_agent, source_task_id, payload
182
+ FROM mem_inbox
183
+ WHERE id = ?
184
+ `).get(proposed.proposal_id);
185
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
186
+ assert.equal(row.kind, "memory_proposal");
187
+ assert.equal(row.status, "pending");
188
+ assert.equal(row.source_agent, "coder");
189
+ assert.equal(row.source_task_id, "task-propose-001");
190
+ const payload = JSON.parse(row.payload);
191
+ assert.equal(payload.kind, "observation");
192
+ assert.equal(payload.scope_slug, "chapterhouse");
193
+ assert.equal(payload.confidence, 0.9);
194
+ assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
195
+ assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
196
+ });
197
+ test("memory_propose rejects invalid proposal kinds", async () => {
198
+ const { toolsModule } = await loadModules();
199
+ const tools = toolsModule.createTools({
200
+ client: { async listModels() { return []; } },
201
+ onAgentTaskComplete: () => { },
202
+ });
203
+ const memoryPropose = findTool(tools, "memory_propose");
204
+ const result = await memoryPropose.handler({
205
+ kind: "pattern",
206
+ payload: { content: "invalid kind" },
207
+ }, {});
208
+ assert.match(String(result), /observation|decision|entity/i);
209
+ });
210
+ test("memory_housekeep is orchestrator-only and returns housekeeping summaries", async () => {
211
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
212
+ const tools = toolsModule.createTools({
213
+ client: { async listModels() { return []; } },
214
+ onAgentTaskComplete: () => { },
215
+ });
216
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
217
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
218
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
219
+ const coderTools = bindToolsToAgent("coder", tools);
220
+ const memorySetScope = findTool(chapterhouseTools, "memory_set_scope");
221
+ const memoryRemember = findTool(chapterhouseTools, "memory_remember");
222
+ const memoryHousekeep = findTool(chapterhouseTools, "memory_housekeep");
223
+ const coderHousekeep = findTool(coderTools, "memory_housekeep");
224
+ await memorySetScope.handler({ slug: "chapterhouse" }, {});
225
+ const remembered = await memoryRemember.handler({
226
+ content: "Very old low-confidence memory should be archived by housekeeping.",
227
+ scope: "chapterhouse",
228
+ kind: "observation",
229
+ }, {});
230
+ const observationId = remembered.id;
231
+ const db = dbModule.getDb();
232
+ db.prepare(`
233
+ UPDATE mem_observations
234
+ SET confidence = 0.1, created_at = datetime('now', '-31 days')
235
+ WHERE id = ?
236
+ `).run(observationId);
237
+ const denied = await coderHousekeep.handler({ passes: ["decay"] }, {});
238
+ assert.match(String(denied), /orchestrator-only/i);
239
+ const visibleToCoder = agentsModule.filterToolsForAgent({
240
+ slug: "coder",
241
+ name: "Coder",
242
+ description: "Software engineer",
243
+ model: "gpt-5.4",
244
+ systemMessage: "test",
245
+ }, tools);
246
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_housekeep"), false);
247
+ const result = await memoryHousekeep.handler({
248
+ scope_slug: "chapterhouse",
249
+ passes: ["decay"],
250
+ }, {});
251
+ assert.equal(result.ok, true);
252
+ assert.deepEqual(result.scope_ids.length, 1);
253
+ assert.equal(result.summaries[0]?.pass, "decayPass");
254
+ assert.equal(result.summaries[0]?.modified, 1);
255
+ });
256
+ test("memory_promote and memory_demote are orchestrator-only manual tier controls", async () => {
257
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
258
+ const tools = toolsModule.createTools({
259
+ client: { async listModels() { return []; } },
260
+ onAgentTaskComplete: () => { },
261
+ });
262
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
263
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
264
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
265
+ const coderTools = bindToolsToAgent("coder", tools);
266
+ const memoryRemember = findTool(chapterhouseTools, "memory_remember");
267
+ const memoryPromote = findTool(chapterhouseTools, "memory_promote");
268
+ const memoryDemote = findTool(chapterhouseTools, "memory_demote");
269
+ const coderPromote = findTool(coderTools, "memory_promote");
270
+ const coderDemote = findTool(coderTools, "memory_demote");
271
+ const remembered = await memoryRemember.handler({
272
+ content: "Manual tier controls should change this observation.",
273
+ scope: "chapterhouse",
274
+ kind: "observation",
275
+ }, {});
276
+ const observationId = remembered.id;
277
+ const db = dbModule.getDb();
278
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "warm");
279
+ const deniedPromote = await coderPromote.handler({ table: "observation", id: observationId, reason: "test" }, {});
280
+ const deniedDemote = await coderDemote.handler({ table: "observation", id: observationId, reason: "test" }, {});
281
+ assert.match(String(deniedPromote), /orchestrator-only/i);
282
+ assert.match(String(deniedDemote), /orchestrator-only/i);
283
+ assert.equal(agentsModule.filterToolsForAgent({
284
+ slug: "coder",
285
+ name: "Coder",
286
+ description: "Software engineer",
287
+ model: "gpt-5.4",
288
+ systemMessage: "test",
289
+ }, tools).some((tool) => tool.name === "memory_promote" || tool.name === "memory_demote"), false);
290
+ const promoted = await memoryPromote.handler({ table: "observation", id: observationId, reason: "actively relevant" }, {});
291
+ assert.equal(promoted.ok, true);
292
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "hot");
293
+ const demoted = await memoryDemote.handler({ table: "observation", id: observationId, reason: "no longer active", tier: "cold" }, {});
294
+ assert.equal(demoted.ok, true);
295
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "cold");
296
+ });
297
+ //# sourceMappingURL=tools.memory.test.js.map
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ test("SESSION_BUFFER_CAPACITY respects CHAPTERHOUSE_SSE_BUFFER_CAPACITY", async () => {
4
+ const previous = process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
5
+ process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
6
+ try {
7
+ const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
8
+ assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
9
+ }
10
+ finally {
11
+ if (previous === undefined) {
12
+ delete process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
13
+ }
14
+ else {
15
+ process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = previous;
16
+ }
17
+ }
18
+ });
19
+ //# sourceMappingURL=turn-event-log-env.test.js.map
@@ -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 (200 events) feeds the SSE reconnect path.
7
- * - On turn completion, events are persisted to the `turn_events` SQLite table.
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)` — write ring buffer to SQLite
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 = 200;
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 the turn's buffered events to the `turn_events` SQLite table.
186
- * Called on `turn:complete` and `turn:error` before scheduling the clear.
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
- export function persistTurnEvents(turnId, sessionKey) {
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 OR IGNORE INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
195
+ const stmt = db.prepare(`INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
199
196
  VALUES (?, ?, ?, ?, ?, ?)`);
200
- db.transaction(() => {
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 500`)
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
- return `session-test-${++sessionCounter}-${Date.now()}`;
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