muonroi-cli 1.6.0 → 1.6.2

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 (54) hide show
  1. package/dist/src/cli/cost-forensics.d.ts +3 -0
  2. package/dist/src/cli/cost-forensics.js +11 -0
  3. package/dist/src/cli/cost-forensics.test.js +1 -0
  4. package/dist/src/cli/experience-report.d.ts +20 -0
  5. package/dist/src/cli/experience-report.js +76 -0
  6. package/dist/src/cli/experience-report.test.d.ts +5 -0
  7. package/dist/src/cli/experience-report.test.js +63 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/gsd/__tests__/directives.test.js +24 -1
  11. package/dist/src/gsd/directives.d.ts +22 -0
  12. package/dist/src/gsd/directives.js +34 -10
  13. package/dist/src/index.js +9 -0
  14. package/dist/src/mcp/__tests__/client-pool.spec.js +54 -4
  15. package/dist/src/mcp/__tests__/forensics-tools.test.js +1 -0
  16. package/dist/src/mcp/client-pool.d.ts +9 -2
  17. package/dist/src/mcp/client-pool.js +60 -21
  18. package/dist/src/orchestrator/message-processor.js +34 -2
  19. package/dist/src/orchestrator/session-experience.d.ts +89 -0
  20. package/dist/src/orchestrator/session-experience.js +169 -0
  21. package/dist/src/orchestrator/session-experience.test.d.ts +6 -0
  22. package/dist/src/orchestrator/session-experience.test.js +72 -0
  23. package/dist/src/orchestrator/stream-runner.js +4 -0
  24. package/dist/src/orchestrator/subagent-compactor.d.ts +10 -0
  25. package/dist/src/orchestrator/subagent-compactor.js +14 -0
  26. package/dist/src/orchestrator/subagent-compactor.spec.js +54 -0
  27. package/dist/src/pil/__tests__/layer3-ee-injection.test.js +5 -3
  28. package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +31 -0
  29. package/dist/src/pil/__tests__/pipeline.test.js +17 -0
  30. package/dist/src/pil/layer3-ee-injection.d.ts +9 -0
  31. package/dist/src/pil/layer3-ee-injection.js +29 -0
  32. package/dist/src/pil/layer4-gsd.js +3 -2
  33. package/dist/src/pil/pipeline.js +11 -0
  34. package/dist/src/pil/session-experience-injection.d.ts +34 -0
  35. package/dist/src/pil/session-experience-injection.js +54 -0
  36. package/dist/src/pil/session-experience-injection.test.d.ts +6 -0
  37. package/dist/src/pil/session-experience-injection.test.js +79 -0
  38. package/dist/src/storage/interaction-log.d.ts +1 -1
  39. package/dist/src/storage/interaction-log.js +17 -4
  40. package/dist/src/storage/session-experience-store.d.ts +63 -0
  41. package/dist/src/storage/session-experience-store.js +164 -0
  42. package/dist/src/storage/session-experience-store.test.d.ts +5 -0
  43. package/dist/src/storage/session-experience-store.test.js +86 -0
  44. package/dist/src/storage/tool-results.js +23 -0
  45. package/dist/src/storage/tool-results.test.d.ts +1 -0
  46. package/dist/src/storage/tool-results.test.js +48 -0
  47. package/dist/src/storage/ui-interaction-log.js +4 -2
  48. package/dist/src/tools/registry-ee-query.test.js +7 -1
  49. package/dist/src/tools/registry.js +7 -0
  50. package/dist/src/types/index.d.ts +6 -0
  51. package/dist/src/ui/__tests__/markdown-render.test.js +17 -0
  52. package/dist/src/ui/app.js +0 -0
  53. package/dist/src/ui/markdown-render.js +12 -0
  54. package/package.json +1 -1
@@ -0,0 +1,86 @@
1
+ /**
2
+ * session-experience-store — persist + cross-session aggregate of the anti-mù
3
+ * counters that decide whether compaction friction is real at a painful rate.
4
+ */
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ vi.mock("./db.js", () => ({ getDatabase: vi.fn(() => ({ prepare: () => ({ all: () => [] }) })) }));
7
+ const logInteraction = vi.fn();
8
+ vi.mock("./interaction-log.js", () => ({ logInteraction: (...a) => logInteraction(...a) }));
9
+ import { computeExperienceAggregate, persistSessionExperience, } from "./session-experience-store.js";
10
+ function counts(p = {}) {
11
+ return {
12
+ compactions: 0,
13
+ elided: 0,
14
+ totalElidedChars: 0,
15
+ rehydratedCache: 0,
16
+ rehydratedDisk: 0,
17
+ rehydratedEe: 0,
18
+ unavailable: 0,
19
+ eeTimeouts: 0,
20
+ eeErrors: 0,
21
+ ...p,
22
+ };
23
+ }
24
+ function row(sessionId, createdAt, c) {
25
+ return { session_id: sessionId, created_at: createdAt, metadata_json: JSON.stringify(counts(c)) };
26
+ }
27
+ describe("persistSessionExperience", () => {
28
+ afterEach(() => logInteraction.mockClear());
29
+ it("no-ops on a missing sessionId", () => {
30
+ persistSessionExperience(undefined, counts({ elided: 3 }));
31
+ persistSessionExperience("", counts({ elided: 3 }));
32
+ expect(logInteraction).not.toHaveBeenCalled();
33
+ });
34
+ it("no-ops on an all-zero snapshot (no signal to store)", () => {
35
+ persistSessionExperience("sess-1", counts());
36
+ expect(logInteraction).not.toHaveBeenCalled();
37
+ });
38
+ it("writes a session_experience snapshot when something happened", () => {
39
+ persistSessionExperience("sess-1", counts({ compactions: 2, elided: 5, rehydratedCache: 1 }));
40
+ expect(logInteraction).toHaveBeenCalledTimes(1);
41
+ const [sid, type, meta] = logInteraction.mock.calls[0];
42
+ expect(sid).toBe("sess-1");
43
+ expect(type).toBe("session_experience");
44
+ expect(meta.data.elided).toBe(5);
45
+ });
46
+ });
47
+ describe("computeExperienceAggregate", () => {
48
+ it("dedups to the latest row per session (rows newest-first) and sums totals", () => {
49
+ const rows = [
50
+ // sess-a newest first (cumulative) then an older row that must be ignored
51
+ row("sess-a", "2026-06-17T10:00:00Z", { compactions: 3, elided: 6, rehydratedCache: 4, unavailable: 1 }),
52
+ row("sess-a", "2026-06-17T09:00:00Z", { compactions: 1, elided: 2 }),
53
+ row("sess-b", "2026-06-17T08:00:00Z", { compactions: 1, elided: 2, rehydratedEe: 1, unavailable: 1 }),
54
+ ];
55
+ const agg = computeExperienceAggregate(rows);
56
+ expect(agg.sessionCount).toBe(2);
57
+ expect(agg.totals.elided).toBe(8); // 6 (latest a) + 2 (b), NOT the stale 2
58
+ expect(agg.totals.compactions).toBe(4); // 3 + 1
59
+ expect(agg.sessionsWithElision).toBe(2);
60
+ expect(agg.sessionsWithUnavailable).toBe(2);
61
+ // recovery = rehydrated(4+0+1) / (rehydrated 5 + unavailable 2) = 5/7
62
+ expect(agg.rehydrateRecoveryRate).toBeCloseTo(5 / 7, 5);
63
+ });
64
+ it("recovery rate is 1 when no rehydrate was ever attempted", () => {
65
+ const agg = computeExperienceAggregate([row("s", "2026-06-17T10:00:00Z", { compactions: 1, elided: 2 })]);
66
+ expect(agg.rehydrateRecoveryRate).toBe(1);
67
+ expect(agg.sessionsWithUnavailable).toBe(0);
68
+ });
69
+ it("caps at `limit` sessions and skips unparseable rows", () => {
70
+ const rows = [
71
+ row("s1", "2026-06-17T10:00:03Z", { elided: 1 }),
72
+ { session_id: "s2", created_at: "2026-06-17T10:00:02Z", metadata_json: "{bad json" },
73
+ row("s3", "2026-06-17T10:00:01Z", { elided: 1 }),
74
+ ];
75
+ const agg = computeExperienceAggregate(rows, 1);
76
+ expect(agg.sessionCount).toBe(1);
77
+ expect(agg.perSession[0].sessionId).toBe("s1");
78
+ });
79
+ it("empty input yields an empty aggregate with recovery rate 1", () => {
80
+ const agg = computeExperienceAggregate([]);
81
+ expect(agg.sessionCount).toBe(0);
82
+ expect(agg.totals.elided).toBe(0);
83
+ expect(agg.rehydrateRecoveryRate).toBe(1);
84
+ });
85
+ });
86
+ //# sourceMappingURL=session-experience-store.test.js.map
@@ -31,6 +31,29 @@ export function extractToolResultFromOutput(output) {
31
31
  output: String(output.value),
32
32
  };
33
33
  }
34
+ // MCP tool results: `{ type: "content", value: [{ type: "text", text }, ...] }`
35
+ // (see cap-tool-result.ts). Before this branch, extraction returned null, so
36
+ // persisted output_json was the raw envelope with NO `success` field — on
37
+ // reload the renderer read `toolResult.success` as undefined and displayed
38
+ // "Error" for a SUCCESSFUL call (session 63f2d542b772: 50 muonroi-docs calls,
39
+ // 0 DB failures, all shown as "Error"). Flatten the text parts so it round-
40
+ // trips as a real ToolResult. A genuinely failed MCP call throws → the SDK
41
+ // records an `error-text` part, handled above, so content == success here.
42
+ if ("type" in output && output.type === "content" && "value" in output && Array.isArray(output.value)) {
43
+ const parts = output.value;
44
+ const text = parts
45
+ .filter((p) => !!p &&
46
+ typeof p === "object" &&
47
+ p.type === "text" &&
48
+ typeof p.text === "string")
49
+ .map((p) => p.text)
50
+ .join("\n");
51
+ const nonText = parts.length - parts.filter((p) => p?.type === "text").length;
52
+ return {
53
+ success: true,
54
+ output: text || (nonText > 0 ? `[${nonText} non-text MCP part(s)]` : "(empty MCP result)"),
55
+ };
56
+ }
34
57
  return null;
35
58
  }
36
59
  export function getOutputKind(output) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractToolResultFromOutput, isOutputSuccess } from "./tool-results.js";
3
+ describe("extractToolResultFromOutput", () => {
4
+ it("passes through a native ToolResult shape", () => {
5
+ const r = extractToolResultFromOutput({ success: true, output: "hi" });
6
+ expect(r).toMatchObject({ success: true, output: "hi" });
7
+ });
8
+ it("treats error-text as a failure", () => {
9
+ const r = extractToolResultFromOutput({ type: "error-text", value: "boom" });
10
+ expect(r).toMatchObject({ success: false, error: "boom" });
11
+ });
12
+ it("flattens an MCP content envelope into a successful ToolResult (session 63f2d542b772)", () => {
13
+ // MCP tools return { type: "content", value: [{ type: "text", text }] }.
14
+ // Before the fix this returned null, so the persisted output_json had no
15
+ // `success` field and the renderer showed "Error" for a successful call.
16
+ const out = {
17
+ type: "content",
18
+ value: [
19
+ { type: "text", text: "package list" },
20
+ { type: "text", text: "more" },
21
+ ],
22
+ };
23
+ const r = extractToolResultFromOutput(out);
24
+ expect(r).toMatchObject({ success: true, output: "package list\nmore" });
25
+ });
26
+ it("round-trips through JSON so the renderer reads success=true (the actual bug)", () => {
27
+ // transcript.loadStoredToolResults does JSON.parse(output_json) and the
28
+ // renderer reads `.success`. Simulate persist→load and assert it is NOT
29
+ // misread as an error.
30
+ const mcpOutput = { type: "content", value: [{ type: "text", text: "## Muonroi.Core\nNuGet: Muonroi.Core" }] };
31
+ const persisted = JSON.stringify(extractToolResultFromOutput(mcpOutput));
32
+ const loaded = JSON.parse(persisted);
33
+ const rendered = loaded.success ? loaded.output || "Success" : loaded.error || "Error";
34
+ expect(rendered).toContain("Muonroi.Core");
35
+ expect(rendered).not.toBe("Error");
36
+ });
37
+ it("describes a non-text-only MCP content result instead of dropping to Error", () => {
38
+ const out = { type: "content", value: [{ type: "image", data: "..." }] };
39
+ const r = extractToolResultFromOutput(out);
40
+ expect(r?.success).toBe(true);
41
+ expect(r?.output).toMatch(/non-text MCP part/);
42
+ });
43
+ it("isOutputSuccess still treats content envelopes as success", () => {
44
+ expect(isOutputSuccess({ type: "content", value: [] })).toBe(true);
45
+ expect(isOutputSuccess({ type: "error-text", value: "x" })).toBe(false);
46
+ });
47
+ });
48
+ //# sourceMappingURL=tool-results.test.js.map
@@ -27,8 +27,10 @@ export function logUIInteraction(sessionId, payload) {
27
27
  data: payload.data,
28
28
  });
29
29
  }
30
- catch {
31
- // Fail-open
30
+ catch (err) {
31
+ // Fail-open (logInteraction is itself guarded; this is defensive). Surface
32
+ // the subtype so a serialization fault here is at least diagnosable.
33
+ console.error(`[ui-interaction-log] persist failed for subtype=${payload.subtype}: ${err?.message}`);
32
34
  }
33
35
  }
34
36
  //# sourceMappingURL=ui-interaction-log.js.map
@@ -11,6 +11,7 @@
11
11
  import os from "node:os";
12
12
  import { afterEach, describe, expect, it } from "vitest";
13
13
  import { __resetArtifactCacheForTests, recordArtifact } from "../ee/artifact-cache.js";
14
+ import { __resetSessionExperienceForTests, getSessionExperience } from "../orchestrator/session-experience.js";
14
15
  import { BashTool } from "./bash.js";
15
16
  import { createBuiltinTools, isToolArtifactQuery } from "./registry.js";
16
17
  describe("ee_query builtin tool", () => {
@@ -47,7 +48,10 @@ describe("isToolArtifactQuery — ee_query intent routing", () => {
47
48
  });
48
49
  });
49
50
  describe("ee_query — anti-mù rehydrate (local-first, durable when EE is down)", () => {
50
- afterEach(() => __resetArtifactCacheForTests());
51
+ afterEach(() => {
52
+ __resetArtifactCacheForTests();
53
+ __resetSessionExperienceForTests();
54
+ });
51
55
  it("rehydrates a tool-artifact from the in-session cache with NO EE/network call", async () => {
52
56
  // Simulates: the compactor elided this output earlier (recordArtifact), EE is
53
57
  // now down. The agent's ee_query("tool-artifact id=X") must still return the
@@ -60,6 +64,8 @@ describe("ee_query — anti-mù rehydrate (local-first, durable when EE is down)
60
64
  expect(out).toContain("tool=read_file");
61
65
  expect(out).toContain("FULL ELIDED CONTENT");
62
66
  expect(out).not.toMatch(/ee_unavailable/);
67
+ // Lived-experience telemetry recorded the cache-sourced rehydrate.
68
+ expect(getSessionExperience().rehydrations.cache).toBe(1);
63
69
  });
64
70
  });
65
71
  //# sourceMappingURL=registry-ee-query.test.js.map
@@ -471,10 +471,15 @@ export function createBuiltinTools(bash, mode, opts) {
471
471
  // lookup this is the authoritative full content for THIS session and
472
472
  // works even when EE is down — the failure window long sessions hit.
473
473
  const { findArtifactByQuery, findArtifactOnDisk } = await import("../ee/artifact-cache.js");
474
+ // Lived-experience telemetry: record where the rehydrate came from so
475
+ // a "cảm nhận trong CLI" question (and the measure-first instrumentation)
476
+ // sees cache vs disk vs ee vs needed-but-unavailable.
477
+ const { recordRehydration } = await import("../orchestrator/session-experience.js");
474
478
  const mem = findArtifactByQuery(query);
475
479
  const local = mem ?? (await findArtifactOnDisk(query));
476
480
  if (local) {
477
481
  const src = mem ? "in-session cache" : "local disk cache";
482
+ recordRehydration(mem ? "cache" : "disk");
478
483
  return truncateOutput(`[tool-artifact id=${local.toolCallId} tool=${local.toolName} — rehydrated from ${src}]\n${local.content}`);
479
484
  }
480
485
  // EE fallback (cross-session / post-restart) → raw /api/search exact lookup.
@@ -484,8 +489,10 @@ export function createBuiltinTools(bash, mode, opts) {
484
489
  ...(typeof input?.limit === "number" ? { limit: input.limit } : {}),
485
490
  });
486
491
  if (resp === null) {
492
+ recordRehydration("unavailable");
487
493
  return "[ee_unavailable] Experience Engine returned no response (server down, timeout, circuit open, or unconfigured) and the artifact is not in this session's local cache. Proceed without EE recall — re-read the source directly if you need the elided content.";
488
494
  }
495
+ recordRehydration("ee");
489
496
  return truncateOutput(JSON.stringify(resp));
490
497
  }
491
498
  // General recall → /api/recall (recallMode, [id col] index + surface).
@@ -308,6 +308,12 @@ export interface ExperienceWarningData {
308
308
  export interface ExperienceInjectedData {
309
309
  pointCount: number;
310
310
  pointIds: string[];
311
+ /** Per-point detail so the TUI can show WHAT was injected, not just the count. */
312
+ points?: Array<{
313
+ id: string;
314
+ title: string;
315
+ tier: "principle" | "behavioral" | "checkpoint";
316
+ }>;
311
317
  scoreFloor: number;
312
318
  taskType?: string;
313
319
  domain?: string;
@@ -39,6 +39,23 @@ describe("parseInline — marker concealment", () => {
39
39
  expect(text(parseInline("a **partial answer", t))).toBe("a **partial answer");
40
40
  expect(text(parseInline("trailing `code", t))).toBe("trailing `code");
41
41
  });
42
+ it("does NOT treat intra-word underscores as emphasis (identifiers stay intact)", () => {
43
+ // Session 584ba476c07a rendered `mcp_filesystem__list_directory` as
44
+ // "mcpfilesystemlistdirectory" — underscores eaten as italic/bold.
45
+ expect(text(parseInline("mcp_filesystem__list_directory", t))).toBe("mcp_filesystem__list_directory");
46
+ expect(text(parseInline("a snake_case name", t))).toBe("a snake_case name");
47
+ expect(text(parseInline("call mcp_muonroi-docs__setup_guide first", t))).toBe("call mcp_muonroi-docs__setup_guide first");
48
+ // None of these should be emphasized.
49
+ expect(parseInline("mcp_filesystem__list_directory", t).some((s) => s.italic || s.bold)).toBe(false);
50
+ });
51
+ it("still emphasizes underscores at word boundaries", () => {
52
+ expect(text(parseInline("an _italic_ word", t))).toBe("an italic word");
53
+ expect(parseInline("an _italic_ word", t).find((s) => s.text === "italic")?.italic).toBe(true);
54
+ expect(text(parseInline("a __bold__ word", t))).toBe("a bold word");
55
+ expect(parseInline("a __bold__ word", t).find((s) => s.text === "bold")?.bold).toBe(true);
56
+ // Underscore emphasis adjacent to punctuation still works.
57
+ expect(parseInline("(_em_)", t).find((s) => s.text === "em")?.italic).toBe(true);
58
+ });
42
59
  it("never leaves ** ` ### markers in styled segments", () => {
43
60
  const sample = "**A** and `b` and ***c*** and [d](http://e) and ~~f~~";
44
61
  const out = text(parseInline(sample, t));
Binary file
@@ -77,6 +77,18 @@ export function parseInline(line, t, base = {}) {
77
77
  const inner = line.slice(i + m.open.length, close);
78
78
  if (inner.length === 0)
79
79
  continue;
80
+ // CommonMark: underscore does NOT open/close emphasis intra-word, so
81
+ // identifiers like `mcp_filesystem__list_directory` or `snake_case` keep
82
+ // their underscores instead of being eaten as italic/bold (session
83
+ // 584ba476c07a rendered "mcpfilesystemlistdirectory"). Asterisk markers
84
+ // keep intraword behaviour. Reject when a word char hugs the marker on the
85
+ // word-internal side.
86
+ if (m.open[0] === "_") {
87
+ const before = i > 0 ? line[i - 1] : "";
88
+ const after = line[close + m.close.length] ?? "";
89
+ if (/[A-Za-z0-9]/.test(before) || /[A-Za-z0-9]/.test(after))
90
+ continue;
91
+ }
80
92
  flushPlain(i);
81
93
  const isBold = "bold" in m && m.bold;
82
94
  const isItalic = "italic" in m && m.italic;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.6.0",
6
+ "version": "1.6.2",
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",