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.
- package/dist/src/cli/cost-forensics.d.ts +3 -0
- package/dist/src/cli/cost-forensics.js +11 -0
- package/dist/src/cli/cost-forensics.test.js +1 -0
- package/dist/src/cli/experience-report.d.ts +20 -0
- package/dist/src/cli/experience-report.js +76 -0
- package/dist/src/cli/experience-report.test.d.ts +5 -0
- package/dist/src/cli/experience-report.test.js +63 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/gsd/__tests__/directives.test.js +24 -1
- package/dist/src/gsd/directives.d.ts +22 -0
- package/dist/src/gsd/directives.js +34 -10
- package/dist/src/index.js +9 -0
- package/dist/src/mcp/__tests__/client-pool.spec.js +54 -4
- package/dist/src/mcp/__tests__/forensics-tools.test.js +1 -0
- package/dist/src/mcp/client-pool.d.ts +9 -2
- package/dist/src/mcp/client-pool.js +60 -21
- package/dist/src/orchestrator/message-processor.js +34 -2
- package/dist/src/orchestrator/session-experience.d.ts +89 -0
- package/dist/src/orchestrator/session-experience.js +169 -0
- package/dist/src/orchestrator/session-experience.test.d.ts +6 -0
- package/dist/src/orchestrator/session-experience.test.js +72 -0
- package/dist/src/orchestrator/stream-runner.js +4 -0
- package/dist/src/orchestrator/subagent-compactor.d.ts +10 -0
- package/dist/src/orchestrator/subagent-compactor.js +14 -0
- package/dist/src/orchestrator/subagent-compactor.spec.js +54 -0
- package/dist/src/pil/__tests__/layer3-ee-injection.test.js +5 -3
- package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +31 -0
- package/dist/src/pil/__tests__/pipeline.test.js +17 -0
- package/dist/src/pil/layer3-ee-injection.d.ts +9 -0
- package/dist/src/pil/layer3-ee-injection.js +29 -0
- package/dist/src/pil/layer4-gsd.js +3 -2
- package/dist/src/pil/pipeline.js +11 -0
- package/dist/src/pil/session-experience-injection.d.ts +34 -0
- package/dist/src/pil/session-experience-injection.js +54 -0
- package/dist/src/pil/session-experience-injection.test.d.ts +6 -0
- package/dist/src/pil/session-experience-injection.test.js +79 -0
- package/dist/src/storage/interaction-log.d.ts +1 -1
- package/dist/src/storage/interaction-log.js +17 -4
- package/dist/src/storage/session-experience-store.d.ts +63 -0
- package/dist/src/storage/session-experience-store.js +164 -0
- package/dist/src/storage/session-experience-store.test.d.ts +5 -0
- package/dist/src/storage/session-experience-store.test.js +86 -0
- package/dist/src/storage/tool-results.js +23 -0
- package/dist/src/storage/tool-results.test.d.ts +1 -0
- package/dist/src/storage/tool-results.test.js +48 -0
- package/dist/src/storage/ui-interaction-log.js +4 -2
- package/dist/src/tools/registry-ee-query.test.js +7 -1
- package/dist/src/tools/registry.js +7 -0
- package/dist/src/types/index.d.ts +6 -0
- package/dist/src/ui/__tests__/markdown-render.test.js +17 -0
- package/dist/src/ui/app.js +0 -0
- package/dist/src/ui/markdown-render.js +12 -0
- 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(() =>
|
|
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));
|
package/dist/src/ui/app.js
CHANGED
|
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;
|