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.
- package/dist/api/server.js +12 -0
- package/dist/api/server.test.js +39 -0
- package/dist/config.js +70 -0
- package/dist/config.test.js +109 -0
- package/dist/copilot/agents.js +32 -6
- package/dist/copilot/agents.test.js +41 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +224 -3
- package/dist/copilot/orchestrator.test.js +380 -0
- package/dist/copilot/prompt-date.js +8 -0
- package/dist/copilot/system-message.js +8 -0
- package/dist/copilot/system-message.test.js +58 -0
- package/dist/copilot/tools.agent.test.js +24 -0
- package/dist/copilot/tools.js +351 -4
- package/dist/copilot/tools.memory.test.js +297 -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 +210 -0
- package/dist/memory/recall.test.js +238 -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 +412 -8
- package/dist/store/db.test.js +83 -0
- package/dist/test/setup-env.js +16 -0
- package/dist/test/setup-env.test.js +4 -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
|
@@ -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
|
|
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
|