chapterhouse 0.5.2 → 0.6.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/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/config.test.js +29 -0
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +31 -209
- package/dist/copilot/orchestrator.test.js +111 -0
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.test.js +30 -0
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +61 -37
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/setup.js +15 -5
- package/dist/setup.test.js +20 -3
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/package.json +6 -1
- package/web/dist/assets/{index-CPaILy2j.js → index-B5oDsQ5y.js} +84 -84
- package/web/dist/assets/{index-CPaILy2j.js.map → index-B5oDsQ5y.js.map} +1 -1
- package/web/dist/assets/index-DknKAtDS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Cs7AGeaL.css +0 -10
package/.pr-types.json
ADDED
package/README.md
CHANGED
|
@@ -91,6 +91,12 @@ cd ~/.chapterhouse/src
|
|
|
91
91
|
npm install && npm run build && npm link
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
If you're opening a sprint PR from the repo, validate the title first:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm run pr:title:check -- "chore: add PR title preflight validation"
|
|
98
|
+
```
|
|
99
|
+
|
|
94
100
|
## Upgrading
|
|
95
101
|
|
|
96
102
|
```bash
|
package/dist/config.test.js
CHANGED
|
@@ -271,7 +271,19 @@ test("rejects invalid SSE replay settings", async () => {
|
|
|
271
271
|
CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
|
|
272
272
|
}), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
|
|
273
273
|
});
|
|
274
|
+
test("personal mode starts cleanly without Entra env vars", async () => {
|
|
275
|
+
// Security: personal mode should boot in its intended unauthenticated configuration instead of requiring enterprise auth by accident.
|
|
276
|
+
const configModule = await import("./config.js");
|
|
277
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
278
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
279
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
280
|
+
});
|
|
281
|
+
assert.equal(parsed.chapterhouseMode, "personal");
|
|
282
|
+
assert.equal(parsed.entraAuthEnabled, false);
|
|
283
|
+
assert.doesNotMatch(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
|
|
284
|
+
});
|
|
274
285
|
test("personal mode rejects explicit Entra auth enablement with a clear fix suggestion", async () => {
|
|
286
|
+
// Security: personal deployments must fail fast instead of starting in a broken auth state that looks protected but is not.
|
|
275
287
|
const configModule = await import("./config.js");
|
|
276
288
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
277
289
|
assert.throws(() => configModule.parseRuntimeConfig({
|
|
@@ -281,7 +293,23 @@ test("personal mode rejects explicit Entra auth enablement with a clear fix sugg
|
|
|
281
293
|
ENTRA_CLIENT_ID: "client-id",
|
|
282
294
|
}), /Personal mode cannot be used with ENTRA_AUTH_ENABLED=true[\s\S]*CHAPTERHOUSE_MODE=team[\s\S]*unset ENTRA_AUTH_ENABLED/);
|
|
283
295
|
});
|
|
296
|
+
test("team mode accepts Entra auth when required settings are present", async () => {
|
|
297
|
+
// Security: team mode is the only safe place for Entra auth, so valid enterprise settings must remain supported there.
|
|
298
|
+
const configModule = await import("./config.js");
|
|
299
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
300
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
301
|
+
CHAPTERHOUSE_MODE: "team",
|
|
302
|
+
ENTRA_AUTH_ENABLED: "true",
|
|
303
|
+
ENTRA_TENANT_ID: "tenant-id",
|
|
304
|
+
ENTRA_CLIENT_ID: "client-id",
|
|
305
|
+
});
|
|
306
|
+
assert.equal(parsed.chapterhouseMode, "team");
|
|
307
|
+
assert.equal(parsed.entraAuthEnabled, true);
|
|
308
|
+
assert.equal(parsed.entraTenantId, "tenant-id");
|
|
309
|
+
assert.equal(parsed.entraClientId, "client-id");
|
|
310
|
+
});
|
|
284
311
|
test("personal mode still warns about leftover Entra settings when auth is not explicitly enabled", async () => {
|
|
312
|
+
// Security: leftover enterprise env vars should be called out so personal-mode operators know those settings are being ignored.
|
|
285
313
|
const configModule = await import("./config.js");
|
|
286
314
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
287
315
|
const parsed = configModule.parseRuntimeConfig({
|
|
@@ -293,6 +321,7 @@ test("personal mode still warns about leftover Entra settings when auth is not e
|
|
|
293
321
|
assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
|
|
294
322
|
});
|
|
295
323
|
test("personal mode warns and disables incomplete Teams notification settings instead of throwing", async () => {
|
|
324
|
+
// Security: personal mode should ignore team-only integrations without partially enabling cross-tenant notification flows.
|
|
296
325
|
const configModule = await import("./config.js");
|
|
297
326
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
298
327
|
const parsed = configModule.parseRuntimeConfig({
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { getActiveScope } from "../memory/active-scope.js";
|
|
2
|
+
import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
|
|
3
|
+
import { runEndOfTaskMemoryHook } from "../memory/eot.js";
|
|
4
|
+
import { getHotTierEntries, renderHotTierForActiveScope, renderHotTierXML } from "../memory/hot-tier.js";
|
|
5
|
+
import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
|
|
6
|
+
import { getScope } from "../memory/scopes.js";
|
|
7
|
+
import { config as defaultConfig } from "../config.js";
|
|
8
|
+
import { childLogger } from "../util/logger.js";
|
|
9
|
+
const log = childLogger("memory-coordinator");
|
|
10
|
+
const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
|
|
11
|
+
export class MemoryCoordinator {
|
|
12
|
+
checkpointTrackers = new Map();
|
|
13
|
+
checkpointTurnsBySession = new Map();
|
|
14
|
+
housekeepingTurnsBySession = new Map();
|
|
15
|
+
completedTaskIds = new Set();
|
|
16
|
+
getCopilotClient;
|
|
17
|
+
resolveScopeForSession;
|
|
18
|
+
config;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.getCopilotClient = options.getCopilotClient;
|
|
21
|
+
this.resolveScopeForSession = options.resolveScopeForSession ?? (() => getActiveScope());
|
|
22
|
+
this.config = options.config ?? defaultConfig;
|
|
23
|
+
}
|
|
24
|
+
async onTurnComplete(sessionKey, prompt, response, source) {
|
|
25
|
+
const sourceType = this.normalizeSource(source);
|
|
26
|
+
if (sourceType === "background") {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this.scheduleCheckpointExtraction(sessionKey, prompt, response);
|
|
30
|
+
this.scheduleHousekeeping(sessionKey);
|
|
31
|
+
}
|
|
32
|
+
async onScopeChange(sessionKey, prev, next) {
|
|
33
|
+
if (!prev) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const previousScope = getScope(prev) ?? null;
|
|
37
|
+
if (!previousScope) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!this.config.memoryCheckpointOnScopeChange) {
|
|
41
|
+
log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const tracker = this.getCheckpointTracker(sessionKey);
|
|
45
|
+
const turnsSinceLast = tracker.turnsSinceLastFire();
|
|
46
|
+
if (turnsSinceLast < this.config.memoryCheckpointMinTurnsForScopeFire) {
|
|
47
|
+
log.info({
|
|
48
|
+
sessionKey,
|
|
49
|
+
scope: previousScope.slug,
|
|
50
|
+
turns_since_last: turnsSinceLast,
|
|
51
|
+
min_required: this.config.memoryCheckpointMinTurnsForScopeFire,
|
|
52
|
+
}, "memory.checkpoint.scope_change_skip");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (isCheckpointInFlight(sessionKey)) {
|
|
56
|
+
log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const copilotClient = this.getCopilotClient();
|
|
60
|
+
if (!copilotClient) {
|
|
61
|
+
log.error({ sessionKey }, "memory.checkpoint.error");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
65
|
+
if (turns.length === 0) {
|
|
66
|
+
log.info({
|
|
67
|
+
sessionKey,
|
|
68
|
+
scope: previousScope.slug,
|
|
69
|
+
turns_since_last: turnsSinceLast,
|
|
70
|
+
min_required: this.config.memoryCheckpointMinTurnsForScopeFire,
|
|
71
|
+
}, "memory.checkpoint.scope_change_skip");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
tracker.markScopeChangeFire();
|
|
75
|
+
const nextScope = next ? (getScope(next) ?? null) : null;
|
|
76
|
+
void runCheckpointExtraction({
|
|
77
|
+
sessionKey,
|
|
78
|
+
turns: turns.slice(-this.config.memoryCheckpointTurns),
|
|
79
|
+
activeScope: previousScope,
|
|
80
|
+
copilotClient,
|
|
81
|
+
trigger: "scope_change",
|
|
82
|
+
scopeChangeContext: {
|
|
83
|
+
from: previousScope.slug,
|
|
84
|
+
to: nextScope?.slug ?? "no active scope",
|
|
85
|
+
},
|
|
86
|
+
}).catch((error) => {
|
|
87
|
+
log.error({ err: error, sessionKey }, "memory.checkpoint.error");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async buildHotTierContext(sessionKey) {
|
|
91
|
+
if (!this.config.memoryInjectEnabled) {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
const scope = this.resolveScopeForSession(sessionKey);
|
|
95
|
+
if (!scope) {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
const activeScope = getActiveScope();
|
|
99
|
+
const hotTierXml = activeScope?.id === scope.id
|
|
100
|
+
? renderHotTierForActiveScope()
|
|
101
|
+
: renderHotTierXML(getHotTierEntries(scope.id));
|
|
102
|
+
return hotTierXml ? hotTierXml.trimEnd() : "";
|
|
103
|
+
}
|
|
104
|
+
buildPerTurnHooks(sessionKey) {
|
|
105
|
+
if (!this.config.memoryInjectEnabled) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const hooks = {
|
|
109
|
+
onUserPromptSubmitted: async () => {
|
|
110
|
+
const hotTierXml = await this.buildHotTierContext(sessionKey);
|
|
111
|
+
return hotTierXml ? { additionalContext: hotTierXml } : undefined;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
return hooks;
|
|
115
|
+
}
|
|
116
|
+
async onAgentTaskComplete(taskId, result) {
|
|
117
|
+
if (this.completedTaskIds.has(taskId)) {
|
|
118
|
+
log.info({ taskId }, "memory.eot.duplicate_skip");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.completedTaskIds.add(taskId);
|
|
122
|
+
const copilotClient = this.getCopilotClient();
|
|
123
|
+
if (!copilotClient) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const finalResult = typeof result === "string" ? result : result == null ? "" : String(result);
|
|
127
|
+
await runEndOfTaskMemoryHook({
|
|
128
|
+
taskId,
|
|
129
|
+
finalResult,
|
|
130
|
+
copilotClient,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
reset(sessionKey) {
|
|
134
|
+
this.getCheckpointTracker(sessionKey).reset();
|
|
135
|
+
this.checkpointTurnsBySession.delete(sessionKey);
|
|
136
|
+
this.housekeepingTurnsBySession.delete(sessionKey);
|
|
137
|
+
this.completedTaskIds.clear();
|
|
138
|
+
}
|
|
139
|
+
shutdown() {
|
|
140
|
+
this.checkpointTrackers.clear();
|
|
141
|
+
this.checkpointTurnsBySession.clear();
|
|
142
|
+
this.housekeepingTurnsBySession.clear();
|
|
143
|
+
this.completedTaskIds.clear();
|
|
144
|
+
}
|
|
145
|
+
normalizeSource(source) {
|
|
146
|
+
return source === "background" ? "background" : source === "sse-web" ? "sse-web" : "web";
|
|
147
|
+
}
|
|
148
|
+
truncateCheckpointText(value) {
|
|
149
|
+
const trimmed = value.trim();
|
|
150
|
+
if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
|
|
151
|
+
return trimmed;
|
|
152
|
+
}
|
|
153
|
+
return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
|
|
154
|
+
}
|
|
155
|
+
getCheckpointTracker(sessionKey) {
|
|
156
|
+
let tracker = this.checkpointTrackers.get(sessionKey);
|
|
157
|
+
if (!tracker) {
|
|
158
|
+
tracker = new CheckpointTracker();
|
|
159
|
+
this.checkpointTrackers.set(sessionKey, tracker);
|
|
160
|
+
}
|
|
161
|
+
return tracker;
|
|
162
|
+
}
|
|
163
|
+
appendCheckpointTurn(sessionKey, turn) {
|
|
164
|
+
const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
165
|
+
turns.push(turn);
|
|
166
|
+
const overflow = turns.length - this.config.memoryCheckpointTurns;
|
|
167
|
+
if (overflow > 0) {
|
|
168
|
+
turns.splice(0, overflow);
|
|
169
|
+
}
|
|
170
|
+
this.checkpointTurnsBySession.set(sessionKey, turns);
|
|
171
|
+
return turns;
|
|
172
|
+
}
|
|
173
|
+
scheduleCheckpointExtraction(sessionKey, prompt, response) {
|
|
174
|
+
const tracker = this.getCheckpointTracker(sessionKey);
|
|
175
|
+
const turns = this.appendCheckpointTurn(sessionKey, {
|
|
176
|
+
user: this.truncateCheckpointText(prompt),
|
|
177
|
+
assistant: this.truncateCheckpointText(response),
|
|
178
|
+
});
|
|
179
|
+
if (!this.config.memoryCheckpointEnabled) {
|
|
180
|
+
log.info({ sessionKey }, "memory.checkpoint.disabled");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
tracker.tickOrchestratorTurn();
|
|
184
|
+
if (!tracker.shouldFire()) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
tracker.markFired();
|
|
188
|
+
if (isCheckpointInFlight(sessionKey)) {
|
|
189
|
+
log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const copilotClient = this.getCopilotClient();
|
|
193
|
+
if (!copilotClient) {
|
|
194
|
+
log.error({ sessionKey }, "memory.checkpoint.error");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const activeScope = this.resolveScopeForSession(sessionKey);
|
|
198
|
+
void runCheckpointExtraction({
|
|
199
|
+
sessionKey,
|
|
200
|
+
turns: turns.slice(-this.config.memoryCheckpointTurns),
|
|
201
|
+
activeScope,
|
|
202
|
+
copilotClient,
|
|
203
|
+
trigger: "cadence",
|
|
204
|
+
}).catch((error) => {
|
|
205
|
+
log.error({ err: error, sessionKey }, "memory.checkpoint.error");
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
scheduleHousekeeping(sessionKey) {
|
|
209
|
+
if (!this.config.memoryHousekeepingEnabled) {
|
|
210
|
+
log.info({ sessionKey }, "memory.housekeeping.disabled");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const turns = (this.housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
|
|
214
|
+
if (turns < this.config.memoryHousekeepingTurns) {
|
|
215
|
+
this.housekeepingTurnsBySession.set(sessionKey, turns);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.housekeepingTurnsBySession.set(sessionKey, 0);
|
|
219
|
+
const activeScope = this.resolveScopeForSession(sessionKey);
|
|
220
|
+
if (!activeScope) {
|
|
221
|
+
log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const scopeIds = [activeScope.id];
|
|
225
|
+
if (isHousekeepingInFlight(scopeIds)) {
|
|
226
|
+
log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
void runHousekeeping({ scopeIds }).catch((error) => {
|
|
230
|
+
log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=memory-coordinator.js.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
function makeScope(id, slug, title = slug, description = `${slug} scope`) {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
slug,
|
|
7
|
+
title,
|
|
8
|
+
description,
|
|
9
|
+
keywords: [],
|
|
10
|
+
active: true,
|
|
11
|
+
createdAt: "2026-05-14T00:00:00.000Z",
|
|
12
|
+
updatedAt: "2026-05-14T00:00:00.000Z",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function loadMemoryCoordinatorModule(t, overrides = {}) {
|
|
16
|
+
const chapterhouseScope = makeScope(1, "chapterhouse", "Chapterhouse", "Core Chapterhouse work.");
|
|
17
|
+
const wikiScope = makeScope(2, "wiki", "Wiki", "Wiki work.");
|
|
18
|
+
const state = {
|
|
19
|
+
config: {
|
|
20
|
+
copilotModel: "claude-sonnet-4.6",
|
|
21
|
+
memoryInjectEnabled: true,
|
|
22
|
+
memoryCheckpointEnabled: true,
|
|
23
|
+
memoryCheckpointTurns: 2,
|
|
24
|
+
memoryCheckpointOnScopeChange: true,
|
|
25
|
+
memoryCheckpointMinTurnsForScopeFire: 2,
|
|
26
|
+
memoryHousekeepingEnabled: true,
|
|
27
|
+
memoryHousekeepingTurns: 2,
|
|
28
|
+
},
|
|
29
|
+
activeScope: chapterhouseScope,
|
|
30
|
+
scopes: new Map([
|
|
31
|
+
[chapterhouseScope.slug, chapterhouseScope],
|
|
32
|
+
[wikiScope.slug, wikiScope],
|
|
33
|
+
]),
|
|
34
|
+
sessionScopes: new Map([["default", chapterhouseScope]]),
|
|
35
|
+
hotTierXmlByScope: new Map([[chapterhouseScope.slug, "<memory_context scope=\"chapterhouse\">\n <decision>Ship it</decision>\n</memory_context>\n"]]),
|
|
36
|
+
checkpointRuns: [],
|
|
37
|
+
checkpointInFlight: false,
|
|
38
|
+
checkpointTickCalls: 0,
|
|
39
|
+
checkpointMarkFiredCalls: 0,
|
|
40
|
+
checkpointMarkScopeChangeFireCalls: 0,
|
|
41
|
+
checkpointResetCalls: 0,
|
|
42
|
+
housekeepingRuns: [],
|
|
43
|
+
housekeepingInFlight: false,
|
|
44
|
+
eotCalls: [],
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
t.mock.module("../config.js", {
|
|
48
|
+
namedExports: {
|
|
49
|
+
config: state.config,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
t.mock.module("../memory/active-scope.js", {
|
|
53
|
+
namedExports: {
|
|
54
|
+
getActiveScope: () => state.activeScope,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
t.mock.module("../memory/scopes.js", {
|
|
58
|
+
namedExports: {
|
|
59
|
+
getScope: (slug) => state.scopes.get(slug) ?? null,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
t.mock.module("../memory/hot-tier.js", {
|
|
63
|
+
namedExports: {
|
|
64
|
+
getHotTierEntries: (scopeId) => {
|
|
65
|
+
const scope = Array.from(state.scopes.values()).find((candidate) => candidate.id === scopeId) ?? null;
|
|
66
|
+
return { scope, entities: [], observations: [], decisions: [], actionItems: [] };
|
|
67
|
+
},
|
|
68
|
+
renderHotTierXML: (entries) => entries.scope ? (state.hotTierXmlByScope.get(entries.scope.slug) ?? "") : "",
|
|
69
|
+
renderHotTierForActiveScope: () => state.activeScope ? (state.hotTierXmlByScope.get(state.activeScope.slug) ?? "") : "",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
t.mock.module("../memory/checkpoint.js", {
|
|
73
|
+
namedExports: {
|
|
74
|
+
CheckpointTracker: class {
|
|
75
|
+
turns = 0;
|
|
76
|
+
tickOrchestratorTurn() {
|
|
77
|
+
state.checkpointTickCalls++;
|
|
78
|
+
this.turns++;
|
|
79
|
+
}
|
|
80
|
+
shouldFire() {
|
|
81
|
+
return this.turns >= state.config.memoryCheckpointTurns;
|
|
82
|
+
}
|
|
83
|
+
turnsSinceLastFire() {
|
|
84
|
+
return this.turns;
|
|
85
|
+
}
|
|
86
|
+
markFired() {
|
|
87
|
+
state.checkpointMarkFiredCalls++;
|
|
88
|
+
this.turns = 0;
|
|
89
|
+
}
|
|
90
|
+
markScopeChangeFire() {
|
|
91
|
+
state.checkpointMarkScopeChangeFireCalls++;
|
|
92
|
+
this.turns = 0;
|
|
93
|
+
}
|
|
94
|
+
reset() {
|
|
95
|
+
state.checkpointResetCalls++;
|
|
96
|
+
this.turns = 0;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
isCheckpointInFlight: () => state.checkpointInFlight,
|
|
100
|
+
runCheckpointExtraction: async (input) => {
|
|
101
|
+
state.checkpointRuns.push(input);
|
|
102
|
+
return { written: 0, skipped: 0, errors: [] };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
t.mock.module("../memory/housekeeping.js", {
|
|
107
|
+
namedExports: {
|
|
108
|
+
isHousekeepingInFlight: () => state.housekeepingInFlight,
|
|
109
|
+
runHousekeeping: async (input) => {
|
|
110
|
+
state.housekeepingRuns.push(input);
|
|
111
|
+
return { scopeIds: input.scopeIds ?? [], summaries: [], totalExamined: 0, totalModified: 0, durationMs: 0 };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
t.mock.module("../memory/eot.js", {
|
|
116
|
+
namedExports: {
|
|
117
|
+
runEndOfTaskMemoryHook: async (input) => {
|
|
118
|
+
state.eotCalls.push(input);
|
|
119
|
+
return {
|
|
120
|
+
task_id: String(input.taskId ?? "task"),
|
|
121
|
+
proposals_total: 0,
|
|
122
|
+
accepted: 0,
|
|
123
|
+
rejected: 0,
|
|
124
|
+
implicit_extracted: 0,
|
|
125
|
+
auto_accept: true,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const module = await import(new URL(`./memory-coordinator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
131
|
+
return { module, state };
|
|
132
|
+
}
|
|
133
|
+
test("buildHotTierContext and buildPerTurnHooks inject trimmed scoped hot-tier memory", async (t) => {
|
|
134
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
135
|
+
const client = { name: "mock-client" };
|
|
136
|
+
const coordinator = new module.MemoryCoordinator({
|
|
137
|
+
getCopilotClient: () => client,
|
|
138
|
+
config: state.config,
|
|
139
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
140
|
+
});
|
|
141
|
+
const hotTierContext = await coordinator.buildHotTierContext("default");
|
|
142
|
+
const hooks = coordinator.buildPerTurnHooks("default");
|
|
143
|
+
const hookResult = await hooks?.onUserPromptSubmitted?.();
|
|
144
|
+
assert.equal(hotTierContext, "<memory_context scope=\"chapterhouse\">\n <decision>Ship it</decision>\n</memory_context>");
|
|
145
|
+
assert.deepEqual(hookResult, {
|
|
146
|
+
additionalContext: "<memory_context scope=\"chapterhouse\">\n <decision>Ship it</decision>\n</memory_context>",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
test("onTurnComplete schedules checkpoint and housekeeping at the configured turn boundary", async (t) => {
|
|
150
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
151
|
+
const coordinator = new module.MemoryCoordinator({
|
|
152
|
+
getCopilotClient: () => ({ name: "mock-client" }),
|
|
153
|
+
config: state.config,
|
|
154
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
155
|
+
});
|
|
156
|
+
await coordinator.onTurnComplete("default", "First prompt", "First response", "web");
|
|
157
|
+
assert.equal(state.checkpointRuns.length, 0);
|
|
158
|
+
assert.equal(state.housekeepingRuns.length, 0);
|
|
159
|
+
await coordinator.onTurnComplete("default", "Second prompt", "Second response", "web");
|
|
160
|
+
assert.equal(state.checkpointMarkFiredCalls, 1);
|
|
161
|
+
assert.equal(state.checkpointRuns.length, 1);
|
|
162
|
+
assert.equal(state.housekeepingRuns.length, 1);
|
|
163
|
+
assert.deepEqual(state.housekeepingRuns[0]?.scopeIds, [1]);
|
|
164
|
+
assert.equal((state.checkpointRuns[0]?.activeScope).slug, "chapterhouse");
|
|
165
|
+
assert.deepEqual(state.checkpointRuns[0]?.turns, [
|
|
166
|
+
{ user: "First prompt", assistant: "First response" },
|
|
167
|
+
{ user: "Second prompt", assistant: "Second response" },
|
|
168
|
+
]);
|
|
169
|
+
await coordinator.onTurnComplete("default", "Background prompt", "Background response", "background");
|
|
170
|
+
assert.equal(state.checkpointRuns.length, 1);
|
|
171
|
+
assert.equal(state.housekeepingRuns.length, 1);
|
|
172
|
+
});
|
|
173
|
+
test("onScopeChange triggers checkpoint extraction for the previous scope", async (t) => {
|
|
174
|
+
const { module, state } = await loadMemoryCoordinatorModule(t, {
|
|
175
|
+
config: {
|
|
176
|
+
copilotModel: "claude-sonnet-4.6",
|
|
177
|
+
memoryInjectEnabled: true,
|
|
178
|
+
memoryCheckpointEnabled: true,
|
|
179
|
+
memoryCheckpointTurns: 5,
|
|
180
|
+
memoryCheckpointOnScopeChange: true,
|
|
181
|
+
memoryCheckpointMinTurnsForScopeFire: 2,
|
|
182
|
+
memoryHousekeepingEnabled: true,
|
|
183
|
+
memoryHousekeepingTurns: 2,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const coordinator = new module.MemoryCoordinator({
|
|
187
|
+
getCopilotClient: () => ({ name: "mock-client" }),
|
|
188
|
+
config: state.config,
|
|
189
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
190
|
+
});
|
|
191
|
+
await coordinator.onTurnComplete("default", "Turn one", "Reply one", "web");
|
|
192
|
+
await coordinator.onTurnComplete("default", "Turn two", "Reply two", "web");
|
|
193
|
+
state.checkpointRuns.length = 0;
|
|
194
|
+
await coordinator.onScopeChange("default", "chapterhouse", "wiki");
|
|
195
|
+
assert.equal(state.checkpointMarkScopeChangeFireCalls, 1);
|
|
196
|
+
assert.equal(state.checkpointRuns.length, 1);
|
|
197
|
+
assert.equal((state.checkpointRuns[0]?.activeScope).slug, "chapterhouse");
|
|
198
|
+
assert.equal(state.checkpointRuns[0]?.trigger, "scope_change");
|
|
199
|
+
assert.deepEqual(state.checkpointRuns[0]?.scopeChangeContext, { from: "chapterhouse", to: "wiki" });
|
|
200
|
+
});
|
|
201
|
+
test("onAgentTaskComplete runs the end-of-task memory hook with the mock client", async (t) => {
|
|
202
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
203
|
+
const client = { name: "mock-client" };
|
|
204
|
+
const coordinator = new module.MemoryCoordinator({
|
|
205
|
+
getCopilotClient: () => client,
|
|
206
|
+
config: state.config,
|
|
207
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
208
|
+
});
|
|
209
|
+
await coordinator.onAgentTaskComplete("task-42", "Final delegated result");
|
|
210
|
+
assert.equal(state.eotCalls.length, 1);
|
|
211
|
+
assert.equal(state.eotCalls[0]?.taskId, "task-42");
|
|
212
|
+
assert.equal(state.eotCalls[0]?.finalResult, "Final delegated result");
|
|
213
|
+
assert.equal(state.eotCalls[0]?.copilotClient, client);
|
|
214
|
+
});
|
|
215
|
+
test("onAgentTaskComplete is a no-op for a duplicate task ID (double-fire guard)", async (t) => {
|
|
216
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
217
|
+
const client = { name: "mock-client" };
|
|
218
|
+
const coordinator = new module.MemoryCoordinator({
|
|
219
|
+
getCopilotClient: () => client,
|
|
220
|
+
config: state.config,
|
|
221
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
222
|
+
});
|
|
223
|
+
await coordinator.onAgentTaskComplete("task-99", "result one");
|
|
224
|
+
await coordinator.onAgentTaskComplete("task-99", "result two");
|
|
225
|
+
assert.equal(state.eotCalls.length, 1);
|
|
226
|
+
assert.equal(state.eotCalls[0]?.taskId, "task-99");
|
|
227
|
+
assert.equal(state.eotCalls[0]?.finalResult, "result one");
|
|
228
|
+
});
|
|
229
|
+
test("reset clears turn buffers and tracker state for a session", async (t) => {
|
|
230
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
231
|
+
const coordinator = new module.MemoryCoordinator({
|
|
232
|
+
getCopilotClient: () => ({ name: "mock-client" }),
|
|
233
|
+
config: state.config,
|
|
234
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
235
|
+
});
|
|
236
|
+
await coordinator.onTurnComplete("default", "Turn one", "Reply one", "web");
|
|
237
|
+
coordinator.reset("default");
|
|
238
|
+
await coordinator.onTurnComplete("default", "Turn two", "Reply two", "web");
|
|
239
|
+
await coordinator.onScopeChange("default", "chapterhouse", "wiki");
|
|
240
|
+
assert.equal(state.checkpointResetCalls, 1);
|
|
241
|
+
assert.equal(state.checkpointRuns.length, 0);
|
|
242
|
+
assert.equal(state.housekeepingRuns.length, 0);
|
|
243
|
+
});
|
|
244
|
+
test("shutdown clears session state across all tracked maps", async (t) => {
|
|
245
|
+
const { module, state } = await loadMemoryCoordinatorModule(t);
|
|
246
|
+
const coordinator = new module.MemoryCoordinator({
|
|
247
|
+
getCopilotClient: () => ({ name: "mock-client" }),
|
|
248
|
+
config: state.config,
|
|
249
|
+
resolveScopeForSession: (sessionKey) => state.sessionScopes.get(sessionKey) ?? null,
|
|
250
|
+
});
|
|
251
|
+
await coordinator.onTurnComplete("default", "Turn one", "Reply one", "web");
|
|
252
|
+
coordinator.shutdown();
|
|
253
|
+
await coordinator.onTurnComplete("default", "Turn two", "Reply two", "web");
|
|
254
|
+
assert.equal(state.checkpointRuns.length, 0);
|
|
255
|
+
assert.equal(state.housekeepingRuns.length, 0);
|
|
256
|
+
});
|
|
257
|
+
//# sourceMappingURL=memory-coordinator.test.js.map
|