chapterhouse 0.13.1 → 0.14.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 (132) hide show
  1. package/dist/api/route-coverage.test.js +1 -3
  2. package/dist/api/server.js +0 -2
  3. package/dist/api/server.test.js +0 -281
  4. package/dist/config.js +3 -85
  5. package/dist/config.test.js +5 -123
  6. package/dist/copilot/agents.js +13 -10
  7. package/dist/copilot/agents.test.js +10 -11
  8. package/dist/copilot/memory-coordinator.js +12 -227
  9. package/dist/copilot/memory-coordinator.test.js +31 -250
  10. package/dist/copilot/orchestrator.js +8 -66
  11. package/dist/copilot/orchestrator.test.js +9 -467
  12. package/dist/copilot/skills.js +15 -1
  13. package/dist/copilot/system-message.js +9 -15
  14. package/dist/copilot/system-message.test.js +9 -22
  15. package/dist/copilot/tools/index.js +3 -3
  16. package/dist/copilot/tools-deps.js +1 -1
  17. package/dist/copilot/tools.agent.test.js +6 -0
  18. package/dist/copilot/tools.inventory.test.js +1 -14
  19. package/dist/daemon.js +7 -9
  20. package/dist/memory/assets.js +33 -0
  21. package/dist/memory/domains.js +58 -0
  22. package/dist/memory/domains.test.js +47 -0
  23. package/dist/memory/git.js +66 -0
  24. package/dist/memory/git.test.js +32 -0
  25. package/dist/memory/history.js +19 -0
  26. package/dist/memory/hottier.js +32 -0
  27. package/dist/memory/hottier.test.js +33 -0
  28. package/dist/memory/index.js +5 -13
  29. package/dist/memory/instructions.js +17 -0
  30. package/dist/memory/manager.js +92 -0
  31. package/dist/memory/markdown.js +78 -0
  32. package/dist/memory/markdown.test.js +42 -0
  33. package/dist/memory/mutex.js +18 -0
  34. package/dist/memory/path-guard.js +26 -0
  35. package/dist/memory/path-guard.test.js +27 -0
  36. package/dist/memory/paths.js +12 -0
  37. package/dist/memory/reconcile.js +75 -0
  38. package/dist/memory/reconcile.test.js +50 -0
  39. package/dist/memory/scaffold.js +37 -0
  40. package/dist/memory/scaffold.test.js +52 -0
  41. package/dist/memory/tools/commit-wrapper.js +32 -0
  42. package/dist/memory/tools/domains.js +73 -0
  43. package/dist/memory/tools/domains.test.js +66 -0
  44. package/dist/memory/tools/git.js +52 -0
  45. package/dist/memory/tools/index.js +25 -0
  46. package/dist/memory/tools/read.js +101 -0
  47. package/dist/memory/tools/read.test.js +69 -0
  48. package/dist/memory/tools/search.js +103 -0
  49. package/dist/memory/tools/search.test.js +63 -0
  50. package/dist/memory/tools/sessions.js +45 -0
  51. package/dist/memory/tools/sessions.test.js +74 -0
  52. package/dist/memory/tools/shared.js +7 -0
  53. package/dist/memory/tools/write.js +116 -0
  54. package/dist/memory/tools/write.test.js +107 -0
  55. package/dist/memory/walk.js +39 -0
  56. package/dist/store/repositories/sessions.js +40 -0
  57. package/dist/wiki/consolidation.js +3 -31
  58. package/dist/wiki/consolidation.test.js +0 -19
  59. package/memory-assets/domain-skill.md +38 -0
  60. package/memory-assets/seed/cog-meta/improvements.md +8 -0
  61. package/memory-assets/seed/cog-meta/patterns.md +5 -0
  62. package/memory-assets/seed/cog-meta/reflect-cursor.md +4 -0
  63. package/memory-assets/seed/cog-meta/scenario-calibration.md +14 -0
  64. package/memory-assets/seed/cog-meta/self-observations.md +4 -0
  65. package/memory-assets/seed/domains.yml +19 -0
  66. package/memory-assets/seed/glacier/index.md +6 -0
  67. package/memory-assets/seed/hot-memory.md +5 -0
  68. package/memory-assets/seed/link-index.md +6 -0
  69. package/memory-assets/system-instructions.md +214 -0
  70. package/memory-assets/templates/action-items.md +8 -0
  71. package/memory-assets/templates/entities.md +4 -0
  72. package/memory-assets/templates/generic.md +2 -0
  73. package/memory-assets/templates/hot-memory.md +4 -0
  74. package/memory-assets/templates/observations.md +4 -0
  75. package/package.json +2 -1
  76. package/skills/system/evolve/SKILL.md +131 -0
  77. package/skills/system/foresight/SKILL.md +116 -0
  78. package/skills/system/history/SKILL.md +58 -0
  79. package/skills/system/housekeeping/SKILL.md +185 -0
  80. package/skills/system/reflect/SKILL.md +214 -0
  81. package/skills/system/scenario/SKILL.md +198 -0
  82. package/skills/system/setup/SKILL.md +113 -0
  83. package/web/dist/assets/{WikiEdit-CGRxNazp.js → WikiEdit-BTsiBfbC.js} +2 -2
  84. package/web/dist/assets/{WikiEdit-CGRxNazp.js.map → WikiEdit-BTsiBfbC.js.map} +1 -1
  85. package/web/dist/assets/{WikiGraph-eVWNhZS3.js → WikiGraph-COOZbUeH.js} +2 -2
  86. package/web/dist/assets/{WikiGraph-eVWNhZS3.js.map → WikiGraph-COOZbUeH.js.map} +1 -1
  87. package/web/dist/assets/{index-gAvLNEvJ.js → index-aCcfpaLM.js} +101 -101
  88. package/web/dist/assets/index-aCcfpaLM.js.map +1 -0
  89. package/web/dist/index.html +1 -1
  90. package/dist/api/routes/memory.js +0 -475
  91. package/dist/api/routes/memory.test.js +0 -108
  92. package/dist/copilot/tools/memory.js +0 -678
  93. package/dist/copilot/tools.memory.test.js +0 -590
  94. package/dist/memory/action-items.js +0 -100
  95. package/dist/memory/action-items.test.js +0 -83
  96. package/dist/memory/active-scope.js +0 -78
  97. package/dist/memory/active-scope.test.js +0 -80
  98. package/dist/memory/checkpoint-prompt.js +0 -71
  99. package/dist/memory/checkpoint.js +0 -274
  100. package/dist/memory/checkpoint.test.js +0 -275
  101. package/dist/memory/decisions.js +0 -54
  102. package/dist/memory/decisions.test.js +0 -92
  103. package/dist/memory/entities.js +0 -70
  104. package/dist/memory/entities.test.js +0 -65
  105. package/dist/memory/eot.js +0 -459
  106. package/dist/memory/eot.test.js +0 -949
  107. package/dist/memory/hooks.js +0 -149
  108. package/dist/memory/hooks.test.js +0 -325
  109. package/dist/memory/hot-tier.js +0 -283
  110. package/dist/memory/hot-tier.test.js +0 -275
  111. package/dist/memory/housekeeping-scheduler.js +0 -187
  112. package/dist/memory/housekeeping-scheduler.test.js +0 -236
  113. package/dist/memory/housekeeping.js +0 -497
  114. package/dist/memory/housekeeping.test.js +0 -410
  115. package/dist/memory/inbox.js +0 -83
  116. package/dist/memory/inbox.test.js +0 -178
  117. package/dist/memory/migration.js +0 -244
  118. package/dist/memory/migration.test.js +0 -108
  119. package/dist/memory/observations.js +0 -46
  120. package/dist/memory/observations.test.js +0 -86
  121. package/dist/memory/recall.js +0 -269
  122. package/dist/memory/recall.test.js +0 -265
  123. package/dist/memory/reflect.js +0 -273
  124. package/dist/memory/reflect.test.js +0 -256
  125. package/dist/memory/scope-lock.js +0 -26
  126. package/dist/memory/scope-lock.test.js +0 -118
  127. package/dist/memory/scopes.js +0 -89
  128. package/dist/memory/scopes.test.js +0 -176
  129. package/dist/memory/tiering.js +0 -223
  130. package/dist/memory/tiering.test.js +0 -323
  131. package/dist/memory/types.js +0 -2
  132. package/web/dist/assets/index-gAvLNEvJ.js.map +0 -1
@@ -12,6 +12,7 @@ import { loadMcpConfig } from "./mcp-config.js";
12
12
  import { getCurrentDateSystemLine } from "./prompt-date.js";
13
13
  import { getSkillDirectories } from "./skills.js";
14
14
  import { EXCLUDED_BUILTIN_TOOLS } from "./builtin-tools.js";
15
+ import { memorySystemInstructions } from "../memory/index.js";
15
16
  import { childLogger } from "../util/logger.js";
16
17
  const log = childLogger("agents");
17
18
  const toolAgentContext = new AsyncLocalStorage();
@@ -297,12 +298,12 @@ function getAgentBasePrompt() {
297
298
  You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
298
299
 
299
300
  ### Agent Memory
300
- Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\` and \`memory_list_action_items\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, a named system/tool/person can be proposed as an \`entity\`, and a reminder/follow-up can be proposed as an \`action_item\`.
301
+ Chapterhouse gives every agent a persistent, file-based memory tree under \`memory/\`, organized into domains. Read it with \`cog_read\`, \`cog_l0_scan\`, \`cog_l1_outline\`, \`cog_tree\`, and \`cog_search\`; write directly with \`cog_write\`, \`cog_edit\`, \`cog_append\`, and \`cog_move\` whenever you learn something worth remembering don't wait for end of task. Every write auto-commits to git. The full memory operating instructions are included below.
301
302
 
302
303
  ### Shared Wiki
303
304
  All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
304
305
 
305
- Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\` and \`wiki_ingest_source\` as write-sensitive workflows, and use \`memory_propose\` for agent-memory handoff. Before using wiki write tools, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
306
+ Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wiki_update\` and \`wiki_ingest_source\` as write-sensitive workflows. Before using wiki write tools, read \`pages/index.md\`, scan the last 20-30 entries of \`pages/_meta/log.md\`, and run \`wiki_search\` for the topic when the wiki is large or the topic is ambiguous.
306
307
 
307
308
  ### Communication
308
309
  - You receive tasks from @chapterhouse (the orchestrator) or directly from the user
@@ -321,11 +322,12 @@ export function composeAgentSystemMessage(agent, rosterInfo) {
321
322
  const agentPrompt = agent.systemMessage;
322
323
  const currentDateLine = getCurrentDateSystemLine();
323
324
  const currentDateBlock = currentDateLine ? `${currentDateLine}\n\n` : "";
325
+ const memoryBlock = memorySystemInstructions() ? `\n\n${memorySystemInstructions()}` : "";
324
326
  // For @chapterhouse, inject the agent roster
325
327
  if (agent.slug === "chapterhouse" && rosterInfo) {
326
- return `${currentDateBlock}${agentPrompt.replace("{agent_roster}", rosterInfo)}`;
328
+ return `${currentDateBlock}${agentPrompt.replace("{agent_roster}", rosterInfo)}${memoryBlock}`;
327
329
  }
328
- return `${currentDateBlock}${agentPrompt}\n\n${base}`;
330
+ return `${currentDateBlock}${agentPrompt}\n\n${base}${memoryBlock}`;
329
331
  }
330
332
  /** Build a roster description of all agents for @chapterhouse's system prompt. */
331
333
  export function buildAgentRoster() {
@@ -341,10 +343,13 @@ export function buildAgentRoster() {
341
343
  return "No agents registered.";
342
344
  return chLines.join("\n");
343
345
  }
344
- // The wiki tools that every agent gets regardless of tool config
345
- const WIKI_TOOL_NAMES = new Set([
346
+ // Tools every agent gets regardless of tool config: the shared wiki and the
347
+ // full file-based memory surface (all agents read and write memory directly).
348
+ const ALWAYS_AVAILABLE_TOOL_NAMES = new Set([
346
349
  "wiki_search", "wiki_read", "wiki_update", "wiki_reindex", "wiki_append_timeline", "wiki_ingest_source",
347
- "memory_recall", "memory_propose", "memory_list_action_items",
350
+ "cog_read", "cog_l0_scan", "cog_l1_outline", "cog_tree", "cog_search", "cog_stats",
351
+ "cog_write", "cog_edit", "cog_append", "cog_move",
352
+ "cog_git", "cog_domains", "cog_domain_create", "cog_sessions",
348
353
  ]);
349
354
  // Management tools that only @chapterhouse should have
350
355
  const MANAGEMENT_TOOL_NAMES = new Set([
@@ -353,8 +358,6 @@ const MANAGEMENT_TOOL_NAMES = new Set([
353
358
  "switch_model", "toggle_auto", "list_models",
354
359
  "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
355
360
  "list_machine_sessions", "attach_machine_session",
356
- "memory_remember", "memory_set_scope", "memory_housekeep", "memory_reflect", "memory_promote", "memory_demote",
357
- "memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
358
361
  ]);
359
362
  export function getCurrentToolAgentSlug() {
360
363
  return toolAgentContext.getStore();
@@ -375,7 +378,7 @@ export function bindToolsToAgent(agentSlug, allTools, taskId) {
375
378
  export function filterToolsForAgent(agent, allTools) {
376
379
  if (agent.tools && agent.tools.length > 0) {
377
380
  // Agent specifies an explicit allowlist — give those + wiki tools
378
- const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
381
+ const allowed = new Set([...agent.tools, ...ALWAYS_AVAILABLE_TOOL_NAMES]);
379
382
  return allTools.filter((t) => allowed.has(t.name));
380
383
  }
381
384
  // Default: all tools except management (only @chapterhouse gets those by default)
@@ -50,18 +50,17 @@ test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions",
50
50
  for (const slug of ["coder", "general-purpose"]) {
51
51
  const message = composeAgentSystemMessage(makeAgent(slug));
52
52
  assert.match(message, /invoke `wiki-conventions` before wiki writes/i);
53
- assert.match(message, /wiki_update[\s\S]{0,120}wiki_ingest_source[\s\S]{0,120}memory_propose/i);
53
+ assert.match(message, /wiki_update[\s\S]{0,120}wiki_ingest_source/i);
54
54
  assert.doesNotMatch(message, /`remember`|`forget`|`wiki_ingest`(?!_source)|`wiki_lint`|`wiki_rebuild_index`/);
55
55
  assert.match(message, /read `pages\/index\.md`/i);
56
56
  assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
57
57
  }
58
58
  });
59
- test("composeAgentSystemMessage teaches subagents the three-tier memory model and directs them to memory_propose", () => {
59
+ test("composeAgentSystemMessage teaches subagents the file-based memory tools", () => {
60
60
  const message = composeAgentSystemMessage(makeAgent("coder"));
61
- assert.match(message, /three-tier memory model|read, propose, write/i);
62
- assert.match(message, /memory_recall/i);
63
- assert.match(message, /memory_propose/i);
64
- assert.match(message, /do not call `memory_remember` directly|should not call `memory_remember` directly/i);
61
+ assert.match(message, /cog_read/);
62
+ assert.match(message, /cog_write/);
63
+ assert.match(message, /write directly/i);
65
64
  });
66
65
  test("parseAgentMd detects persistent agent scope from charter frontmatter", () => {
67
66
  const agent = parseAgentMd([
@@ -103,22 +102,22 @@ test("korg charter is persistent and standardizes on the Summary heading", () =>
103
102
  assert.match(charter, /## Summary/);
104
103
  assert.doesNotMatch(charter, /## Compiled Truth/);
105
104
  });
106
- test("persistent agents cannot receive scope-changing management tools", () => {
105
+ test("persistent agents cannot receive management tools", () => {
107
106
  const agent = {
108
107
  ...makeAgent("bellonda"),
109
108
  persistent: true,
110
109
  scope: "infra",
111
110
  };
112
111
  const tools = [
113
- { name: "memory_recall" },
114
- { name: "memory_propose" },
115
- { name: "memory_set_scope" },
112
+ { name: "cog_read" },
113
+ { name: "cog_write" },
116
114
  { name: "delegate_to_agent" },
115
+ { name: "hire_agent" },
117
116
  { name: "bash" },
118
117
  ];
119
118
  const filtered = filterToolsForAgent(agent, tools);
120
119
  const names = filtered.map((tool) => tool.name);
121
- assert.deepEqual(names.sort(), ["bash", "memory_propose", "memory_recall"].sort());
120
+ assert.deepEqual(names.sort(), ["bash", "cog_read", "cog_write"].sort());
122
121
  });
123
122
  test("bindToolsToAgent uses the per-turn task context when no fixed task id is provided", async () => {
124
123
  const tools = bindToolsToAgent("bellonda", [{
@@ -1,234 +1,19 @@
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;
1
+ import { getMemoryManager } from "../memory/index.js";
2
+ /**
3
+ * Wires the file-based memory subsystem into the agent runtime. Its only job is
4
+ * to inject the always-loaded hot-tier block into each turn via the
5
+ * onUserPromptSubmitted hook all memory reads and writes are agent-driven via
6
+ * the cog_* tools.
7
+ */
11
8
  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 = {
9
+ /** Returns the per-turn hooks that inject the hot-tier memory block. */
10
+ buildPerTurnHooks() {
11
+ return {
109
12
  onUserPromptSubmitted: async () => {
110
- const hotTierXml = await this.buildHotTierContext(sessionKey);
111
- return hotTierXml ? { additionalContext: hotTierXml } : undefined;
13
+ const block = getMemoryManager().hotTier();
14
+ return block ? { additionalContext: block } : undefined;
112
15
  },
113
16
  };
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
17
  }
233
18
  }
234
19
  //# sourceMappingURL=memory-coordinator.js.map
@@ -1,257 +1,38 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  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
- });
5
+ const sandbox = join(process.cwd(), ".test-work", `memory-coordinator-${process.pid}`);
6
+ process.env.CHAPTERHOUSE_HOME = sandbox;
7
+ import { MemoryCoordinator } from "./memory-coordinator.js";
8
+ import { gitAvailable } from "../memory/git.js";
9
+ import { getMemoryManager, resetMemoryManagerForTests } from "../memory/index.js";
10
+ test.beforeEach(() => {
11
+ rmSync(sandbox, { recursive: true, force: true });
12
+ mkdirSync(sandbox, { recursive: true });
13
+ resetMemoryManagerForTests();
148
14
  });
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);
15
+ test.after(() => rmSync(sandbox, { recursive: true, force: true }));
16
+ test("buildPerTurnHooks returns an onUserPromptSubmitted hook", () => {
17
+ const hooks = new MemoryCoordinator().buildPerTurnHooks();
18
+ assert.ok(hooks);
19
+ assert.equal(typeof hooks.onUserPromptSubmitted, "function");
172
20
  });
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" });
21
+ test("the hook returns undefined when memory has no hot-tier content", async () => {
22
+ const hooks = new MemoryCoordinator().buildPerTurnHooks();
23
+ const result = await hooks.onUserPromptSubmitted();
24
+ assert.equal(result, undefined);
200
25
  });
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);
26
+ test("the hook injects the hot-tier block once memory is scaffolded", async () => {
27
+ if (!gitAvailable())
28
+ return;
29
+ const manager = getMemoryManager();
30
+ await manager.ensureReady();
31
+ writeFileSync(join(manager.paths.memoryRoot, "hot-memory.md"), "<!-- L0: hot -->\ntop of mind");
32
+ const hooks = new MemoryCoordinator().buildPerTurnHooks();
33
+ const result = await hooks.onUserPromptSubmitted();
34
+ assert.ok(result && typeof result === "object");
35
+ assert.match(result.additionalContext, /top of mind/);
36
+ assert.match(result.additionalContext, /<chapterhouse-memory>/);
256
37
  });
257
38
  //# sourceMappingURL=memory-coordinator.test.js.map