muonroi-cli 1.6.2 → 1.6.3

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.
@@ -1,2 +1,2 @@
1
- export declare const PACKAGE_VERSION = "1.6.2";
1
+ export declare const PACKAGE_VERSION = "1.6.3";
2
2
  export declare const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/sync-version.cjs. DO NOT EDIT BY HAND.
2
2
  // Sourced from package.json at build time so it survives bun --compile bundling.
3
- export const PACKAGE_VERSION = "1.6.2";
3
+ export const PACKAGE_VERSION = "1.6.3";
4
4
  export const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
5
5
  //# sourceMappingURL=version.js.map
@@ -109,6 +109,27 @@ describe("acquireMcpTools — cross-turn client pool", () => {
109
109
  expect(results.every((r) => r === "pong")).toBe(true);
110
110
  expect(connectOneServer).toHaveBeenCalledTimes(2); // 14 failures → exactly ONE shared reconnect
111
111
  });
112
+ it("waits for a criticalServerId past the normal deadline so it lands THIS turn (session 584ba476c07a)", async () => {
113
+ // Normal deadline is 500ms (mock). docs connects at ~700ms — past the normal
114
+ // deadline but within the critical window → must be included when critical.
115
+ connectOneServer.mockImplementation((s) => new Promise((res) => {
116
+ if (s.id === "docs")
117
+ setTimeout(() => res(connected(s.id)), 700);
118
+ else
119
+ res(connected(s.id));
120
+ }));
121
+ const b = await acquireMcpTools([srv("docs")], { criticalServerIds: ["docs"], criticalDeadlineMs: 3000 });
122
+ expect(Object.keys(b.tools)).toContain("mcp_docs__ping");
123
+ expect(b.errors).toHaveLength(0);
124
+ });
125
+ it("without criticalServerIds, a slow server is reported still-connecting (available next turn)", async () => {
126
+ connectOneServer.mockImplementation((s) => new Promise((res) => {
127
+ setTimeout(() => res(connected(s.id)), 700);
128
+ }));
129
+ const b = await acquireMcpTools([srv("docs")]);
130
+ expect(Object.keys(b.tools)).not.toContain("mcp_docs__ping");
131
+ expect(b.errors.some((e) => /still connecting/.test(e))).toBe(true);
132
+ });
112
133
  it("keys by cwd/config — a different command reconnects rather than reusing", async () => {
113
134
  connectOneServer.mockImplementation(async (s) => connected(s.id));
114
135
  await acquireMcpTools([
@@ -179,6 +179,28 @@ export async function acquireMcpTools(servers, opts) {
179
179
  await Promise.race([Promise.allSettled(attempts), deadline]);
180
180
  if (deadlineTimer)
181
181
  clearTimeout(deadlineTimer);
182
+ // Critical-server extended wait: a turn that MUST have a specific server (e.g.
183
+ // muonroi-docs on an ecosystem question) waits for just that server's connect
184
+ // beyond the normal deadline, so a cold first-connect is included THIS turn
185
+ // rather than reported "still connecting → next turn". Only the named servers
186
+ // are awaited; everything already settled is untouched (no added latency for
187
+ // normal turns, which pass no criticalServerIds).
188
+ const critical = new Set((opts?.criticalServerIds ?? []).filter(Boolean));
189
+ if (critical.size > 0) {
190
+ const pendingIdx = enabled.map((s, i) => ({ s, i })).filter(({ s, i }) => critical.has(s.id) && !slots[i].done);
191
+ if (pendingIdx.length > 0) {
192
+ const criticalDeadlineMs = Math.max(deadlineMs, opts?.criticalDeadlineMs ?? 8000);
193
+ const extraMs = Math.max(0, criticalDeadlineMs - deadlineMs);
194
+ let extraTimer;
195
+ const extraDeadline = new Promise((resolve) => {
196
+ extraTimer = setTimeout(resolve, extraMs);
197
+ extraTimer.unref?.();
198
+ });
199
+ await Promise.race([Promise.allSettled(pendingIdx.map(({ i }) => attempts[i])), extraDeadline]);
200
+ if (extraTimer)
201
+ clearTimeout(extraTimer);
202
+ }
203
+ }
182
204
  for (let i = 0; i < slots.length; i++) {
183
205
  const slot = slots[i];
184
206
  if (slot.done) {
@@ -8,6 +8,17 @@ export interface McpToolBundle {
8
8
  }
9
9
  export interface McpBuildOptions {
10
10
  onOAuthRequired?: (serverId: string, url: URL) => void;
11
+ /**
12
+ * Server ids the CURRENT turn critically needs (e.g. muonroi-docs on an
13
+ * ecosystem question). acquireMcpTools waits for these specifically beyond the
14
+ * normal build deadline — up to `criticalDeadlineMs` — so a cold first-connect
15
+ * is included THIS turn instead of "ready next turn" (session 584ba476c07a:
16
+ * first ecosystem question missed muonroi-docs while it was still warming).
17
+ * Other servers are unaffected — only the named ones get the extended wait.
18
+ */
19
+ criticalServerIds?: string[];
20
+ /** Extended ceiling (ms) for criticalServerIds. Default 8000. */
21
+ criticalDeadlineMs?: number;
11
22
  }
12
23
  /**
13
24
  * Total wall-clock budget for building the MCP tool set. Servers connect in
@@ -60,6 +60,7 @@ import * as phaseTracker from "../ee/phase-tracker.js";
60
60
  import { buildScope as buildScopeForVeto } from "../ee/scope.js";
61
61
  import { fireTrajectoryEvent } from "../ee/session-trajectory.js";
62
62
  import { getTenantId as getTenantIdForVeto } from "../ee/tenant.js";
63
+ import { mentionsEcosystemScope } from "../gsd/directives.js";
63
64
  import { acquireMcpTools } from "../mcp/client-pool.js";
64
65
  import { dropRedundantFsMcpTools, filterMcpServersByMessage } from "../mcp/smart-filter.js";
65
66
  import { getModelInfo } from "../models/registry.js";
@@ -1022,6 +1023,16 @@ export class MessageProcessor {
1022
1023
  const filteredServers = filterMcpServersByMessage(loadMcpServers(), userMessage, {
1023
1024
  disabled: process.env.MUONROI_DISABLE_SMART_MCP === "1",
1024
1025
  });
1026
+ // Ecosystem question → muonroi-docs is the authoritative source the
1027
+ // agent is nudged to consult FIRST. Wait for it specifically beyond the
1028
+ // normal deadline so a cold first-connect lands THIS turn instead of
1029
+ // "ready next turn" (session 584ba476c07a: first ecosystem question
1030
+ // missed docs while warming → agent guessed from local files).
1031
+ const criticalServerIds = mentionsEcosystemScope(userMessage)
1032
+ ? filteredServers
1033
+ .filter((s) => /(^|[-_])docs([-_]|$)/.test(s.id) && /muonroi/i.test(s.id))
1034
+ .map((s) => s.id)
1035
+ : undefined;
1025
1036
  // MCP non-blocking: acquireMcpTools self-bounds — it connects servers
1026
1037
  // in parallel and returns PARTIAL results at its internal deadline
1027
1038
  // (fast/cached servers included; slow first-connects reported in
@@ -1038,6 +1049,7 @@ export class MessageProcessor {
1038
1049
  // command-injection vector the old exec() opener had.
1039
1050
  openUrl(url);
1040
1051
  },
1052
+ ...(criticalServerIds && criticalServerIds.length > 0 ? { criticalServerIds } : {}),
1041
1053
  });
1042
1054
  }
1043
1055
  catch (err) {
@@ -104,6 +104,16 @@ describe("layer4Gsd (gsd-native)", () => {
104
104
  const result = await layer4Gsd(makeCtx({ raw, enriched: raw, taskType: "analyze", intentKind: "task", deliverableKind: "answer" }));
105
105
  expect(result.enriched).toContain("QUESTION / explanatory");
106
106
  });
107
+ it("deliverableKind='report' is informational (no council/discuss scaffold) — session 666630479c1a", async () => {
108
+ // "Đọc và tóm tắt kiến trúc…" classifies as deliverableKind 'report'. A
109
+ // report is human-facing with NO code change, so it must route to the
110
+ // QUESTION directive, not the heavy implement/discuss/council scaffold that
111
+ // over-asked with askcards on a read/summarize task.
112
+ const raw = "đọc và tóm tắt kiến trúc src/orchestrator, src/pil, src/mcp kèm file:line";
113
+ const result = await layer4Gsd(makeCtx({ raw, enriched: raw, taskType: "analyze", intentKind: "task", deliverableKind: "report" }));
114
+ expect(result.enriched).toContain("QUESTION / explanatory");
115
+ expect(result.enriched).not.toContain("MANDATORY");
116
+ });
107
117
  it("Phase 2b: deliverableKind='code' is NOT informational even for a question-shaped prompt", async () => {
108
118
  // The raw text reads as a question — the legacy regex would mark it
109
119
  // informational. The model's deliverableKind='code' must override that so
@@ -84,15 +84,20 @@ export async function layer4Gsd(ctx) {
84
84
  // into the human-facing reply as a "2-3 line plan" + process narration
85
85
  // (session 829a83888dd2). Route them to the human-facing question directive.
86
86
  //
87
- // Phase 2b: when the model classified the deliverable, CONSUME it an
88
- // "answer" deliverable IS informational. Only when the model didn't emit one
89
- // (deliverableKind null legacy cascade, or the model omitted the word) do
90
- // we fall back to the legacy regex predicates:
87
+ // Phase 2b: when the model classified the deliverable, CONSUME it. Both an
88
+ // "answer" AND a "report" deliverable are HUMAN-FACING with no code change, so
89
+ // both are informational only "code" routes through the implement/verify (and
90
+ // heavy discuss/council) scaffold. Treating "report" as non-informational sent
91
+ // read/summarize/architecture tasks (deliverableKind "report") down the heavy
92
+ // council + AskUserQuestion path, over-asking on a task that just wanted a
93
+ // written summary (session 666630479c1a: "Đọc và tóm tắt kiến trúc…" raised 2
94
+ // askcards + a council loop). Only when the model emitted no deliverable
95
+ // (deliverableKind null → legacy cascade) do we fall back to regex predicates:
91
96
  // 1. isMetaAnalysisPrompt — self/CLI evaluation, prior-turn reflection.
92
97
  // 2. taskType "general" classified as a real task by L1.
93
98
  // 3. question-shaped prompt that is NOT an implementation request.
94
99
  const informational = ctx.deliverableKind
95
- ? ctx.deliverableKind === "answer"
100
+ ? ctx.deliverableKind !== "code"
96
101
  : isMetaAnalysisPrompt(ctx.raw) ||
97
102
  (ctx.taskType === "general" && ctx.intentKind === "task") ||
98
103
  (isQuestionLike(ctx.raw) && !isImplementationIntent(ctx.raw));
@@ -2525,7 +2525,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
2525
2525
  applyLocalAssistantDelta(`\n⚠ [Experience] ${eeChunk.experienceWarning?.message ?? eeChunk.content ?? ""}\nWhy: ${eeChunk.experienceWarning?.why ?? ""}\n`);
2526
2526
  }
2527
2527
  else if (eeChunk.type === "experience_injected") {
2528
- applyLocalAssistantDelta(`\n💡 [Experience Injected] ${eeChunk.experienceInjected?.pointCount ?? 0} point(s) loaded (score ≥ ${eeChunk.experienceInjected?.scoreFloor ?? 0})\n`);
2528
+ applyLocalAssistantDelta(formatExperienceInjectedBlock(eeChunk.experienceInjected ?? {}));
2529
2529
  }
2530
2530
  });
2531
2531
  for await (const chunk of agent.processMessage(text.trim(), undefined, images)) {
@@ -3472,10 +3472,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
3472
3472
  },
3473
3473
  ];
3474
3474
  }
3475
- return [
3476
- ...prev,
3477
- buildAssistantEntry(`💡 [Experience Injected] ${chunk.experienceInjected.pointCount} point(s)`),
3478
- ];
3475
+ return [...prev, buildAssistantEntry(formatExperienceInjectedBlock(chunk.experienceInjected))];
3479
3476
  });
3480
3477
  }
3481
3478
  if (chunk.type === "halt" && chunk.haltChunk) {
@@ -3694,10 +3691,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
3694
3691
  },
3695
3692
  ];
3696
3693
  }
3697
- return [
3698
- ...prev,
3699
- buildAssistantEntry(`💡 [Experience Injected] ${chunk.experienceInjected.pointCount} point(s)`),
3700
- ];
3694
+ return [...prev, buildAssistantEntry(formatExperienceInjectedBlock(chunk.experienceInjected))];
3701
3695
  });
3702
3696
  }
3703
3697
  if (chunk.type === "done")
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.6.2",
6
+ "version": "1.6.3",
7
7
  "description": "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.",
8
8
  "repository": {
9
9
  "type": "git",