muonroi-cli 1.4.1 → 1.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 (194) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  4. package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
  5. package/dist/src/agent-harness/mock-model.d.ts +11 -0
  6. package/dist/src/agent-harness/mock-model.js +21 -0
  7. package/dist/src/cli/cost-forensics.js +12 -12
  8. package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
  9. package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
  10. package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
  11. package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
  12. package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
  13. package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
  14. package/dist/src/council/clarifier.js +9 -1
  15. package/dist/src/council/debate.js +5 -1
  16. package/dist/src/council/decisions-lock.js +3 -3
  17. package/dist/src/council/index.js +12 -5
  18. package/dist/src/council/leader.d.ts +0 -17
  19. package/dist/src/council/leader.js +22 -15
  20. package/dist/src/council/planner.js +1 -1
  21. package/dist/src/council/prompts.js +63 -57
  22. package/dist/src/council/types.d.ts +7 -0
  23. package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
  24. package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
  25. package/dist/src/ee/artifact-cache.d.ts +56 -0
  26. package/dist/src/ee/artifact-cache.js +155 -0
  27. package/dist/src/ee/artifact-cache.test.d.ts +1 -0
  28. package/dist/src/ee/artifact-cache.test.js +69 -0
  29. package/dist/src/ee/auth.d.ts +9 -0
  30. package/dist/src/ee/auth.js +19 -0
  31. package/dist/src/ee/ee-onboarding.d.ts +5 -0
  32. package/dist/src/ee/ee-onboarding.js +76 -0
  33. package/dist/src/ee/search.js +7 -5
  34. package/dist/src/ee/search.test.d.ts +1 -0
  35. package/dist/src/ee/search.test.js +23 -0
  36. package/dist/src/generated/version.d.ts +1 -1
  37. package/dist/src/generated/version.js +1 -1
  38. package/dist/src/headless/output.js +6 -4
  39. package/dist/src/headless/output.test.js +4 -3
  40. package/dist/src/index.js +20 -1
  41. package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
  42. package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
  43. package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
  44. package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
  45. package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
  46. package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
  47. package/dist/src/mcp/auto-setup.js +56 -2
  48. package/dist/src/mcp/client-pool.d.ts +46 -0
  49. package/dist/src/mcp/client-pool.js +212 -0
  50. package/dist/src/mcp/oauth-callback.js +2 -2
  51. package/dist/src/mcp/parse-headers.test.js +14 -14
  52. package/dist/src/mcp/runtime.d.ts +28 -0
  53. package/dist/src/mcp/runtime.js +117 -51
  54. package/dist/src/mcp/self-verify-runner.d.ts +14 -0
  55. package/dist/src/mcp/self-verify-runner.js +38 -0
  56. package/dist/src/mcp/setup-guide-text.d.ts +9 -0
  57. package/dist/src/mcp/setup-guide-text.js +84 -0
  58. package/dist/src/mcp/smart-filter.js +49 -0
  59. package/dist/src/mcp/smoke.test.js +43 -43
  60. package/dist/src/mcp/tools-server.d.ts +7 -0
  61. package/dist/src/mcp/tools-server.js +19 -22
  62. package/dist/src/models/catalog.json +349 -349
  63. package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
  64. package/dist/src/ops/doctor.d.ts +3 -2
  65. package/dist/src/ops/doctor.js +47 -11
  66. package/dist/src/ops/doctor.test.js +4 -3
  67. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
  68. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
  69. package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
  70. package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
  71. package/dist/src/orchestrator/batch-turn-runner.js +7 -11
  72. package/dist/src/orchestrator/compaction.d.ts +2 -0
  73. package/dist/src/orchestrator/compaction.js +14 -1
  74. package/dist/src/orchestrator/compaction.test.js +25 -1
  75. package/dist/src/orchestrator/message-processor.js +72 -32
  76. package/dist/src/orchestrator/orchestrator.js +26 -0
  77. package/dist/src/orchestrator/prompts.d.ts +51 -0
  78. package/dist/src/orchestrator/prompts.js +257 -134
  79. package/dist/src/orchestrator/scope-ceiling.js +6 -1
  80. package/dist/src/orchestrator/scope-reminder.d.ts +12 -0
  81. package/dist/src/orchestrator/scope-reminder.js +16 -0
  82. package/dist/src/orchestrator/scope-reminder.test.js +22 -1
  83. package/dist/src/orchestrator/stream-runner.js +23 -15
  84. package/dist/src/orchestrator/subagent-compactor.d.ts +14 -5
  85. package/dist/src/orchestrator/subagent-compactor.js +30 -8
  86. package/dist/src/orchestrator/subagent-compactor.spec.js +18 -0
  87. package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
  88. package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
  89. package/dist/src/pil/__tests__/config.test.js +1 -17
  90. package/dist/src/pil/__tests__/discovery.test.js +144 -11
  91. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
  92. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  93. package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
  94. package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
  95. package/dist/src/pil/__tests__/layer6-output.test.js +158 -18
  96. package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
  97. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.d.ts +1 -0
  98. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.js +112 -0
  99. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  100. package/dist/src/pil/agent-operating-contract.js +2 -0
  101. package/dist/src/pil/agent-operating-contract.test.js +7 -2
  102. package/dist/src/pil/cheap-model-playbook.js +35 -35
  103. package/dist/src/pil/cheap-model-workbooks.js +16 -13
  104. package/dist/src/pil/clarity-gate.d.ts +21 -19
  105. package/dist/src/pil/clarity-gate.js +26 -153
  106. package/dist/src/pil/config.d.ts +9 -1
  107. package/dist/src/pil/config.js +15 -4
  108. package/dist/src/pil/discovery.js +211 -136
  109. package/dist/src/pil/layer1-intent.d.ts +12 -0
  110. package/dist/src/pil/layer1-intent.js +283 -38
  111. package/dist/src/pil/layer1-intent.test.js +210 -4
  112. package/dist/src/pil/layer16-clarity.d.ts +25 -11
  113. package/dist/src/pil/layer16-clarity.js +19 -306
  114. package/dist/src/pil/layer3-ee-injection.d.ts +19 -0
  115. package/dist/src/pil/layer3-ee-injection.js +96 -4
  116. package/dist/src/pil/layer4-gsd.js +18 -6
  117. package/dist/src/pil/layer6-output.d.ts +2 -0
  118. package/dist/src/pil/layer6-output.js +151 -25
  119. package/dist/src/pil/llm-classify.d.ts +26 -0
  120. package/dist/src/pil/llm-classify.js +34 -5
  121. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  122. package/dist/src/pil/native-capabilities-workbook.js +82 -76
  123. package/dist/src/pil/pipeline.js +15 -9
  124. package/dist/src/pil/schema.d.ts +8 -0
  125. package/dist/src/pil/schema.js +12 -1
  126. package/dist/src/pil/task-tier-map.js +4 -0
  127. package/dist/src/pil/types.d.ts +11 -1
  128. package/dist/src/product-loop/done-gate.js +3 -3
  129. package/dist/src/product-loop/loop-driver.js +18 -18
  130. package/dist/src/product-loop/progress-snapshot.js +4 -4
  131. package/dist/src/providers/auth/gemini-oauth.js +6 -15
  132. package/dist/src/providers/auth/grok-oauth.js +6 -15
  133. package/dist/src/providers/auth/openai-oauth.js +6 -15
  134. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  135. package/dist/src/reporter/index.js +1 -1
  136. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  137. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  138. package/dist/src/scaffold/continuation-prompt.js +60 -60
  139. package/dist/src/scaffold/init-new.js +453 -453
  140. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  141. package/dist/src/self-qa/agentic-loop.js +24 -19
  142. package/dist/src/self-qa/spec-emitter.js +26 -23
  143. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  144. package/dist/src/storage/interaction-log.js +5 -5
  145. package/dist/src/storage/migrations.js +122 -122
  146. package/dist/src/storage/sessions.js +42 -42
  147. package/dist/src/storage/transcript.js +91 -84
  148. package/dist/src/storage/usage.js +14 -14
  149. package/dist/src/storage/workspaces.js +12 -12
  150. package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
  151. package/dist/src/tools/__tests__/native-tools.test.js +53 -0
  152. package/dist/src/tools/git-safety.d.ts +61 -0
  153. package/dist/src/tools/git-safety.js +141 -0
  154. package/dist/src/tools/git-safety.test.d.ts +1 -0
  155. package/dist/src/tools/git-safety.test.js +111 -0
  156. package/dist/src/tools/native-tools.d.ts +31 -0
  157. package/dist/src/tools/native-tools.js +273 -0
  158. package/dist/src/tools/registry-ee-query.test.js +18 -1
  159. package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
  160. package/dist/src/tools/registry-git-safety.test.js +92 -0
  161. package/dist/src/tools/registry.js +52 -6
  162. package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
  163. package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
  164. package/dist/src/ui/app.js +0 -0
  165. package/dist/src/ui/components/message-view.js +4 -1
  166. package/dist/src/ui/components/structured-response-view.js +7 -3
  167. package/dist/src/ui/components/tool-group.js +7 -1
  168. package/dist/src/ui/markdown-render.d.ts +41 -0
  169. package/dist/src/ui/markdown-render.js +223 -0
  170. package/dist/src/ui/markdown.d.ts +10 -0
  171. package/dist/src/ui/markdown.js +12 -35
  172. package/dist/src/ui/slash/council-inspect.js +4 -4
  173. package/dist/src/ui/slash/export.js +4 -4
  174. package/dist/src/ui/utils/text.d.ts +8 -0
  175. package/dist/src/ui/utils/text.js +16 -0
  176. package/dist/src/ui/utils/text.test.d.ts +1 -0
  177. package/dist/src/ui/utils/text.test.js +23 -0
  178. package/dist/src/usage/ledger.js +48 -15
  179. package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
  180. package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
  181. package/dist/src/utils/clipboard-image.js +23 -23
  182. package/dist/src/utils/open-url.d.ts +56 -0
  183. package/dist/src/utils/open-url.js +58 -0
  184. package/dist/src/utils/open-url.test.d.ts +1 -0
  185. package/dist/src/utils/open-url.test.js +86 -0
  186. package/dist/src/utils/settings.d.ts +12 -0
  187. package/dist/src/utils/settings.js +48 -0
  188. package/dist/src/utils/side-question.js +2 -2
  189. package/dist/src/utils/skills.js +3 -3
  190. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  191. package/dist/src/verify/environment.js +2 -1
  192. package/package.json +1 -1
  193. package/dist/src/pil/layer16-clarity.test.js +0 -31
  194. /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
@@ -84,6 +84,27 @@ describe("doctor EE health checks (CQ-16c/16d)", () => {
84
84
  expect(eeHealth?.status).toBe("warn");
85
85
  expect(eeHealth?.detail).toContain("72.61.127.154");
86
86
  });
87
+ it("ee.health does NOT report unreachable when server is up but gates degraded (VERIFY F9)", async () => {
88
+ // Live ee_query works (server reachable) yet the gates sub-check fails —
89
+ // doctor must not call this "unreachable" (false negative). server.ok is
90
+ // the reachability signal, not result.ok.
91
+ healthDetailedMock.mockResolvedValue({
92
+ ok: false,
93
+ status: 200,
94
+ mode: "thin-client",
95
+ circuit: "closed",
96
+ components: {
97
+ server: { ok: true, status: 200 },
98
+ gates: { ok: false, status: 0 },
99
+ },
100
+ });
101
+ const results = await runDoctor();
102
+ const eeHealth = results.find((r) => r.name === "ee.health");
103
+ expect(eeHealth?.status).toBe("warn");
104
+ expect(eeHealth?.detail).not.toContain("unreachable");
105
+ expect(eeHealth?.detail).toContain("server=ok");
106
+ expect(eeHealth?.detail.toLowerCase()).toContain("gates");
107
+ });
87
108
  it("ee.health warns gracefully when healthDetailed throws", async () => {
88
109
  healthDetailedMock.mockRejectedValue(new Error("network timeout"));
89
110
  const results = await runDoctor();
@@ -2,9 +2,10 @@
2
2
  * src/ops/doctor.ts
3
3
  *
4
4
  * Health check runner for muonroi-cli doctor command.
5
- * Runs 7 named checks and returns pass/warn/fail results.
5
+ * Runs 10 named checks and returns pass/warn/fail results.
6
6
  *
7
- * Checks: bun_version, os, key_presence, ollama, ee, qdrant, error_rate
7
+ * Checks: bun_version, os, key_presence, ollama, dotnet, ee.health, ee.brain,
8
+ * qdrant, error_rate, council.mcp
8
9
  * Never throws — all checks handle errors gracefully (warn, not crash).
9
10
  */
10
11
  export interface CheckResult {
@@ -2,11 +2,13 @@
2
2
  * src/ops/doctor.ts
3
3
  *
4
4
  * Health check runner for muonroi-cli doctor command.
5
- * Runs 7 named checks and returns pass/warn/fail results.
5
+ * Runs 10 named checks and returns pass/warn/fail results.
6
6
  *
7
- * Checks: bun_version, os, key_presence, ollama, ee, qdrant, error_rate
7
+ * Checks: bun_version, os, key_presence, ollama, dotnet, ee.health, ee.brain,
8
+ * qdrant, error_rate, council.mcp
8
9
  * Never throws — all checks handle errors gracefully (warn, not crash).
9
10
  */
11
+ import { spawnSync } from "node:child_process";
10
12
  import { readFile } from "fs/promises";
11
13
  import os from "os";
12
14
  import path from "path";
@@ -116,7 +118,6 @@ async function checkEEDetailed() {
116
118
  const result = await healthDetailed();
117
119
  const serverOk = result.components.server.ok;
118
120
  const gatesOk = result.components.gates?.ok ?? true; // null if local mode
119
- const isHealthy = result.ok;
120
121
  const parts = [
121
122
  `mode=${result.mode}`,
122
123
  `circuit=${result.circuit}`,
@@ -125,7 +126,11 @@ async function checkEEDetailed() {
125
126
  if (result.components.gates !== null) {
126
127
  parts.push(`gates=${gatesOk ? "ok" : `fail(${result.components.gates.status})`}`);
127
128
  }
128
- if (!isHealthy) {
129
+ // Reachability is the SERVER component, not result.ok. A failing gates
130
+ // sub-check (e.g. read-token scope in thin-client mode) does NOT mean the
131
+ // EE server is unreachable — labelling it "unreachable" is a false negative
132
+ // that contradicts a live ee_query working. See VERIFY F9.
133
+ if (!serverOk) {
129
134
  const hint = result.mode === "thin-client"
130
135
  ? "Hint: check VPS 72.61.127.154:8082 is reachable; verify ~/.experience/config.json serverBaseUrl + serverReadAuthToken"
131
136
  : "Hint: start EE locally or configure thin-client in ~/.experience/config.json";
@@ -135,6 +140,13 @@ async function checkEEDetailed() {
135
140
  detail: `EE unreachable — ${parts.join(", ")}. ${hint}`,
136
141
  };
137
142
  }
143
+ if (!gatesOk) {
144
+ return {
145
+ name: "ee.health",
146
+ status: "warn",
147
+ detail: `EE reachable; gates check degraded — ${parts.join(", ")}. Hint: gates needs serverReadAuthToken scope in ~/.experience/config.json`,
148
+ };
149
+ }
138
150
  return {
139
151
  name: "ee.health",
140
152
  status: "pass",
@@ -157,9 +169,9 @@ async function checkBrainEmptiness() {
157
169
  // Count ee_injection events with event_subtype='no_match' in last 30 days
158
170
  const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString();
159
171
  const row = db
160
- .prepare(`SELECT COUNT(*) as cnt FROM interaction_logs
161
- WHERE event_type = 'ee_injection'
162
- AND event_subtype = 'no_match'
172
+ .prepare(`SELECT COUNT(*) as cnt FROM interaction_logs
173
+ WHERE event_type = 'ee_injection'
174
+ AND event_subtype = 'no_match'
163
175
  AND created_at >= ?`)
164
176
  .get(cutoff);
165
177
  const noMatchCount = row?.cnt ?? 0;
@@ -193,6 +205,29 @@ async function checkBrainEmptiness() {
193
205
  return { name: "ee.brain", status: "pass", detail: "brain check skipped (DB unavailable)" };
194
206
  }
195
207
  }
208
+ async function checkDotnet() {
209
+ // BB-aware scaffolding (muonroi-building-block) needs the .NET SDK for its
210
+ // restore/build/modular-boundaries quality gate. Doctor previously had no
211
+ // dotnet probe, so BB tasks had no preflight. See VERIFY F1.
212
+ try {
213
+ const res = spawnSync("dotnet", ["--version"], { encoding: "utf8", timeout: 5000 });
214
+ if (res.status === 0 && typeof res.stdout === "string" && res.stdout.trim().length > 0) {
215
+ return { name: "dotnet", status: "pass", detail: `dotnet ${res.stdout.trim()} — BB/.NET scaffold ready` };
216
+ }
217
+ return {
218
+ name: "dotnet",
219
+ status: "warn",
220
+ detail: "dotnet not found (optional — needed for muonroi-building-block scaffolding + quality gate)",
221
+ };
222
+ }
223
+ catch (err) {
224
+ return {
225
+ name: "dotnet",
226
+ status: "warn",
227
+ detail: `dotnet probe failed: ${err.message} (optional — needed for BB scaffolding)`,
228
+ };
229
+ }
230
+ }
196
231
  async function checkQdrant() {
197
232
  try {
198
233
  const qdrantUrl = process.env.QDRANT_URL ?? "http://localhost:6333";
@@ -258,10 +293,10 @@ async function checkCouncilMcpNudge() {
258
293
  // 2. Query DB for [Council Memory] records with URL or research topics
259
294
  const db = getDatabase();
260
295
  const rows = db
261
- .prepare(`SELECT message_json FROM messages
262
- WHERE role = 'system'
263
- AND message_json LIKE '%[Council Memory]%'
264
- ORDER BY created_at DESC
296
+ .prepare(`SELECT message_json FROM messages
297
+ WHERE role = 'system'
298
+ AND message_json LIKE '%[Council Memory]%'
299
+ ORDER BY created_at DESC
265
300
  LIMIT 50`)
266
301
  .all();
267
302
  let qualifyingCount = 0;
@@ -323,6 +358,7 @@ export async function runDoctor() {
323
358
  checkOS(),
324
359
  checkKeyPresence(),
325
360
  checkOllamaHealth(),
361
+ checkDotnet(), // NEW — VERIFY F1: BB/.NET scaffold preflight
326
362
  checkEEDetailed(), // replaces checkEE() — CQ-16c
327
363
  checkBrainEmptiness(), // NEW — CQ-16d
328
364
  checkQdrant(),
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  // RED phase: import module under test (will fail until doctor.ts is created)
3
3
  import { formatDoctorReport, runDoctor } from "./doctor.js";
4
- describe("doctor — runDoctor returns 9 checks", () => {
4
+ describe("doctor — runDoctor returns 10 checks", () => {
5
5
  beforeEach(() => {
6
6
  // Mock fetch to avoid real network calls in tests
7
7
  vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
@@ -10,9 +10,9 @@ describe("doctor — runDoctor returns 9 checks", () => {
10
10
  vi.unstubAllGlobals();
11
11
  vi.restoreAllMocks();
12
12
  });
13
- it("returns exactly 9 CheckResult entries (council_mcp_nudge added in CQ-23)", async () => {
13
+ it("returns exactly 10 CheckResult entries (dotnet added in VERIFY F1)", async () => {
14
14
  const results = await runDoctor();
15
- expect(results).toHaveLength(9);
15
+ expect(results).toHaveLength(10);
16
16
  });
17
17
  it("each CheckResult has valid name, status, and detail fields", async () => {
18
18
  const results = await runDoctor();
@@ -32,6 +32,7 @@ describe("doctor — runDoctor returns 9 checks", () => {
32
32
  expect(names).toContain("os");
33
33
  expect(names).toContain("key_presence");
34
34
  expect(names).toContain("ollama");
35
+ expect(names).toContain("dotnet");
35
36
  expect(names).toContain("ee.health");
36
37
  expect(names).toContain("ee.brain");
37
38
  expect(names).toContain("qdrant");
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildMcpCapabilityBlock } from "../prompts.js";
3
+ describe("buildMcpCapabilityBlock", () => {
4
+ it("returns '' when no MCP tools are connected (non-agent / chitchat / no-client-tools turns add nothing)", () => {
5
+ expect(buildMcpCapabilityBlock([])).toBe("");
6
+ expect(buildMcpCapabilityBlock(["read_file", "grep", "bash", "edit_file"])).toBe("");
7
+ });
8
+ it("names the exact callable mcp_<server>__<tool> tools connected this turn (regression: session f6f7881a5fae)", () => {
9
+ const block = buildMcpCapabilityBlock([
10
+ "read_file",
11
+ "bash",
12
+ "mcp_muonroi-docs__setup_guide",
13
+ "mcp_muonroi-docs__docs_search",
14
+ ]);
15
+ // The failure was the agent not knowing it could call setup_guide directly.
16
+ expect(block).toContain("mcp_muonroi-docs__setup_guide");
17
+ expect(block).toContain("mcp_muonroi-docs__docs_search");
18
+ expect(block).toMatch(/CONNECTED MCP TOOLS/);
19
+ // Steers away from the bash-JSON-RPC fallback the agent actually did.
20
+ expect(block).toMatch(/do NOT shell out to bash/i);
21
+ });
22
+ it("groups tools by server (id with a hyphen split on the first '__')", () => {
23
+ const block = buildMcpCapabilityBlock([
24
+ "mcp_muonroi-docs__setup_guide",
25
+ "mcp_context7__query_docs",
26
+ "mcp_muonroi-docs__docs_search",
27
+ ]);
28
+ // muonroi-docs appears once as a group header with both its tools.
29
+ expect(block.match(/muonroi-docs:/g)?.length).toBe(1);
30
+ expect(block).toMatch(/context7:/);
31
+ });
32
+ it("ignores non-mcp tool names and is deterministic (tools sorted within a server)", () => {
33
+ const block = buildMcpCapabilityBlock(["mcp_srv__b_tool", "write_file", "mcp_srv__a_tool"]);
34
+ expect(block).not.toContain("write_file");
35
+ // a_tool sorts before b_tool → stable output regardless of input order.
36
+ expect(block.indexOf("mcp_srv__a_tool")).toBeLessThan(block.indexOf("mcp_srv__b_tool"));
37
+ });
38
+ });
39
+ //# sourceMappingURL=mcp-capability-block.test.js.map
@@ -0,0 +1,65 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { detectProjectStack } from "../prompts.js";
6
+ // detectProjectStack feeds the ENVIRONMENT block so every model — in any mode,
7
+ // on any provider — knows the concrete stack of the repo it is running inside,
8
+ // instead of assuming Python / asking the user to describe the project
9
+ // (2026-06-14 dogfood: "model native doesn't know what it can do in the CLI").
10
+ describe("detectProjectStack", () => {
11
+ const mkTemp = (slug) => mkdtempSync(join(tmpdir(), `mr-stack-${slug}-`));
12
+ it("detects the current repo as a JS/TS project under git", () => {
13
+ const out = detectProjectStack(process.cwd());
14
+ expect(out).toMatch(/TypeScript|JavaScript/);
15
+ expect(out).toMatch(/vcs: git/);
16
+ });
17
+ it("returns empty string for a bare directory (greenfield)", () => {
18
+ const dir = mkTemp("empty");
19
+ try {
20
+ expect(detectProjectStack(dir)).toBe("");
21
+ }
22
+ finally {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ });
26
+ it("detects a Rust project from Cargo.toml", () => {
27
+ const dir = mkTemp("rust");
28
+ try {
29
+ writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = 'x'\n");
30
+ expect(detectProjectStack(dir)).toMatch(/^Rust/);
31
+ }
32
+ finally {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+ it("detects a .NET project from a .csproj file", () => {
37
+ const dir = mkTemp("net");
38
+ try {
39
+ writeFileSync(join(dir, "App.csproj"), "<Project/>");
40
+ expect(detectProjectStack(dir)).toMatch(/\.NET\/C#/);
41
+ }
42
+ finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+ it("reports package manager + test runner for a bun/vitest TS project", () => {
47
+ const dir = mkTemp("ts");
48
+ try {
49
+ writeFileSync(join(dir, "tsconfig.json"), "{}");
50
+ writeFileSync(join(dir, "bun.lock"), "");
51
+ writeFileSync(join(dir, "vitest.config.ts"), "export default {}");
52
+ const out = detectProjectStack(dir);
53
+ expect(out).toMatch(/TypeScript/);
54
+ expect(out).toMatch(/pkg: bun/);
55
+ expect(out).toMatch(/tests: vitest/);
56
+ }
57
+ finally {
58
+ rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ });
61
+ it("returns empty (no throw) for a missing directory", () => {
62
+ expect(detectProjectStack(join(tmpdir(), "definitely-missing-dir-9f8a7b6c"))).toBe("");
63
+ });
64
+ });
65
+ //# sourceMappingURL=project-stack.test.js.map
@@ -25,9 +25,10 @@
25
25
  // `recordUsage`, `appendCompletedTurn`, `discardAbortedTurn`,
26
26
  // `getCompactedThisTurn` / `setCompactedThisTurn`, etc.) so a future
27
27
  // `TurnRunnerDepsBase` hoist is mechanical.
28
- import { buildMcpToolSet } from "../mcp/runtime.js";
28
+ import { acquireMcpTools } from "../mcp/client-pool.js";
29
29
  import { getProviderCapabilities } from "../providers/capabilities.js";
30
30
  import { requireRuntimeProvider } from "../providers/runtime.js";
31
+ import { openUrl } from "../utils/open-url.js";
31
32
  import { loadMcpServers } from "../utils/settings.js";
32
33
  import { accumulateUsage, buildAssistantBatchMessage, buildBatchChatCompletionRequest, buildBatchName, buildToolBatchMessage, getBatchFinishReason, getBatchUsage, hasUsage, toLocalToolCall, } from "./batch-utils.js";
33
34
  import { relaxCompactionSettings } from "./compaction.js";
@@ -104,17 +105,12 @@ export class BatchTurnRunner {
104
105
  });
105
106
  let tools = !batchCaps.supportsClientTools(runtime.modelInfo) ? {} : baseTools;
106
107
  if (deps.mode === "agent" && batchCaps.supportsClientTools(runtime.modelInfo)) {
107
- const mcpBundle = await buildMcpToolSet(loadMcpServers(), {
108
+ const mcpBundle = await acquireMcpTools(loadMcpServers(), {
108
109
  onOAuthRequired: (_serverId, url) => {
109
- const urlStr = url.toString();
110
- import("child_process").then(({ exec }) => {
111
- const cmd = process.platform === "win32"
112
- ? `start "" "${urlStr}"`
113
- : process.platform === "darwin"
114
- ? `open "${urlStr}"`
115
- : `xdg-open "${urlStr}"`;
116
- exec(cmd);
117
- });
110
+ // Server-supplied URL is untrusted — openUrl validates the scheme
111
+ // and spawns via execFile (no shell), closing the command-injection
112
+ // vector the old exec() opener had.
113
+ openUrl(url);
118
114
  },
119
115
  });
120
116
  closeMcp = mcpBundle.close;
@@ -23,6 +23,8 @@ export declare const DEFAULT_RESERVE_TOKENS = 16384;
23
23
  export declare const DEFAULT_KEEP_RECENT_TOKENS = 20000;
24
24
  export declare const POST_TURN_MIN_TOKENS = 2000;
25
25
  export declare const COMPACTION_MAX_OUTPUT_TOKENS = 4096;
26
+ export declare const COMPACTION_META_MAX_OUTPUT_TOKENS = 1536;
27
+ export declare function metaCompactionMaxTokens(): number;
26
28
  export declare const TOOL_RESULT_MAX_CHARS_CONFIGURABLE = 8000;
27
29
  export declare const COMPACTION_SUMMARY_HEADER = "[Context checkpoint summary]";
28
30
  export declare function extractUserContent(content: unknown): string;
@@ -10,6 +10,19 @@ export const DEFAULT_RESERVE_TOKENS = 16_384;
10
10
  export const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
11
11
  export const POST_TURN_MIN_TOKENS = 2_000;
12
12
  export const COMPACTION_MAX_OUTPUT_TOKENS = 4_096;
13
+ // Meta-analysis (agent/PIL self-eval) summaries are capped tighter than normal
14
+ // to prevent runaway summaries (session df2dbb878984: 73k input → 14k-char
15
+ // summary). Default 1536 (was a hard 1024) — modestly more fidelity now that
16
+ // anti-mù recovery (layer3 surfacing + the in-process/disk artifact cache)
17
+ // backstops detail loss, still ~2.3x below the 14k-char problem. Tune per machine
18
+ // with MUONROI_META_COMPACT_MAX_TOKENS (clamped 512..COMPACTION_MAX_OUTPUT_TOKENS).
19
+ export const COMPACTION_META_MAX_OUTPUT_TOKENS = 1_536;
20
+ export function metaCompactionMaxTokens() {
21
+ const raw = Number(process.env.MUONROI_META_COMPACT_MAX_TOKENS);
22
+ if (Number.isFinite(raw) && raw >= 512 && raw <= COMPACTION_MAX_OUTPUT_TOKENS)
23
+ return Math.floor(raw);
24
+ return COMPACTION_META_MAX_OUTPUT_TOKENS;
25
+ }
13
26
  export const TOOL_RESULT_MAX_CHARS_CONFIGURABLE = 8000;
14
27
  export const COMPACTION_SUMMARY_HEADER = "[Context checkpoint summary]";
15
28
  const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant.
@@ -450,7 +463,7 @@ async function summarizeConversation(provider, modelId, messages, reserveTokens,
450
463
  const userText = messages.map((m) => extractUserContent(m.content)).join("\n");
451
464
  const isMeta = isMetaAnalysisPrompt(userText);
452
465
  const effectiveMax = isMeta
453
- ? Math.min(1024, Math.max(512, Math.floor(reserveTokens * 0.5)))
466
+ ? Math.min(metaCompactionMaxTokens(), Math.max(512, Math.floor(reserveTokens * 0.5)))
454
467
  : Math.min(COMPACTION_MAX_OUTPUT_TOKENS, Math.max(512, Math.floor(reserveTokens * 0.8)));
455
468
  if (previousSummary) {
456
469
  promptParts.push(`Existing summary:\n${previousSummary}`);
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
2
  import { buildEffectiveTranscript } from "../storage/transcript-view.js";
3
- import { COMPACTION_SUMMARY_HEADER, createCompactionSummaryMessage, findCutPoint, prepareCompaction, serializeConversation, shouldCompactContext, } from "./compaction.js";
3
+ import { COMPACTION_META_MAX_OUTPUT_TOKENS, COMPACTION_SUMMARY_HEADER, createCompactionSummaryMessage, findCutPoint, metaCompactionMaxTokens, prepareCompaction, serializeConversation, shouldCompactContext, } from "./compaction.js";
4
4
  import { buildCheckpointReminder } from "./scope-reminder.js";
5
5
  import { __forceFallbackForTests } from "./token-counter.js";
6
6
  // Pin token counts to the chars/4 fallback so cut-point assertions remain stable.
@@ -160,4 +160,28 @@ describe("compaction helpers", () => {
160
160
  expect(r).toContain("tool-artifact");
161
161
  });
162
162
  });
163
+ describe("metaCompactionMaxTokens — meta summary cap (tunable, session 2b7a10219499)", () => {
164
+ it("defaults to 1536 — looser than the old hard 1024, still well below the 14k-char problem", () => {
165
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
166
+ expect(metaCompactionMaxTokens()).toBe(COMPACTION_META_MAX_OUTPUT_TOKENS);
167
+ expect(COMPACTION_META_MAX_OUTPUT_TOKENS).toBe(1536);
168
+ expect(COMPACTION_META_MAX_OUTPUT_TOKENS).toBeGreaterThan(1024);
169
+ });
170
+ it("honors a valid MUONROI_META_COMPACT_MAX_TOKENS override", () => {
171
+ process.env.MUONROI_META_COMPACT_MAX_TOKENS = "2048";
172
+ try {
173
+ expect(metaCompactionMaxTokens()).toBe(2048);
174
+ }
175
+ finally {
176
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
177
+ }
178
+ });
179
+ it("clamps out-of-range / garbage overrides to the default", () => {
180
+ for (const bad of ["999999", "100", "-5", "abc", ""]) {
181
+ process.env.MUONROI_META_COMPACT_MAX_TOKENS = bad;
182
+ expect(metaCompactionMaxTokens(), bad).toBe(COMPACTION_META_MAX_OUTPUT_TOKENS);
183
+ }
184
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
185
+ });
186
+ });
163
187
  //# sourceMappingURL=compaction.test.js.map
@@ -50,6 +50,7 @@
50
50
  // - O1 (providerOptions shape forensics) — extractProviderOptionsShape
51
51
  // - siliconflow reasoning-strip — turnCaps.sanitizeHistory
52
52
  import { stepCountIs, streamText } from "ai";
53
+ import { recordArtifact } from "../ee/artifact-cache.js";
53
54
  import { getCachedAuthToken, getCachedServerBaseUrl } from "../ee/auth.js";
54
55
  import { routeFeedback, routeModel } from "../ee/bridge.js";
55
56
  import { getDefaultEEClient } from "../ee/intercept.js";
@@ -59,7 +60,7 @@ import * as phaseTracker from "../ee/phase-tracker.js";
59
60
  import { buildScope as buildScopeForVeto } from "../ee/scope.js";
60
61
  import { fireTrajectoryEvent } from "../ee/session-trajectory.js";
61
62
  import { getTenantId as getTenantIdForVeto } from "../ee/tenant.js";
62
- import { buildMcpToolSet } from "../mcp/runtime.js";
63
+ import { acquireMcpTools } from "../mcp/client-pool.js";
63
64
  import { dropRedundantFsMcpTools, filterMcpServersByMessage } from "../mcp/smart-filter.js";
64
65
  import { getModelInfo } from "../models/registry.js";
65
66
  import { cheapModelShellLine, injectCheapModelPlaybook, injectCheapModelShellDirective, shouldInjectCheapModelPlaybook, } from "../pil/cheap-model-playbook.js";
@@ -83,6 +84,7 @@ import { visionToolsNeeded } from "../tools/vision-gate.js";
83
84
  import { isDebugEnabled, recordTurnTrace } from "../ui/slash/debug.js";
84
85
  import { statusBarStore } from "../ui/status-bar/store.js";
85
86
  import { appendDecisionLog } from "../usage/decision-log.js";
87
+ import { openUrl } from "../utils/open-url.js";
86
88
  import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
87
89
  import { getAutoCouncilConfidence, getAutoCouncilMinRoles, getProviderStallTimeoutMs, getRoleModels, getTopLevelCompactKeepLast, getTopLevelCompactThresholdChars, getTopLevelToolBudgetChars, isAutoCouncilEnabled, isProviderDisabled, loadMcpServers, loadValidSubAgents, } from "../utils/settings.js";
88
90
  import { resolveShell } from "../utils/shell.js";
@@ -92,7 +94,7 @@ import { humanizeApiError, isAuthenticationError, isContextLimitError, summarize
92
94
  import { buildGroundingFootnote, findUnverifiedClaims } from "./grounding-check.js";
93
95
  import { buildInterruptedTurnNote } from "./interrupted-turn.js";
94
96
  import { stableCallId } from "./pending-calls.js";
95
- import { applyModelConstraints, buildSystemPromptParts } from "./prompts.js";
97
+ import { applyModelConstraints, buildMcpCapabilityBlock, buildSystemPromptParts } from "./prompts.js";
96
98
  import { extractProviderOptionsShape } from "./provider-options-shape.js";
97
99
  import { wrapToolSetWithReadBudget } from "./read-path-budget.js";
98
100
  import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning.js";
@@ -100,11 +102,11 @@ import { repairToolCallHook } from "./repair-tool-call.js";
100
102
  import { buildRepetitionReminder, recordAssistantBurst, shouldInjectRepetitionReminder, } from "./repetition-detector.js";
101
103
  import { classifyStreamError } from "./retry-classifier.js";
102
104
  import { forcedFinalize, getSessionLastTask, incSessionStep, parseBudgetOverride, recordSessionLastTask, resetSessionStep, resolveCeiling, } from "./scope-ceiling.js";
103
- import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, } from "./scope-reminder.js";
105
+ import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
104
106
  import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
105
107
  import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
106
108
  import { wrapToolSetWithCap } from "./sub-agent-cap.js";
107
- import { compactSubAgentMessages } from "./subagent-compactor.js";
109
+ import { compactSubAgentMessages, cumulativeMessageChars } from "./subagent-compactor.js";
108
110
  import { detectTextEmittedToolCall, parseDsmlToolCalls } from "./text-tool-call-detector.js";
109
111
  import { createToolLoopCapPredicate } from "./tool-loop-cap.js";
110
112
  import { buildToolRepetitionAbortMessage, recordToolError as recordToolRepetitionError, recordToolSuccess as recordToolRepetitionSuccess, } from "./tool-repetition-detector.js";
@@ -1017,32 +1019,26 @@ export class MessageProcessor {
1017
1019
  const filteredServers = filterMcpServersByMessage(loadMcpServers(), userMessage, {
1018
1020
  disabled: process.env.MUONROI_DISABLE_SMART_MCP === "1",
1019
1021
  });
1020
- // MCP non-blocking: race the build against a 2500ms cap so a slow
1021
- // stdio MCP server spawn (or many optional servers) does not block
1022
- // the main turn's first token / streamText indefinitely. On timeout
1023
- // or error we fall back to builtins only (domain servers like fs/tools
1024
- // are still valuable but the optional ones can be skipped for this turn).
1022
+ // MCP non-blocking: acquireMcpTools self-bounds it connects servers
1023
+ // in parallel and returns PARTIAL results at its internal deadline
1024
+ // (fast/cached servers included; slow first-connects reported in
1025
+ // .errors and available next turn). Clients are POOLED across turns
1026
+ // (client-pool.ts), so a server cold-spawns at most once per session
1027
+ // instead of every turn. No outer race: the old race discarded the
1028
+ // WHOLE bundle on timeout (Phase 1c — session f6f7881a5fae).
1025
1029
  let mcpBundle = null;
1026
1030
  try {
1027
- mcpBundle = await Promise.race([
1028
- buildMcpToolSet(filteredServers, {
1029
- onOAuthRequired: (_serverId, url) => {
1030
- const urlStr = url.toString();
1031
- import("child_process").then(({ exec }) => {
1032
- const cmd = process.platform === "win32"
1033
- ? `start "" "${urlStr}"`
1034
- : process.platform === "darwin"
1035
- ? `open "${urlStr}"`
1036
- : `xdg-open "${urlStr}"`;
1037
- exec(cmd);
1038
- });
1039
- },
1040
- }),
1041
- new Promise((_, reject) => setTimeout(() => reject(new Error("MCP build timeout (2500ms)")), 2500)),
1042
- ]);
1031
+ mcpBundle = await acquireMcpTools(filteredServers, {
1032
+ onOAuthRequired: (_serverId, url) => {
1033
+ // Server-supplied URL is untrusted — openUrl validates the
1034
+ // scheme and spawns via execFile (no shell), closing the
1035
+ // command-injection vector the old exec() opener had.
1036
+ openUrl(url);
1037
+ },
1038
+ });
1043
1039
  }
1044
1040
  catch (err) {
1045
- console.error("[MCP] buildMcpToolSet timed out or failed, proceeding with builtins only", err);
1041
+ console.error("[MCP] buildMcpToolSet failed, proceeding with builtins only", err);
1046
1042
  }
1047
1043
  if (mcpBundle) {
1048
1044
  closeMcp = mcpBundle.close;
@@ -1056,6 +1052,19 @@ export class MessageProcessor {
1056
1052
  const _builtinToolNames = new Set(Object.keys(rawToolSet));
1057
1053
  const { tools: _dedupedMcpTools, dropped: _droppedFsMcp } = dropRedundantFsMcpTools(mcpBundle.tools, _builtinToolNames);
1058
1054
  rawToolSet = { ...rawToolSet, ..._dedupedMcpTools };
1055
+ // muonroi-tools is THIS CLI: every tool it exposes (ee_query,
1056
+ // ee_feedback, ee_health, usage_forensics, lsp_query, setup_guide,
1057
+ // selfverify_*) is now a NATIVE in-process builtin (src/tools/
1058
+ // native-tools.ts) — strictly better (no subprocess, no cold-start).
1059
+ // If an external/legacy config still self-spawns muonroi-tools, drop
1060
+ // any MCP twin whose native equivalent is present so the model never
1061
+ // sees two interchangeable copies. (The CLI no longer self-spawns it
1062
+ // by default — see auto-setup.ts.)
1063
+ for (const key of Object.keys(rawToolSet)) {
1064
+ const twin = key.match(/^mcp_muonroi-tools__(.+)$/);
1065
+ if (twin && rawToolSet[twin[1]])
1066
+ delete rawToolSet[key];
1067
+ }
1059
1068
  if (_droppedFsMcp.length > 0 && deps.session) {
1060
1069
  try {
1061
1070
  logInteraction(deps.session.id, "routing", {
@@ -1068,7 +1077,20 @@ export class MessageProcessor {
1068
1077
  }
1069
1078
  }
1070
1079
  if (mcpBundle.errors.length > 0) {
1071
- yield { type: "content", content: `MCP unavailable: ${mcpBundle.errors.join(" | ")}\n\n` };
1080
+ // A pooled server that is still cold-starting is NOT "unavailable"
1081
+ // — it's warming up and will be ready next turn. Only surface
1082
+ // GENUINE failures as "unavailable"; show warming servers as a
1083
+ // soft, non-alarming note (and only the first time, since the
1084
+ // pool connects them in the background).
1085
+ const warming = mcpBundle.errors.filter((e) => /still connecting/.test(e));
1086
+ const failed = mcpBundle.errors.filter((e) => !/still connecting/.test(e));
1087
+ if (failed.length > 0) {
1088
+ yield { type: "content", content: `MCP unavailable: ${failed.join(" | ")}\n\n` };
1089
+ }
1090
+ if (warming.length > 0) {
1091
+ const names = warming.map((e) => e.split(":")[0]).join(", ");
1092
+ yield { type: "content", content: `MCP warming up (${names}) — ready from the next turn.\n\n` };
1093
+ }
1072
1094
  }
1073
1095
  }
1074
1096
  }
@@ -1169,6 +1191,15 @@ export class MessageProcessor {
1169
1191
  const systemWithShell = shouldInjectCheapModelPlaybook(runtime.modelInfo)
1170
1192
  ? injectCheapModelShellDirective(systemWithPlaybook, cheapModelShellLine(resolveShell({}).kind, process.platform))
1171
1193
  : systemWithPlaybook;
1194
+ // Append the LIVE MCP tool roster so the agent calls connected MCP
1195
+ // tools by their exact mcp_<server>__<tool> name instead of shelling
1196
+ // out (session f6f7881a5fae). Built from the FINAL toolset for this
1197
+ // iteration (post smart-filter + fs-dedup), so it never names a tool
1198
+ // the model can't actually call. Dynamic per turn → must live OUTSIDE
1199
+ // the cached staticPrefix; for claude it lands in the second
1200
+ // (non-cached) system message via the slice below.
1201
+ const mcpCapabilityBlock = buildMcpCapabilityBlock(Object.keys(tools));
1202
+ const systemWithCaps = mcpCapabilityBlock ? `${systemWithShell}${mcpCapabilityBlock}` : systemWithShell;
1172
1203
  const systemForModel = runtime.modelId.startsWith("claude")
1173
1204
  ? [
1174
1205
  {
@@ -1178,10 +1209,10 @@ export class MessageProcessor {
1178
1209
  },
1179
1210
  {
1180
1211
  role: "system",
1181
- content: systemWithShell.slice(systemParts.staticPrefix.length),
1212
+ content: systemWithCaps.slice(systemParts.staticPrefix.length),
1182
1213
  },
1183
1214
  ]
1184
- : systemWithShell;
1215
+ : systemWithCaps;
1185
1216
  // Capture prompt-size breakdown so recordUsage can attach it to the
1186
1217
  // cost-log entry. Without this, "system prompt is huge" is unfalsifiable.
1187
1218
  // chars/4 ≈ tokens for English; reported as chars to keep math obvious.
@@ -1470,6 +1501,10 @@ export class MessageProcessor {
1470
1501
  const _cwd = process.cwd();
1471
1502
  const _sess = undefined; // best-effort; EE artifact still indexable by content + meta.toolCallId
1472
1503
  const persistArtifact = (toolCallId, toolName, fullContent, reason) => {
1504
+ // Local-first: record the FULL output in-process so ee_query can
1505
+ // rehydrate it even if EE is down (the EE extract below caps at 8k
1506
+ // and needs the network; the cache keeps up to 200k, no network).
1507
+ recordArtifact(toolCallId, toolName, fullContent);
1473
1508
  try {
1474
1509
  getDefaultEEClient()
1475
1510
  .extract({
@@ -1502,9 +1537,14 @@ export class MessageProcessor {
1502
1537
  // Pre-compaction visibility: give the agent one step of notice
1503
1538
  // before B4 actually rewrites history into stubs. This is the
1504
1539
  // advance warning that was missing — agent can now decide to
1505
- // summarize, finish, or request preservation.
1506
- const _preCompactWarnAt = Math.floor(topLevelCompactThreshold * 0.78);
1507
- if (stripped.length > _preCompactWarnAt && compacted === stripped) {
1540
+ // summarize, finish, or request preservation. Fires when we did
1541
+ // NOT compact this step (compacted === stripped, restored by the
1542
+ // compactSubAgentMessages no-op ref contract) AND the prompt is
1543
+ // approaching the threshold. Must compare CHARS (messages +
1544
+ // envelope), not stripped.length (a message count that never
1545
+ // exceeds a char-scaled threshold) — session 2b7a10219499.
1546
+ const _preWarnChars = cumulativeMessageChars(stripped) + envelopeChars;
1547
+ if (compacted === stripped && shouldPreWarnCompaction(_preWarnChars, topLevelCompactThreshold)) {
1508
1548
  const _cp = buildCheckpointReminder(sn, true);
1509
1549
  const _pre = `[pre-compaction warning at step ${sn} — next step(s) will likely rewrite older tool results to stubs (threshold ${topLevelCompactThreshold}, keepLast=${topLevelCompactKeepLast}). ${_cp} Summarize or finish if possible.]`;
1510
1550
  return { messages: attachReminderToMessages(stripped, _pre) };