chapterhouse 0.5.1 → 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.
Files changed (41) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/server.js +5 -3
  4. package/dist/cli.js +4 -2
  5. package/dist/config.js +75 -13
  6. package/dist/config.test.js +73 -0
  7. package/dist/copilot/memory-coordinator.js +234 -0
  8. package/dist/copilot/memory-coordinator.test.js +257 -0
  9. package/dist/copilot/orchestrator.js +31 -212
  10. package/dist/copilot/orchestrator.test.js +111 -0
  11. package/dist/copilot/pr-title.js +92 -0
  12. package/dist/copilot/pr-title.test.js +54 -0
  13. package/dist/copilot/router.js +43 -8
  14. package/dist/copilot/router.test.js +60 -18
  15. package/dist/copilot/threat-model.js +50 -0
  16. package/dist/copilot/threat-model.test.js +129 -0
  17. package/dist/copilot/tools.js +65 -39
  18. package/dist/copilot/tools.wiki.test.js +15 -6
  19. package/dist/daemon.js +7 -2
  20. package/dist/integrations/team-push.js +8 -1
  21. package/dist/integrations/teams-notify.js +8 -1
  22. package/dist/memory/housekeeping.js +73 -25
  23. package/dist/memory/housekeeping.test.js +95 -3
  24. package/dist/memory/inbox.test.js +178 -0
  25. package/dist/memory/tiering.test.js +323 -0
  26. package/dist/mode-context.js +28 -0
  27. package/dist/mode-context.test.js +42 -0
  28. package/dist/setup.js +162 -95
  29. package/dist/setup.test.js +139 -0
  30. package/dist/sprint-merge.js +168 -0
  31. package/dist/sprint-merge.test.js +131 -0
  32. package/dist/store/db.js +63 -0
  33. package/dist/store/db.test.js +279 -0
  34. package/dist/wiki/team-sync.js +8 -1
  35. package/package.json +6 -1
  36. package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
  37. package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
  38. package/web/dist/assets/index-DknKAtDS.css +10 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
  41. package/web/dist/assets/index-_O6AoWOS.css +0 -10
@@ -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
@@ -3,13 +3,9 @@ import { randomUUID } from "node:crypto";
3
3
  import { approveAll } from "@github/copilot-sdk";
4
4
  import { createTools } from "./tools.js";
5
5
  import { getOrchestratorSystemMessage } from "./system-message.js";
6
- import { renderHotTierForActiveScope } from "../memory/hot-tier.js";
7
- import { getHotTierEntries, renderHotTierXML } from "../memory/hot-tier.js";
8
6
  import { getActiveScope, withActiveScope } from "../memory/active-scope.js";
9
7
  import { getScope } from "../memory/scopes.js";
10
- import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
11
- import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
12
- import { runEndOfTaskMemoryHook } from "../memory/eot.js";
8
+ import { MemoryCoordinator } from "./memory-coordinator.js";
13
9
  import { CHAPTERHOUSE_VERSION } from "../version.js";
14
10
  import { config, DEFAULT_MODEL } from "../config.js";
15
11
  import { loadMcpConfig } from "./mcp-config.js";
@@ -77,160 +73,15 @@ let currentUserContext;
77
73
  let currentAuthenticatedUser;
78
74
  let currentAuthorizationHeader;
79
75
  let lastRouteResult;
80
- const checkpointTrackers = new Map();
81
- const checkpointTurnsBySession = new Map();
82
- const housekeepingTurnsBySession = new Map();
83
- const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
76
+ let memoryCoordinator;
84
77
  export function getLastRouteResult() {
85
78
  return lastRouteResult;
86
79
  }
87
- function truncateCheckpointText(value) {
88
- const trimmed = value.trim();
89
- if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
90
- return trimmed;
91
- }
92
- return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
93
- }
94
- function getCheckpointTracker(sessionKey) {
95
- let tracker = checkpointTrackers.get(sessionKey);
96
- if (!tracker) {
97
- tracker = new CheckpointTracker();
98
- checkpointTrackers.set(sessionKey, tracker);
99
- }
100
- return tracker;
101
- }
102
80
  export function resetCheckpointSessionState(sessionKey) {
103
- getCheckpointTracker(sessionKey).reset();
104
- checkpointTurnsBySession.delete(sessionKey);
105
- housekeepingTurnsBySession.delete(sessionKey);
106
- }
107
- function appendCheckpointTurn(sessionKey, turn) {
108
- const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
109
- turns.push(turn);
110
- const overflow = turns.length - config.memoryCheckpointTurns;
111
- if (overflow > 0) {
112
- turns.splice(0, overflow);
113
- }
114
- checkpointTurnsBySession.set(sessionKey, turns);
115
- return turns;
116
- }
117
- function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source) {
118
- if (source.type === "background") {
119
- return;
120
- }
121
- const tracker = getCheckpointTracker(sessionKey);
122
- const turns = appendCheckpointTurn(sessionKey, {
123
- user: truncateCheckpointText(prompt),
124
- assistant: truncateCheckpointText(finalContent),
125
- });
126
- if (!config.memoryCheckpointEnabled) {
127
- log.info({ sessionKey }, "memory.checkpoint.disabled");
128
- return;
129
- }
130
- tracker.tickOrchestratorTurn();
131
- if (!tracker.shouldFire()) {
132
- return;
133
- }
134
- tracker.markFired();
135
- if (isCheckpointInFlight(sessionKey)) {
136
- log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
137
- return;
138
- }
139
- if (!copilotClient) {
140
- log.error({ sessionKey }, "memory.checkpoint.error");
141
- return;
142
- }
143
- const activeScope = getMemoryScopeForSession(sessionKey);
144
- void runCheckpointExtraction({
145
- sessionKey,
146
- turns: turns.slice(-config.memoryCheckpointTurns),
147
- activeScope,
148
- copilotClient,
149
- trigger: "cadence",
150
- }).catch((error) => {
151
- log.error({ err: error, sessionKey }, "memory.checkpoint.error");
152
- });
153
- }
154
- function scheduleHousekeeping(sessionKey, source) {
155
- if (source.type === "background") {
156
- return;
157
- }
158
- if (!config.memoryHousekeepingEnabled) {
159
- log.info({ sessionKey }, "memory.housekeeping.disabled");
160
- return;
161
- }
162
- const turns = (housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
163
- if (turns < config.memoryHousekeepingTurns) {
164
- housekeepingTurnsBySession.set(sessionKey, turns);
165
- return;
166
- }
167
- housekeepingTurnsBySession.set(sessionKey, 0);
168
- const activeScope = getMemoryScopeForSession(sessionKey);
169
- if (!activeScope) {
170
- log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
171
- return;
172
- }
173
- const scopeIds = [activeScope.id];
174
- if (isHousekeepingInFlight(scopeIds)) {
175
- log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
176
- return;
177
- }
178
- try {
179
- void runHousekeeping({ scopeIds });
180
- }
181
- catch (error) {
182
- log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
183
- }
81
+ memoryCoordinator?.reset(sessionKey);
184
82
  }
185
83
  export function maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope) {
186
- if (!previousScope) {
187
- return;
188
- }
189
- if (!config.memoryCheckpointOnScopeChange) {
190
- log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
191
- return;
192
- }
193
- const tracker = getCheckpointTracker(sessionKey);
194
- const turnsSinceLast = tracker.turnsSinceLastFire();
195
- if (turnsSinceLast < config.memoryCheckpointMinTurnsForScopeFire) {
196
- log.info({
197
- sessionKey,
198
- scope: previousScope.slug,
199
- turns_since_last: turnsSinceLast,
200
- min_required: config.memoryCheckpointMinTurnsForScopeFire,
201
- }, "memory.checkpoint.scope_change_skip");
202
- return;
203
- }
204
- if (isCheckpointInFlight(sessionKey)) {
205
- log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
206
- return;
207
- }
208
- if (!copilotClient) {
209
- log.error({ sessionKey }, "memory.checkpoint.error");
210
- return;
211
- }
212
- const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
213
- if (turns.length === 0) {
214
- log.info({
215
- sessionKey,
216
- scope: previousScope.slug,
217
- turns_since_last: turnsSinceLast,
218
- min_required: config.memoryCheckpointMinTurnsForScopeFire,
219
- }, "memory.checkpoint.scope_change_skip");
220
- return;
221
- }
222
- tracker.markScopeChangeFire();
223
- void runCheckpointExtraction({
224
- sessionKey,
225
- turns: turns.slice(-config.memoryCheckpointTurns),
226
- activeScope: previousScope,
227
- copilotClient,
228
- trigger: "scope_change",
229
- scopeChangeContext: {
230
- from: previousScope.slug,
231
- to: nextScope?.slug ?? "no active scope",
232
- },
233
- }).catch((error) => {
84
+ void memoryCoordinator?.onScopeChange(sessionKey, previousScope?.slug ?? "", nextScope?.slug ?? "").catch((error) => {
234
85
  log.error({ err: error, sessionKey }, "memory.checkpoint.error");
235
86
  });
236
87
  }
@@ -342,39 +193,11 @@ function getMemoryScopeForSession(sessionKey) {
342
193
  }
343
194
  return getActiveScope();
344
195
  }
345
- function buildScopedHotTierContext(scope) {
346
- if (!config.memoryInjectEnabled || !scope) {
347
- return undefined;
348
- }
349
- const hotTierXml = renderHotTierXML(getHotTierEntries(scope.id));
350
- return hotTierXml ? hotTierXml.trimEnd() : undefined;
351
- }
352
- function buildHotTierContext() {
353
- if (!config.memoryInjectEnabled) {
354
- return undefined;
355
- }
356
- const hotTierXml = renderHotTierForActiveScope();
357
- if (!hotTierXml) {
358
- return undefined;
359
- }
360
- return hotTierXml.trimEnd();
361
- }
362
- function buildPerTurnMemoryHooks(sessionKey) {
363
- if (!config.memoryInjectEnabled) {
364
- return undefined;
365
- }
366
- return {
367
- onUserPromptSubmitted: () => {
368
- const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
369
- return hotTierXml ? { additionalContext: hotTierXml } : undefined;
370
- },
371
- };
372
- }
373
- function getSystemMessageOptions(memorySummary) {
196
+ function getSystemMessageOptions(memorySummary, hotTierXml) {
374
197
  return {
375
198
  selfEditEnabled: config.selfEditEnabled,
376
199
  memorySummary: memorySummary || undefined,
377
- hotTierXml: buildHotTierContext(),
200
+ hotTierXml,
378
201
  agentRoster: buildAgentRoster(),
379
202
  userContext: currentUserContext,
380
203
  };
@@ -410,15 +233,9 @@ function updateRequestContext(source) {
410
233
  }
411
234
  }
412
235
  export function feedAgentResult(taskId, agentSlug, result) {
413
- if (copilotClient) {
414
- void runEndOfTaskMemoryHook({
415
- taskId,
416
- finalResult: result,
417
- copilotClient,
418
- }).catch((error) => {
419
- log.error({ err: error, taskId }, "memory.eot.error");
420
- });
421
- }
236
+ void memoryCoordinator?.onAgentTaskComplete(taskId, result).catch((error) => {
237
+ log.error({ err: error, taskId }, "memory.eot.error");
238
+ });
422
239
  const sessionKey = getTaskSessionKey(taskId) || "default";
423
240
  const agentTurnId = randomUUID();
424
241
  const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
@@ -512,13 +329,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
512
329
  let { tools, mcpServers, skillDirectories } = baseConfig;
513
330
  const isProjectSession = sessionKey.startsWith("project:");
514
331
  const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
515
- const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
516
332
  const infiniteSessions = {
517
333
  enabled: true,
518
334
  backgroundCompactionThreshold: 0.80,
519
335
  bufferExhaustionThreshold: 0.95,
520
336
  };
521
- const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
337
+ const memoryHooks = memoryCoordinator?.buildPerTurnHooks(sessionKey);
522
338
  let model = config.copilotModel;
523
339
  let systemMessageContent;
524
340
  let sessionMode = isProjectSession ? "project" : "default";
@@ -528,7 +344,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
528
344
  client: copilotClient,
529
345
  onAgentTaskComplete: feedAgentResult,
530
346
  })));
531
- const scopedHotTier = buildScopedHotTierContext(agentScope);
347
+ const scopedHotTier = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
532
348
  const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
533
349
  systemMessageContent = [
534
350
  composeAgentSystemMessage(persistentAgent),
@@ -539,8 +355,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
539
355
  }
540
356
  else {
541
357
  const memorySummary = getWikiSummary();
358
+ const hotTierXml = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
542
359
  systemMessageContent = getOrchestratorSystemMessage({
543
- ...getSystemMessageOptions(memorySummary),
360
+ ...getSystemMessageOptions(memorySummary, hotTierXml),
544
361
  version: CHAPTERHOUSE_VERSION,
545
362
  });
546
363
  }
@@ -562,7 +379,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
562
379
  infiniteSessions,
563
380
  });
564
381
  log.info({ sessionKey }, "Session resumed successfully");
565
- resetCheckpointSessionState(sessionKey);
382
+ memoryCoordinator?.reset(sessionKey);
566
383
  upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
567
384
  const mgr = registry?.get(sessionKey);
568
385
  if (mgr)
@@ -589,7 +406,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
589
406
  infiniteSessions,
590
407
  });
591
408
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
592
- resetCheckpointSessionState(sessionKey);
409
+ memoryCoordinator?.reset(sessionKey);
593
410
  upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
594
411
  if (sessionKey === "default")
595
412
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
@@ -600,6 +417,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
600
417
  }
601
418
  export async function initOrchestrator(client) {
602
419
  copilotClient = client;
420
+ memoryCoordinator?.shutdown();
421
+ memoryCoordinator = new MemoryCoordinator({
422
+ getCopilotClient: () => copilotClient,
423
+ config,
424
+ resolveScopeForSession: getMemoryScopeForSession,
425
+ });
603
426
  // Initialize per-task ring buffer — subscribes to agentEventBus for session:tool_call events.
604
427
  initTaskEventLog();
605
428
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
@@ -896,12 +719,8 @@ async function executeOnSession(manager, item) {
896
719
  spawnArgsMap.delete(taskId);
897
720
  activeSubagentTaskIds.delete(taskId);
898
721
  db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
899
- if (copilotClient && finalResult) {
900
- void runEndOfTaskMemoryHook({
901
- taskId,
902
- finalResult,
903
- copilotClient,
904
- }).catch((error) => {
722
+ if (finalResult) {
723
+ void memoryCoordinator?.onAgentTaskComplete(taskId, finalResult).catch((error) => {
905
724
  log.error({ err: error, taskId }, "memory.eot.error");
906
725
  });
907
726
  }
@@ -1252,8 +1071,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1252
1071
  logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
1253
1072
  }
1254
1073
  catch { /* best-effort */ }
1255
- scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
1256
- scheduleHousekeeping(sessionKey, source);
1074
+ void memoryCoordinator?.onTurnComplete(sessionKey, prompt, finalContent, source.type).catch((error) => {
1075
+ log.error({ err: error, sessionKey }, "memory.turn_complete.error");
1076
+ });
1257
1077
  if (copilotClient) {
1258
1078
  maybeWriteEpisode(copilotClient).catch((err) => {
1259
1079
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -1353,8 +1173,9 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1353
1173
  logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1354
1174
  }
1355
1175
  catch { /* best-effort */ }
1356
- scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
1357
- scheduleHousekeeping(sessionKey, source);
1176
+ void memoryCoordinator?.onTurnComplete(sessionKey, newPrompt, finalContent, source.type).catch((error) => {
1177
+ log.error({ err: error, sessionKey }, "memory.turn_complete.error");
1178
+ });
1358
1179
  if (copilotClient) {
1359
1180
  maybeWriteEpisode(copilotClient).catch((err) => {
1360
1181
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -1494,15 +1315,13 @@ export function getAgentInfo() {
1494
1315
  }
1495
1316
  /** Clean up on shutdown/restart. */
1496
1317
  export async function shutdownAgents() {
1318
+ memoryCoordinator?.shutdown();
1319
+ memoryCoordinator = undefined;
1497
1320
  if (!registry) {
1498
- checkpointTrackers.clear();
1499
- checkpointTurnsBySession.clear();
1500
1321
  await clearActiveTasks();
1501
1322
  return;
1502
1323
  }
1503
1324
  await registry.shutdown();
1504
- checkpointTrackers.clear();
1505
- checkpointTurnsBySession.clear();
1506
1325
  await clearActiveTasks();
1507
1326
  }
1508
1327
  //# sourceMappingURL=orchestrator.js.map