chapterhouse 0.3.26 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -3,6 +3,11 @@ 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 { getActiveScope } from "../memory/active-scope.js";
8
+ import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
9
+ import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
10
+ import { runEndOfTaskMemoryHook } from "../memory/eot.js";
6
11
  import { CHAPTERHOUSE_VERSION } from "../version.js";
7
12
  import { config, DEFAULT_MODEL } from "../config.js";
8
13
  import { loadMcpConfig } from "./mcp-config.js";
@@ -14,6 +19,7 @@ import { getWikiSummary } from "../wiki/context.js";
14
19
  import { SESSIONS_DIR } from "../paths.js";
15
20
  import { resolveModel } from "./router.js";
16
21
  import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
22
+ import * as agentsModule from "./agents.js";
17
23
  import { childLogger } from "../util/logger.js";
18
24
  import { agentEventBus } from "./agent-event-bus.js";
19
25
  import { initTaskEventLog } from "./task-event-log.js";
@@ -67,9 +73,163 @@ let currentUserContext;
67
73
  let currentAuthenticatedUser;
68
74
  let currentAuthorizationHeader;
69
75
  let lastRouteResult;
76
+ const checkpointTrackers = new Map();
77
+ const checkpointTurnsBySession = new Map();
78
+ const housekeepingTurnsBySession = new Map();
79
+ const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
70
80
  export function getLastRouteResult() {
71
81
  return lastRouteResult;
72
82
  }
83
+ function truncateCheckpointText(value) {
84
+ const trimmed = value.trim();
85
+ if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
86
+ return trimmed;
87
+ }
88
+ return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
89
+ }
90
+ function getCheckpointTracker(sessionKey) {
91
+ let tracker = checkpointTrackers.get(sessionKey);
92
+ if (!tracker) {
93
+ tracker = new CheckpointTracker();
94
+ checkpointTrackers.set(sessionKey, tracker);
95
+ }
96
+ return tracker;
97
+ }
98
+ export function resetCheckpointSessionState(sessionKey) {
99
+ getCheckpointTracker(sessionKey).reset();
100
+ checkpointTurnsBySession.delete(sessionKey);
101
+ housekeepingTurnsBySession.delete(sessionKey);
102
+ }
103
+ function appendCheckpointTurn(sessionKey, turn) {
104
+ const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
105
+ turns.push(turn);
106
+ const overflow = turns.length - config.memoryCheckpointTurns;
107
+ if (overflow > 0) {
108
+ turns.splice(0, overflow);
109
+ }
110
+ checkpointTurnsBySession.set(sessionKey, turns);
111
+ return turns;
112
+ }
113
+ function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source) {
114
+ if (source.type === "background") {
115
+ return;
116
+ }
117
+ const tracker = getCheckpointTracker(sessionKey);
118
+ const turns = appendCheckpointTurn(sessionKey, {
119
+ user: truncateCheckpointText(prompt),
120
+ assistant: truncateCheckpointText(finalContent),
121
+ });
122
+ if (!config.memoryCheckpointEnabled) {
123
+ log.info({ sessionKey }, "memory.checkpoint.disabled");
124
+ return;
125
+ }
126
+ tracker.tickOrchestratorTurn();
127
+ if (!tracker.shouldFire()) {
128
+ return;
129
+ }
130
+ tracker.markFired();
131
+ if (isCheckpointInFlight(sessionKey)) {
132
+ log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
133
+ return;
134
+ }
135
+ if (!copilotClient) {
136
+ log.error({ sessionKey }, "memory.checkpoint.error");
137
+ return;
138
+ }
139
+ const activeScope = getActiveScope();
140
+ void runCheckpointExtraction({
141
+ sessionKey,
142
+ turns: turns.slice(-config.memoryCheckpointTurns),
143
+ activeScope,
144
+ copilotClient,
145
+ trigger: "cadence",
146
+ }).catch((error) => {
147
+ log.error({ err: error, sessionKey }, "memory.checkpoint.error");
148
+ });
149
+ }
150
+ function scheduleHousekeeping(sessionKey, source) {
151
+ if (source.type === "background") {
152
+ return;
153
+ }
154
+ if (!config.memoryHousekeepingEnabled) {
155
+ log.info({ sessionKey }, "memory.housekeeping.disabled");
156
+ return;
157
+ }
158
+ const turns = (housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
159
+ if (turns < config.memoryHousekeepingTurns) {
160
+ housekeepingTurnsBySession.set(sessionKey, turns);
161
+ return;
162
+ }
163
+ housekeepingTurnsBySession.set(sessionKey, 0);
164
+ const activeScope = getActiveScope();
165
+ if (!activeScope) {
166
+ log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
167
+ return;
168
+ }
169
+ const scopeIds = [activeScope.id];
170
+ if (isHousekeepingInFlight(scopeIds)) {
171
+ log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
172
+ return;
173
+ }
174
+ try {
175
+ void runHousekeeping({ scopeIds });
176
+ }
177
+ catch (error) {
178
+ log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
179
+ }
180
+ }
181
+ export function maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope) {
182
+ if (!previousScope) {
183
+ return;
184
+ }
185
+ if (!config.memoryCheckpointOnScopeChange) {
186
+ log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
187
+ return;
188
+ }
189
+ const tracker = getCheckpointTracker(sessionKey);
190
+ const turnsSinceLast = tracker.turnsSinceLastFire();
191
+ if (turnsSinceLast < config.memoryCheckpointMinTurnsForScopeFire) {
192
+ log.info({
193
+ sessionKey,
194
+ scope: previousScope.slug,
195
+ turns_since_last: turnsSinceLast,
196
+ min_required: config.memoryCheckpointMinTurnsForScopeFire,
197
+ }, "memory.checkpoint.scope_change_skip");
198
+ return;
199
+ }
200
+ if (isCheckpointInFlight(sessionKey)) {
201
+ log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
202
+ return;
203
+ }
204
+ if (!copilotClient) {
205
+ log.error({ sessionKey }, "memory.checkpoint.error");
206
+ return;
207
+ }
208
+ const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
209
+ if (turns.length === 0) {
210
+ log.info({
211
+ sessionKey,
212
+ scope: previousScope.slug,
213
+ turns_since_last: turnsSinceLast,
214
+ min_required: config.memoryCheckpointMinTurnsForScopeFire,
215
+ }, "memory.checkpoint.scope_change_skip");
216
+ return;
217
+ }
218
+ tracker.markScopeChangeFire();
219
+ void runCheckpointExtraction({
220
+ sessionKey,
221
+ turns: turns.slice(-config.memoryCheckpointTurns),
222
+ activeScope: previousScope,
223
+ copilotClient,
224
+ trigger: "scope_change",
225
+ scopeChangeContext: {
226
+ from: previousScope.slug,
227
+ to: nextScope?.slug ?? "no active scope",
228
+ },
229
+ }).catch((error) => {
230
+ log.error({ err: error, sessionKey }, "memory.checkpoint.error");
231
+ });
232
+ }
73
233
  export function subscribeTaskEvents(taskId, listener) {
74
234
  return agentEventBus.subscribe("session:tool_call", (event) => {
75
235
  if (event.sessionId !== taskId)
@@ -152,18 +312,30 @@ export function getCurrentAuthorizationHeader() {
152
312
  // Internal helpers
153
313
  // ---------------------------------------------------------------------------
154
314
  function getSessionConfig() {
155
- const tools = createTools({
315
+ const baseTools = createTools({
156
316
  client: copilotClient,
157
317
  onAgentTaskComplete: feedAgentResult,
158
318
  });
319
+ const tools = agentsModule.bindToolsToAgent?.("chapterhouse", baseTools) ?? baseTools;
159
320
  const mcpServers = loadMcpConfig();
160
321
  const skillDirectories = getSkillDirectories();
161
322
  return { tools, mcpServers, skillDirectories };
162
323
  }
324
+ function buildHotTierContext() {
325
+ if (!config.memoryInjectEnabled) {
326
+ return undefined;
327
+ }
328
+ const hotTierXml = renderHotTierForActiveScope();
329
+ if (!hotTierXml) {
330
+ return undefined;
331
+ }
332
+ return hotTierXml.trimEnd();
333
+ }
163
334
  function getSystemMessageOptions(memorySummary) {
164
335
  return {
165
336
  selfEditEnabled: config.selfEditEnabled,
166
337
  memorySummary: memorySummary || undefined,
338
+ hotTierXml: buildHotTierContext(),
167
339
  agentRoster: buildAgentRoster(),
168
340
  userContext: currentUserContext,
169
341
  };
@@ -183,6 +355,9 @@ function updateUserContext(source) {
183
355
  // Invalidate the default session so it's recreated with the updated system message
184
356
  registry?.get("default")?.invalidateSession();
185
357
  }
358
+ export function invalidateOrchestratorSession(sessionKey) {
359
+ registry?.get(sessionKey)?.invalidateSession();
360
+ }
186
361
  function updateRequestContext(source) {
187
362
  if (source.type !== "web" && source.type !== "sse-web") {
188
363
  currentAuthenticatedUser = undefined;
@@ -196,6 +371,15 @@ function updateRequestContext(source) {
196
371
  }
197
372
  }
198
373
  export function feedAgentResult(taskId, agentSlug, result) {
374
+ if (copilotClient) {
375
+ void runEndOfTaskMemoryHook({
376
+ taskId,
377
+ finalResult: result,
378
+ copilotClient,
379
+ }).catch((error) => {
380
+ log.error({ err: error, taskId }, "memory.eot.error");
381
+ });
382
+ }
199
383
  const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
200
384
  const sessionKey = getTaskSessionKey(taskId);
201
385
  sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
@@ -276,6 +460,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
276
460
  infiniteSessions,
277
461
  });
278
462
  log.info({ sessionKey }, "Session resumed successfully");
463
+ resetCheckpointSessionState(sessionKey);
279
464
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
280
465
  const mgr = registry?.get(sessionKey);
281
466
  if (mgr)
@@ -301,6 +486,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
301
486
  infiniteSessions,
302
487
  });
303
488
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
489
+ resetCheckpointSessionState(sessionKey);
304
490
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
305
491
  if (sessionKey === "default")
306
492
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
@@ -397,9 +583,17 @@ async function executeOnSession(manager, item) {
397
583
  // Correlates the SDK's subagent.started event (which only carries agent_type fields) with the
398
584
  // actual spawn parameters (name, description) passed to the task() tool call.
399
585
  const spawnArgsMap = new Map();
586
+ const toolStartDetails = new Map();
400
587
  // Unconditional capture — must fire even when onActivity is absent so the DB handler can resolve names.
401
588
  const unsubSpawnCapture = session.on("tool.execution_start", (event) => {
402
589
  const data = event.data;
590
+ if (data.toolCallId) {
591
+ toolStartDetails.set(data.toolCallId, {
592
+ toolName: String(data.toolName ?? "unknown"),
593
+ mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
594
+ arguments: data.arguments,
595
+ });
596
+ }
403
597
  if (data.toolName === "task" && data.toolCallId) {
404
598
  const args = (data.arguments ?? {});
405
599
  spawnArgsMap.set(data.toolCallId, {
@@ -424,6 +618,9 @@ async function executeOnSession(manager, item) {
424
618
  : typeof result?.content === "string"
425
619
  ? result.content
426
620
  : undefined;
621
+ const toolCallId = String(data.toolCallId ?? "");
622
+ const startDetails = toolStartDetails.get(toolCallId);
623
+ const completionToolName = data.toolName;
427
624
  if (item.onActivity) {
428
625
  item.onActivity({
429
626
  kind: "tool_complete",
@@ -436,13 +633,20 @@ async function executeOnSession(manager, item) {
436
633
  // Emit turn:delta with tool-call part (coexistence — #130)
437
634
  const toolPart = {
438
635
  type: "tool-call",
439
- toolCallId: String(data.toolCallId ?? ""),
440
- toolName: String(data.toolName ?? "unknown"),
636
+ toolCallId,
637
+ toolName: typeof completionToolName === "string" && completionToolName.length > 0
638
+ ? completionToolName
639
+ : (startDetails?.toolName ?? "unknown"),
640
+ mcpServerName: startDetails?.mcpServerName,
641
+ arguments: startDetails?.arguments,
441
642
  status: data.success !== false ? "done" : "failed",
442
643
  resultPreview,
443
644
  detailedContent,
444
645
  };
445
646
  emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: toolPart });
647
+ if (toolCallId) {
648
+ toolStartDetails.delete(toolCallId);
649
+ }
446
650
  });
447
651
  const unsubToolStart = item.onActivity
448
652
  ? session.on("tool.execution_start", (event) => {
@@ -586,6 +790,15 @@ async function executeOnSession(manager, item) {
586
790
  spawnArgsMap.delete(taskId);
587
791
  activeSubagentTaskIds.delete(taskId);
588
792
  db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
793
+ if (copilotClient && finalResult) {
794
+ void runEndOfTaskMemoryHook({
795
+ taskId,
796
+ finalResult,
797
+ copilotClient,
798
+ }).catch((error) => {
799
+ log.error({ err: error, taskId }, "memory.eot.error");
800
+ });
801
+ }
589
802
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
590
803
  void agentEventBus.emit({
591
804
  type: "session:destroyed",
@@ -898,6 +1111,8 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
898
1111
  logConversation("assistant", finalContent, sourceLabel, sessionKey);
899
1112
  }
900
1113
  catch { /* best-effort */ }
1114
+ scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
1115
+ scheduleHousekeeping(sessionKey, source);
901
1116
  if (copilotClient) {
902
1117
  maybeWriteEpisode(copilotClient).catch((err) => {
903
1118
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -997,6 +1212,8 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
997
1212
  logConversation("assistant", finalContent, sourceLabel, sessionKey);
998
1213
  }
999
1214
  catch { /* best-effort */ }
1215
+ scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
1216
+ scheduleHousekeeping(sessionKey, source);
1000
1217
  if (copilotClient) {
1001
1218
  maybeWriteEpisode(copilotClient).catch((err) => {
1002
1219
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -1123,10 +1340,14 @@ export function getAgentInfo() {
1123
1340
  /** Clean up on shutdown/restart. */
1124
1341
  export async function shutdownAgents() {
1125
1342
  if (!registry) {
1343
+ checkpointTrackers.clear();
1344
+ checkpointTurnsBySession.clear();
1126
1345
  await clearActiveTasks();
1127
1346
  return;
1128
1347
  }
1129
1348
  await registry.shutdown();
1349
+ checkpointTrackers.clear();
1350
+ checkpointTurnsBySession.clear();
1130
1351
  await clearActiveTasks();
1131
1352
  }
1132
1353
  //# sourceMappingURL=orchestrator.js.map