muonroi-cli 1.4.1 → 1.5.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.
- package/LICENSE +21 -21
- package/README.md +122 -122
- package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
- package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
- package/dist/src/agent-harness/mock-model.d.ts +11 -0
- package/dist/src/agent-harness/mock-model.js +21 -0
- package/dist/src/cli/cost-forensics.js +12 -12
- package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
- package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
- package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
- package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
- package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
- package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
- package/dist/src/council/clarifier.js +9 -1
- package/dist/src/council/debate.js +5 -1
- package/dist/src/council/decisions-lock.js +3 -3
- package/dist/src/council/index.js +12 -5
- package/dist/src/council/leader.d.ts +0 -17
- package/dist/src/council/leader.js +22 -15
- package/dist/src/council/planner.js +1 -1
- package/dist/src/council/prompts.js +63 -57
- package/dist/src/council/types.d.ts +7 -0
- package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
- package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
- package/dist/src/ee/auth.d.ts +9 -0
- package/dist/src/ee/auth.js +19 -0
- package/dist/src/ee/ee-onboarding.d.ts +5 -0
- package/dist/src/ee/ee-onboarding.js +76 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/headless/output.js +6 -4
- package/dist/src/headless/output.test.js +4 -3
- package/dist/src/index.js +20 -1
- package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
- package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
- package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
- package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
- package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
- package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
- package/dist/src/mcp/auto-setup.js +56 -2
- package/dist/src/mcp/client-pool.d.ts +46 -0
- package/dist/src/mcp/client-pool.js +212 -0
- package/dist/src/mcp/oauth-callback.js +2 -2
- package/dist/src/mcp/parse-headers.test.js +14 -14
- package/dist/src/mcp/runtime.d.ts +28 -0
- package/dist/src/mcp/runtime.js +117 -51
- package/dist/src/mcp/self-verify-runner.d.ts +14 -0
- package/dist/src/mcp/self-verify-runner.js +38 -0
- package/dist/src/mcp/setup-guide-text.d.ts +9 -0
- package/dist/src/mcp/setup-guide-text.js +84 -0
- package/dist/src/mcp/smart-filter.js +49 -0
- package/dist/src/mcp/smoke.test.js +43 -43
- package/dist/src/mcp/tools-server.d.ts +7 -0
- package/dist/src/mcp/tools-server.js +19 -22
- package/dist/src/models/catalog.json +349 -349
- package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
- package/dist/src/ops/doctor.d.ts +3 -2
- package/dist/src/ops/doctor.js +47 -11
- package/dist/src/ops/doctor.test.js +4 -3
- package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
- package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
- package/dist/src/orchestrator/batch-turn-runner.js +7 -11
- package/dist/src/orchestrator/message-processor.js +57 -27
- package/dist/src/orchestrator/orchestrator.js +26 -0
- package/dist/src/orchestrator/prompts.d.ts +51 -0
- package/dist/src/orchestrator/prompts.js +257 -134
- package/dist/src/orchestrator/scope-ceiling.js +6 -1
- package/dist/src/orchestrator/stream-runner.js +20 -15
- package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
- package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
- package/dist/src/pil/__tests__/config.test.js +1 -17
- package/dist/src/pil/__tests__/discovery.test.js +144 -11
- package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
- package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
- package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
- package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
- package/dist/src/pil/__tests__/layer6-output.test.js +137 -18
- package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
- package/dist/src/pil/agent-operating-contract.d.ts +1 -1
- package/dist/src/pil/agent-operating-contract.js +2 -0
- package/dist/src/pil/agent-operating-contract.test.js +7 -2
- package/dist/src/pil/cheap-model-playbook.js +35 -35
- package/dist/src/pil/cheap-model-workbooks.js +16 -13
- package/dist/src/pil/clarity-gate.d.ts +21 -19
- package/dist/src/pil/clarity-gate.js +26 -153
- package/dist/src/pil/config.d.ts +9 -1
- package/dist/src/pil/config.js +15 -4
- package/dist/src/pil/discovery.js +211 -136
- package/dist/src/pil/layer1-intent.d.ts +12 -0
- package/dist/src/pil/layer1-intent.js +283 -38
- package/dist/src/pil/layer1-intent.test.js +210 -4
- package/dist/src/pil/layer16-clarity.d.ts +25 -11
- package/dist/src/pil/layer16-clarity.js +19 -306
- package/dist/src/pil/layer4-gsd.js +18 -6
- package/dist/src/pil/layer6-output.d.ts +2 -0
- package/dist/src/pil/layer6-output.js +137 -22
- package/dist/src/pil/llm-classify.d.ts +26 -0
- package/dist/src/pil/llm-classify.js +34 -5
- package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
- package/dist/src/pil/native-capabilities-workbook.js +82 -76
- package/dist/src/pil/schema.d.ts +8 -0
- package/dist/src/pil/schema.js +12 -1
- package/dist/src/pil/task-tier-map.js +4 -0
- package/dist/src/pil/types.d.ts +11 -1
- package/dist/src/product-loop/done-gate.js +3 -3
- package/dist/src/product-loop/loop-driver.js +18 -18
- package/dist/src/product-loop/progress-snapshot.js +4 -4
- package/dist/src/providers/auth/gemini-oauth.js +6 -15
- package/dist/src/providers/auth/grok-oauth.js +6 -15
- package/dist/src/providers/auth/openai-oauth.js +6 -15
- package/dist/src/providers/mcp-vision-bridge.js +48 -48
- package/dist/src/reporter/index.js +1 -1
- package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
- package/dist/src/scaffold/bb-quality-gate.js +5 -5
- package/dist/src/scaffold/continuation-prompt.js +60 -60
- package/dist/src/scaffold/init-new.js +453 -453
- package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
- package/dist/src/self-qa/agentic-loop.js +24 -19
- package/dist/src/self-qa/spec-emitter.js +26 -23
- package/dist/src/storage/__tests__/migrations.test.js +2 -2
- package/dist/src/storage/interaction-log.js +5 -5
- package/dist/src/storage/migrations.js +122 -122
- package/dist/src/storage/sessions.js +42 -42
- package/dist/src/storage/transcript.js +91 -84
- package/dist/src/storage/usage.js +14 -14
- package/dist/src/storage/workspaces.js +12 -12
- package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
- package/dist/src/tools/__tests__/native-tools.test.js +53 -0
- package/dist/src/tools/git-safety.d.ts +61 -0
- package/dist/src/tools/git-safety.js +141 -0
- package/dist/src/tools/git-safety.test.d.ts +1 -0
- package/dist/src/tools/git-safety.test.js +111 -0
- package/dist/src/tools/native-tools.d.ts +31 -0
- package/dist/src/tools/native-tools.js +273 -0
- package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
- package/dist/src/tools/registry-git-safety.test.js +92 -0
- package/dist/src/tools/registry.js +39 -4
- package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
- package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
- package/dist/src/ui/app.js +0 -0
- package/dist/src/ui/components/message-view.js +4 -1
- package/dist/src/ui/components/structured-response-view.js +7 -3
- package/dist/src/ui/components/tool-group.js +7 -1
- package/dist/src/ui/markdown-render.d.ts +41 -0
- package/dist/src/ui/markdown-render.js +223 -0
- package/dist/src/ui/markdown.d.ts +10 -0
- package/dist/src/ui/markdown.js +12 -35
- package/dist/src/ui/slash/council-inspect.js +4 -4
- package/dist/src/ui/slash/export.js +4 -4
- package/dist/src/ui/utils/text.d.ts +8 -0
- package/dist/src/ui/utils/text.js +16 -0
- package/dist/src/ui/utils/text.test.d.ts +1 -0
- package/dist/src/ui/utils/text.test.js +23 -0
- package/dist/src/usage/ledger.js +48 -15
- package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
- package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
- package/dist/src/utils/clipboard-image.js +23 -23
- package/dist/src/utils/open-url.d.ts +56 -0
- package/dist/src/utils/open-url.js +58 -0
- package/dist/src/utils/open-url.test.d.ts +1 -0
- package/dist/src/utils/open-url.test.js +86 -0
- package/dist/src/utils/settings.d.ts +12 -0
- package/dist/src/utils/settings.js +48 -0
- package/dist/src/utils/side-question.js +2 -2
- package/dist/src/utils/skills.js +3 -3
- package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
- package/dist/src/verify/environment.js +2 -1
- package/package.json +1 -1
- package/dist/src/pil/layer16-clarity.test.js +0 -31
- /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseInline } from "../markdown-render.js";
|
|
3
|
+
import { dark } from "../theme.js";
|
|
4
|
+
const t = dark;
|
|
5
|
+
const text = (segs) => segs.map((s) => s.text).join("");
|
|
6
|
+
describe("parseInline — marker concealment", () => {
|
|
7
|
+
it("strips bold markers and styles the inner text", () => {
|
|
8
|
+
const segs = parseInline("a **bold** b", t);
|
|
9
|
+
expect(text(segs)).toBe("a bold b");
|
|
10
|
+
const bold = segs.find((s) => s.text === "bold");
|
|
11
|
+
expect(bold?.bold).toBe(true);
|
|
12
|
+
expect(bold?.fg).toBe(t.mdBold);
|
|
13
|
+
});
|
|
14
|
+
it("strips italic markers (* and _)", () => {
|
|
15
|
+
expect(text(parseInline("an *italic* word", t))).toBe("an italic word");
|
|
16
|
+
expect(text(parseInline("an _italic_ word", t))).toBe("an italic word");
|
|
17
|
+
expect(parseInline("an *italic* word", t).find((s) => s.text === "italic")?.italic).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("handles bold+italic ***x***", () => {
|
|
20
|
+
const seg = parseInline("***wow***", t).find((s) => s.text === "wow");
|
|
21
|
+
expect(seg?.bold).toBe(true);
|
|
22
|
+
expect(seg?.italic).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("strips inline code backticks and colors it", () => {
|
|
25
|
+
const segs = parseInline("call `.catch(next)` here", t);
|
|
26
|
+
expect(text(segs)).toBe("call .catch(next) here");
|
|
27
|
+
expect(segs.find((s) => s.text === ".catch(next)")?.fg).toBe(t.mdCode);
|
|
28
|
+
});
|
|
29
|
+
it("renders link label only, dropping the url", () => {
|
|
30
|
+
const segs = parseInline("see [the docs](https://x/y) now", t);
|
|
31
|
+
expect(text(segs)).toBe("see the docs now");
|
|
32
|
+
expect(text(segs)).not.toContain("https://x/y");
|
|
33
|
+
expect(segs.find((s) => s.text === "the docs")?.underline).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it("strips strikethrough ~~x~~", () => {
|
|
36
|
+
expect(text(parseInline("~~gone~~", t))).toBe("gone");
|
|
37
|
+
});
|
|
38
|
+
it("leaves unterminated markers as literal text (streaming-safe)", () => {
|
|
39
|
+
expect(text(parseInline("a **partial answer", t))).toBe("a **partial answer");
|
|
40
|
+
expect(text(parseInline("trailing `code", t))).toBe("trailing `code");
|
|
41
|
+
});
|
|
42
|
+
it("never leaves ** ` ### markers in styled segments", () => {
|
|
43
|
+
const sample = "**A** and `b` and ***c*** and [d](http://e) and ~~f~~";
|
|
44
|
+
const out = text(parseInline(sample, t));
|
|
45
|
+
expect(out).not.toMatch(/\*\*|`|~~|\]\(/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
//# sourceMappingURL=markdown-render.test.js.map
|
package/dist/src/ui/app.js
CHANGED
|
Binary file
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
|
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import { Markdown } from "../markdown.js";
|
|
4
4
|
import { PlanView } from "../plan.js";
|
|
5
|
-
import { trunc } from "../utils/text.js";
|
|
5
|
+
import { stripStrayModelMacros, trunc } from "../utils/text.js";
|
|
6
6
|
import { describeMcpFsTool, toolArgs, toolLabel, tryParseArg } from "../utils/tools.js";
|
|
7
7
|
import { DiffView, ReadFilePreviewView } from "./diff-view.js";
|
|
8
8
|
import { LspDiagnosticsView, LspResultView } from "./lsp-views.js";
|
|
@@ -29,6 +29,9 @@ const USER_MSG_COLLAPSED_LINES = 5;
|
|
|
29
29
|
// cost. 8 fits comfortably on a short terminal and conveys the gist.
|
|
30
30
|
const ASSISTANT_MSG_COLLAPSED_LINES = 8;
|
|
31
31
|
export function AssistantMessageContent({ content, t, expanded, isFinal, }) {
|
|
32
|
+
// Strip stray model self-annotation macros (e.g. grok's trailing
|
|
33
|
+
// `\confidence{85}`) that leak into the answer — not instructed in the prompt.
|
|
34
|
+
content = stripStrayModelMacros(content);
|
|
32
35
|
const lines = content.split("\n");
|
|
33
36
|
const isLong = lines.length > ASSISTANT_MSG_COLLAPSED_LINES;
|
|
34
37
|
// Phase 5 F7 — the FINAL assistant message in a turn IS the answer the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { renderMarkdown } from "../markdown-render.js";
|
|
2
3
|
export function StructuredResponseView({ t, sr, modeColor }) {
|
|
3
4
|
const d = sr.data;
|
|
4
5
|
switch (sr.taskType) {
|
|
@@ -27,17 +28,20 @@ export function StructuredResponseView({ t, sr, modeColor }) {
|
|
|
27
28
|
}
|
|
28
29
|
case "documentation": {
|
|
29
30
|
const r = d;
|
|
30
|
-
return (_jsxs("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [
|
|
31
|
+
return (_jsxs("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [r.content ? renderMarkdown(r.content, t) : null, (r.examples ?? []).map((ex, i) => (_jsxs("box", { flexDirection: "column", marginTop: 1, children: [_jsx("text", { fg: t.textMuted, children: ex.description }), _jsx("text", { fg: t.mdCode, children: ex.code })] }, `de${i}`)))] }));
|
|
31
32
|
}
|
|
32
33
|
case "generate": {
|
|
33
34
|
const r = d;
|
|
34
35
|
return (_jsxs("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [r.explanation && _jsx("text", { fg: t.textMuted, children: r.explanation }), (r.files ?? []).map((f, i) => (_jsxs("box", { flexDirection: "column", marginTop: 1, children: [_jsxs("text", { children: [_jsx("span", { style: { fg: t.accent }, children: "── " }), _jsx("span", { style: { fg: t.accent }, children: f.path }), _jsx("span", { style: { fg: t.textMuted }, children: ` (${f.language})` }), _jsx("span", { style: { fg: t.accent }, children: " ──" })] }), _jsx("text", { fg: t.mdCodeBlockFg, children: f.content })] }, `gf${i}`)))] }));
|
|
35
36
|
}
|
|
36
37
|
case "general": {
|
|
38
|
+
// `reasoning` is the model's internal justification — deliberately NOT
|
|
39
|
+
// surfaced (it leaked as a "── reasoning:" tail and reads as process
|
|
40
|
+
// narration). The user-facing answer is `response`, rendered as markdown.
|
|
37
41
|
const g = d;
|
|
38
42
|
if (!g.response)
|
|
39
43
|
return _jsx("text", { fg: t.textMuted, children: JSON.stringify(d, null, 2) });
|
|
40
|
-
return (
|
|
44
|
+
return (_jsx("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: renderMarkdown(g.response, t) }));
|
|
41
45
|
}
|
|
42
46
|
default: {
|
|
43
47
|
// Graceful fallback for taskTypes without a dedicated renderer (e.g. a new
|
|
@@ -50,7 +54,7 @@ export function StructuredResponseView({ t, sr, modeColor }) {
|
|
|
50
54
|
(typeof obj.text === "string" && obj.text) ||
|
|
51
55
|
null;
|
|
52
56
|
if (primary) {
|
|
53
|
-
return (_jsxs("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [
|
|
57
|
+
return (_jsxs("box", { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [renderMarkdown(primary, t), _jsx("text", { fg: t.textMuted, children: ` ── (renderer missing for taskType: ${sr.taskType})` })] }));
|
|
54
58
|
}
|
|
55
59
|
return _jsx("text", { fg: t.text, children: JSON.stringify(d, null, 2) });
|
|
56
60
|
}
|
|
@@ -15,6 +15,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
|
15
15
|
import { Semantic } from "@muonroi/agent-harness-opentui";
|
|
16
16
|
import { trunc } from "../utils/text.js";
|
|
17
17
|
import { dominantVerb, toolLabel } from "../utils/tools.js";
|
|
18
|
+
import { DiffView } from "./diff-view.js";
|
|
19
|
+
// Tools whose result carries a FileDiff worth rendering inline under the item
|
|
20
|
+
// line. Mirrors the per-tool branch in message-view.tsx so grouped edits show
|
|
21
|
+
// the same +/- diff a non-grouped tool_result would.
|
|
22
|
+
const DIFF_TOOLS = new Set(["write_file", "edit_file"]);
|
|
18
23
|
// Max items rendered inline while a group is active. Anything beyond gets a
|
|
19
24
|
// "+N more (ctrl+e expand)" affordance — matches Claude Code's overflow line.
|
|
20
25
|
const ACTIVE_INLINE_LIMIT = 8;
|
|
@@ -79,7 +84,8 @@ export function ToolGroupView({ entry, t, expanded, modeColor }) {
|
|
|
79
84
|
return (_jsx(Semantic, { id: `tool-group-${g.id}`, role: "region", name: headerVerb, state: g.state, children: _jsxs("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0, flexDirection: "column", children: [_jsxs("text", { children: [_jsx("span", { style: { fg: headerColor }, children: "● " }), _jsx("span", { style: { fg: t.text }, children: headerVerb }), _jsx("span", { style: { fg: t.textMuted }, children: ` (${stats})` })] }), showItems && visibleItems.length > 0 && (_jsxs("box", { paddingLeft: 2, flexDirection: "column", children: [overflow > 0 && (_jsx("text", { fg: t.textMuted, children: `… +${overflow} earlier ${overflow > 1 ? "tools" : "tool"} (ctrl+e to expand)` })), visibleItems.map((it) => {
|
|
80
85
|
const label = trunc(toolLabel(it.toolCall), 90);
|
|
81
86
|
const errSuffix = it.failed && it.result?.error ? ` — ${trunc(it.result.error.replace(/\s+/g, " "), 60)}` : "";
|
|
82
|
-
|
|
87
|
+
const diff = !it.failed && DIFF_TOOLS.has(it.toolCall.function.name) ? it.result?.diff : undefined;
|
|
88
|
+
return (_jsxs("box", { flexDirection: "column", children: [_jsxs("text", { children: [_jsx("span", { style: { fg: itemColor(it, t) }, children: `${itemGlyph(it)} ` }), _jsx("span", { style: { fg: t.textMuted }, children: label }), errSuffix && _jsx("span", { style: { fg: t.diffRemovedFg }, children: errSuffix })] }), diff && _jsx(DiffView, { t: t, diff: diff })] }, it.toolCall.id));
|
|
83
89
|
})] })), g.state === "done" && !expanded && total > 0 && (_jsx("box", { paddingLeft: 2, children: _jsx("text", { fg: t.textDim, children: "ctrl+e to expand" }) }))] }) }));
|
|
84
90
|
}
|
|
85
91
|
//# sourceMappingURL=tool-group.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/ui/markdown-render.tsx
|
|
3
|
+
*
|
|
4
|
+
* Self-contained markdown → styled-text renderer for the TUI.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the bundled `@opentui/core` MarkdownRenderable (v0.1.107)
|
|
7
|
+
* does NOT support concealing syntax markers — the `conceal` prop passed to
|
|
8
|
+
* `<markdown>` is silently ignored, so `**bold**`, `### heading`, `` `code` ``
|
|
9
|
+
* and `- bullet` all render with their literal markers visible (verified via
|
|
10
|
+
* @opentui/react testRender). On top of that the tree-sitter wasm used for
|
|
11
|
+
* highlight often fails to load, leaving raw unstyled text. The result reads
|
|
12
|
+
* like machine output rather than a rendered answer.
|
|
13
|
+
*
|
|
14
|
+
* This renderer parses the common markdown constructs an LLM answer uses and
|
|
15
|
+
* emits opentui `<text>`/`<span>` nodes with theme colors and the markers
|
|
16
|
+
* stripped. It is intentionally pragmatic (not CommonMark-complete): headings,
|
|
17
|
+
* bold, italic, bold+italic, inline code, strikethrough, links, ordered and
|
|
18
|
+
* unordered lists, blockquotes, fenced code blocks, and horizontal rules.
|
|
19
|
+
* Unterminated inline markers degrade to literal text (streaming-safe).
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from "react";
|
|
22
|
+
import type { Theme } from "./theme.js";
|
|
23
|
+
export interface InlineSegment {
|
|
24
|
+
text: string;
|
|
25
|
+
fg?: string;
|
|
26
|
+
bold?: boolean;
|
|
27
|
+
italic?: boolean;
|
|
28
|
+
underline?: boolean;
|
|
29
|
+
strike?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a single line of inline markdown into styled segments, with markers
|
|
33
|
+
* removed. `base` carries the inherited style (used when recursing into the
|
|
34
|
+
* body of a bold/italic span so emphasis nests).
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseInline(line: string, t: Theme, base?: Partial<InlineSegment>): InlineSegment[];
|
|
37
|
+
/**
|
|
38
|
+
* Render markdown `content` into themed opentui nodes with all syntax markers
|
|
39
|
+
* concealed. Returns a column box; safe to embed inside any flex container.
|
|
40
|
+
*/
|
|
41
|
+
export declare function renderMarkdown(content: string, t: Theme): ReactNode;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { detectLang, tokenize } from "./syntax-highlight.js";
|
|
3
|
+
// Inline markers, longest-first so `***` wins over `**`/`*` and `~~` is whole.
|
|
4
|
+
const INLINE = [
|
|
5
|
+
{ open: "***", close: "***", bold: true, italic: true },
|
|
6
|
+
{ open: "___", close: "___", bold: true, italic: true },
|
|
7
|
+
{ open: "**", close: "**", bold: true },
|
|
8
|
+
{ open: "__", close: "__", bold: true },
|
|
9
|
+
{ open: "~~", close: "~~", strike: true },
|
|
10
|
+
{ open: "*", close: "*", italic: true },
|
|
11
|
+
{ open: "_", close: "_", italic: true },
|
|
12
|
+
];
|
|
13
|
+
function pushText(out, text, base) {
|
|
14
|
+
if (!text)
|
|
15
|
+
return;
|
|
16
|
+
const last = out[out.length - 1];
|
|
17
|
+
// Merge adjacent plain segments to keep the span count low.
|
|
18
|
+
if (last && !last.fg && !last.bold && !last.italic && !last.underline && !last.strike && isPlain(base)) {
|
|
19
|
+
last.text += text;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
out.push({ text, ...base });
|
|
23
|
+
}
|
|
24
|
+
function isPlain(b) {
|
|
25
|
+
return !b.fg && !b.bold && !b.italic && !b.underline && !b.strike;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a single line of inline markdown into styled segments, with markers
|
|
29
|
+
* removed. `base` carries the inherited style (used when recursing into the
|
|
30
|
+
* body of a bold/italic span so emphasis nests).
|
|
31
|
+
*/
|
|
32
|
+
export function parseInline(line, t, base = {}) {
|
|
33
|
+
const out = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
let plainStart = 0;
|
|
36
|
+
const flushPlain = (end) => {
|
|
37
|
+
if (end > plainStart)
|
|
38
|
+
pushText(out, line.slice(plainStart, end), base);
|
|
39
|
+
};
|
|
40
|
+
while (i < line.length) {
|
|
41
|
+
const ch = line[i];
|
|
42
|
+
// Inline code: `code` — highest precedence, no nested formatting inside.
|
|
43
|
+
if (ch === "`") {
|
|
44
|
+
const end = line.indexOf("`", i + 1);
|
|
45
|
+
if (end > i) {
|
|
46
|
+
flushPlain(i);
|
|
47
|
+
out.push({ text: line.slice(i + 1, end), fg: t.mdCode });
|
|
48
|
+
i = end + 1;
|
|
49
|
+
plainStart = i;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Link: [label](url) — show the label as an underlined link, drop the url.
|
|
54
|
+
if (ch === "[") {
|
|
55
|
+
const close = line.indexOf("]", i + 1);
|
|
56
|
+
if (close > i && line[close + 1] === "(") {
|
|
57
|
+
const urlEnd = line.indexOf(")", close + 2);
|
|
58
|
+
if (urlEnd > close) {
|
|
59
|
+
flushPlain(i);
|
|
60
|
+
const label = line.slice(i + 1, close);
|
|
61
|
+
for (const seg of parseInline(label, t, { ...base, fg: t.mdLinkText, underline: true }))
|
|
62
|
+
out.push(seg);
|
|
63
|
+
i = urlEnd + 1;
|
|
64
|
+
plainStart = i;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Emphasis markers.
|
|
70
|
+
let matched = false;
|
|
71
|
+
for (const m of INLINE) {
|
|
72
|
+
if (!line.startsWith(m.open, i))
|
|
73
|
+
continue;
|
|
74
|
+
const close = line.indexOf(m.close, i + m.open.length);
|
|
75
|
+
if (close < 0)
|
|
76
|
+
continue; // unterminated → treat as literal
|
|
77
|
+
const inner = line.slice(i + m.open.length, close);
|
|
78
|
+
if (inner.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
flushPlain(i);
|
|
81
|
+
const isBold = "bold" in m && m.bold;
|
|
82
|
+
const isItalic = "italic" in m && m.italic;
|
|
83
|
+
const isStrike = "strike" in m && m.strike;
|
|
84
|
+
const childBase = {
|
|
85
|
+
...base,
|
|
86
|
+
bold: base.bold || isBold || undefined,
|
|
87
|
+
italic: base.italic || isItalic || undefined,
|
|
88
|
+
strike: base.strike || isStrike || undefined,
|
|
89
|
+
fg: base.fg ?? (isBold ? t.mdBold : isItalic ? t.mdItalic : isStrike ? t.textMuted : undefined),
|
|
90
|
+
};
|
|
91
|
+
for (const seg of parseInline(inner, t, childBase))
|
|
92
|
+
out.push(seg);
|
|
93
|
+
i = close + m.close.length;
|
|
94
|
+
plainStart = i;
|
|
95
|
+
matched = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (matched)
|
|
99
|
+
continue;
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
flushPlain(line.length);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)\s*$/;
|
|
106
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
107
|
+
const HR = /^\s*([-*_])\1{2,}\s*$/;
|
|
108
|
+
const BULLET = /^(\s*)[-*+]\s+(.*)$/;
|
|
109
|
+
const ORDERED = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
|
110
|
+
const QUOTE = /^\s*>\s?(.*)$/;
|
|
111
|
+
function parseBlocks(content, t) {
|
|
112
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
113
|
+
const blocks = [];
|
|
114
|
+
let i = 0;
|
|
115
|
+
while (i < lines.length) {
|
|
116
|
+
const line = lines[i];
|
|
117
|
+
const fence = FENCE.exec(line);
|
|
118
|
+
if (fence) {
|
|
119
|
+
const marker = fence[1];
|
|
120
|
+
const lang = fence[2] ?? "";
|
|
121
|
+
const body = [];
|
|
122
|
+
i++;
|
|
123
|
+
while (i < lines.length && !new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`).test(lines[i])) {
|
|
124
|
+
body.push(lines[i]);
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
i++; // consume closing fence (or run off the end — streaming-safe)
|
|
128
|
+
blocks.push({ kind: "code", lang, lines: body });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (line.trim() === "") {
|
|
132
|
+
blocks.push({ kind: "blank" });
|
|
133
|
+
i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (HR.test(line)) {
|
|
137
|
+
blocks.push({ kind: "hr" });
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const h = HEADING.exec(line);
|
|
142
|
+
if (h) {
|
|
143
|
+
blocks.push({ kind: "heading", level: h[1].length, segs: parseInline(h[2], t, { fg: t.mdHeading, bold: true }) });
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const q = QUOTE.exec(line);
|
|
148
|
+
if (q) {
|
|
149
|
+
blocks.push({ kind: "quote", segs: parseInline(q[1], t, { fg: t.mdItalic, italic: true }) });
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const b = BULLET.exec(line);
|
|
154
|
+
if (b) {
|
|
155
|
+
blocks.push({ kind: "bullet", indent: b[1].length, segs: parseInline(b[2], t) });
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const o = ORDERED.exec(line);
|
|
160
|
+
if (o) {
|
|
161
|
+
blocks.push({ kind: "ordered", indent: o[1].length, marker: `${o[2]}.`, segs: parseInline(o[3], t) });
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
blocks.push({ kind: "para", segs: parseInline(line, t) });
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
return blocks;
|
|
169
|
+
}
|
|
170
|
+
function Spans({ segs }) {
|
|
171
|
+
// opentui sets bold/italic/underline via the <b>/<i>/<u> text-modifier
|
|
172
|
+
// elements, NOT via span style flags. Compose them around a colored <span>.
|
|
173
|
+
return (_jsx(_Fragment, { children: segs.map((s, i) => {
|
|
174
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: segments are positional within a line
|
|
175
|
+
let node = (_jsx("span", { style: s.fg ? { fg: s.fg } : undefined, children: s.text }, `s${i}`));
|
|
176
|
+
if (s.underline)
|
|
177
|
+
node = _jsx("u", { children: node }, `u${i}`);
|
|
178
|
+
if (s.italic)
|
|
179
|
+
node = _jsx("i", { children: node }, `i${i}`);
|
|
180
|
+
if (s.bold)
|
|
181
|
+
node = _jsx("b", { children: node }, `b${i}`);
|
|
182
|
+
return node;
|
|
183
|
+
}) }));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Render markdown `content` into themed opentui nodes with all syntax markers
|
|
187
|
+
* concealed. Returns a column box; safe to embed inside any flex container.
|
|
188
|
+
*/
|
|
189
|
+
export function renderMarkdown(content, t) {
|
|
190
|
+
const blocks = parseBlocks(content, t);
|
|
191
|
+
return (_jsx("box", { flexDirection: "column", flexShrink: 0, children: blocks.map((blk, idx) => {
|
|
192
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: blocks are positional and re-rendered wholesale
|
|
193
|
+
const key = `b${idx}`;
|
|
194
|
+
switch (blk.kind) {
|
|
195
|
+
case "blank":
|
|
196
|
+
return _jsx("box", { height: 1 }, key);
|
|
197
|
+
case "hr":
|
|
198
|
+
return (_jsx("text", { fg: t.mdHr, children: "─".repeat(40) }, key));
|
|
199
|
+
case "heading":
|
|
200
|
+
return (_jsx("text", { marginTop: idx === 0 ? 0 : 1, children: _jsx(Spans, { segs: blk.segs }) }, key));
|
|
201
|
+
case "quote":
|
|
202
|
+
return (_jsxs("text", { children: [_jsx("span", { style: { fg: t.mdHr }, children: "▏ " }), _jsx(Spans, { segs: blk.segs })] }, key));
|
|
203
|
+
case "bullet":
|
|
204
|
+
return (_jsxs("text", { children: [_jsx("span", { style: { fg: t.text }, children: " ".repeat(blk.indent) }), _jsx("span", { style: { fg: t.mdListBullet }, children: "• " }), _jsx(Spans, { segs: blk.segs })] }, key));
|
|
205
|
+
case "ordered":
|
|
206
|
+
return (_jsxs("text", { children: [_jsx("span", { style: { fg: t.text }, children: " ".repeat(blk.indent) }), _jsx("span", { style: { fg: t.mdListBullet }, children: `${blk.marker} ` }), _jsx(Spans, { segs: blk.segs })] }, key));
|
|
207
|
+
case "code": {
|
|
208
|
+
const lang = detectLang(`x.${blk.lang || "txt"}`);
|
|
209
|
+
return (_jsx("box", { backgroundColor: t.mdCodeBlockBg, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: blk.lines.map((ln, j) => {
|
|
210
|
+
const toks = lang === "plain" ? [] : tokenize(ln, lang, t);
|
|
211
|
+
return (
|
|
212
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: code lines are positional
|
|
213
|
+
_jsx("text", { children: toks.length === 0 ? (_jsx("span", { style: { fg: t.mdCodeBlockFg }, children: ln || " " })) : (toks.map((tok, k) => (
|
|
214
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: token positions are stable per render
|
|
215
|
+
_jsx("span", { style: { fg: tok.fg }, children: tok.text }, `t${k}`)))) }, `c${j}`));
|
|
216
|
+
}) }, key));
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
return (_jsx("text", { children: _jsx(Spans, { segs: blk.segs }) }, key));
|
|
220
|
+
}
|
|
221
|
+
}) }));
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=markdown-render.js.map
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { Theme } from "./theme.js";
|
|
2
|
+
/**
|
|
3
|
+
* Render markdown to themed TUI text.
|
|
4
|
+
*
|
|
5
|
+
* Historically this delegated to opentui's `<markdown>` renderable with
|
|
6
|
+
* `conceal`, but the bundled @opentui/core (0.1.107) ignores `conceal` and
|
|
7
|
+
* leaves `**`/`###`/`` ` `` markers visible (and frequently fails to load the
|
|
8
|
+
* tree-sitter wasm for highlighting). We now render markdown ourselves via
|
|
9
|
+
* `renderMarkdown`, which strips markers and applies theme colors. See
|
|
10
|
+
* markdown-render.tsx for the construct coverage.
|
|
11
|
+
*/
|
|
2
12
|
export declare function Markdown({ content, t }: {
|
|
3
13
|
content: string;
|
|
4
14
|
t: Theme;
|
package/dist/src/ui/markdown.js
CHANGED
|
@@ -1,38 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"markup.italic": { fg: RGBA.fromHex(t.mdItalic), italic: true },
|
|
13
|
-
"markup.raw": { fg: RGBA.fromHex(t.mdCode) },
|
|
14
|
-
"markup.link": { fg: RGBA.fromHex(t.mdLink), underline: true },
|
|
15
|
-
"markup.link.label": { fg: RGBA.fromHex(t.mdLinkText) },
|
|
16
|
-
"markup.list": { fg: RGBA.fromHex(t.mdListBullet) },
|
|
17
|
-
"markup.quote": { fg: RGBA.fromHex(t.mdItalic), italic: true },
|
|
18
|
-
"markup.separator": { fg: RGBA.fromHex(t.mdHr) },
|
|
19
|
-
code: { fg: RGBA.fromHex(t.mdCodeBlockFg), bg: RGBA.fromHex(t.mdCodeBlockBg) },
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
const TABLE_OPTIONS = {
|
|
23
|
-
widthMode: "full",
|
|
24
|
-
columnFitter: "balanced",
|
|
25
|
-
wrapMode: "word",
|
|
26
|
-
cellPadding: 1,
|
|
27
|
-
borders: true,
|
|
28
|
-
outerBorder: true,
|
|
29
|
-
borderStyle: "rounded",
|
|
30
|
-
borderColor: "#333333",
|
|
31
|
-
};
|
|
1
|
+
import { renderMarkdown } from "./markdown-render.js";
|
|
2
|
+
/**
|
|
3
|
+
* Render markdown to themed TUI text.
|
|
4
|
+
*
|
|
5
|
+
* Historically this delegated to opentui's `<markdown>` renderable with
|
|
6
|
+
* `conceal`, but the bundled @opentui/core (0.1.107) ignores `conceal` and
|
|
7
|
+
* leaves `**`/`###`/`` ` `` markers visible (and frequently fails to load the
|
|
8
|
+
* tree-sitter wasm for highlighting). We now render markdown ourselves via
|
|
9
|
+
* `renderMarkdown`, which strips markers and applies theme colors. See
|
|
10
|
+
* markdown-render.tsx for the construct coverage.
|
|
11
|
+
*/
|
|
32
12
|
export function Markdown({ content, t }) {
|
|
33
|
-
|
|
34
|
-
return (_jsx("markdown", { content: content, syntaxStyle: syntaxStyle, conceal: true,
|
|
35
|
-
// @ts-expect-error MarkdownProps omits inherited Renderable.selectable; needed for TUI text selection
|
|
36
|
-
selectable: true, tableOptions: TABLE_OPTIONS, flexShrink: 0 }));
|
|
13
|
+
return renderMarkdown(content, t);
|
|
37
14
|
}
|
|
38
15
|
//# sourceMappingURL=markdown.js.map
|
|
@@ -37,10 +37,10 @@ export const handleCouncilInspectSlash = async (args) => {
|
|
|
37
37
|
}
|
|
38
38
|
// Load all system messages for this session — parameterized query prevents SQL injection (T-17-04)
|
|
39
39
|
const rows = db
|
|
40
|
-
.prepare(`SELECT role, message_json, seq, created_at
|
|
41
|
-
FROM messages
|
|
42
|
-
WHERE session_id = ?
|
|
43
|
-
AND role = 'system'
|
|
40
|
+
.prepare(`SELECT role, message_json, seq, created_at
|
|
41
|
+
FROM messages
|
|
42
|
+
WHERE session_id = ?
|
|
43
|
+
AND role = 'system'
|
|
44
44
|
ORDER BY seq ASC`)
|
|
45
45
|
.all(sessionId);
|
|
46
46
|
if (rows.length === 0) {
|
|
@@ -25,10 +25,10 @@ function selectInteractionTimeline(sessionId) {
|
|
|
25
25
|
try {
|
|
26
26
|
const db = getDatabase();
|
|
27
27
|
return db
|
|
28
|
-
.prepare(`SELECT event_type, event_subtype, metadata_json, created_at
|
|
29
|
-
FROM interaction_logs
|
|
30
|
-
WHERE session_id = ?
|
|
31
|
-
AND event_type IN ('ui_interaction', 'routing', 'council', 'ee_injection')
|
|
28
|
+
.prepare(`SELECT event_type, event_subtype, metadata_json, created_at
|
|
29
|
+
FROM interaction_logs
|
|
30
|
+
WHERE session_id = ?
|
|
31
|
+
AND event_type IN ('ui_interaction', 'routing', 'council', 'ee_injection')
|
|
32
32
|
ORDER BY created_at ASC, id ASC`)
|
|
33
33
|
.all(sessionId);
|
|
34
34
|
}
|
|
@@ -4,3 +4,11 @@ export declare function truncateLine(s: string, n: number): string;
|
|
|
4
4
|
export declare function truncateBlock(text: string, maxLines: number): string;
|
|
5
5
|
export declare function compactTaskLabel(label: string): string;
|
|
6
6
|
export declare function sanitizeContent(raw: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Strip stray model self-annotation macros that leak into the user-facing answer
|
|
9
|
+
* but are NOT instructed anywhere in the prompt. Currently: a trailing
|
|
10
|
+
* `\confidence{NN}` macro emitted intermittently by grok-build. Conservative —
|
|
11
|
+
* only the `\confidence{...}` form is removed, so legitimate LaTeX/code in an
|
|
12
|
+
* answer (e.g. `\frac{a}{b}`) is untouched. Fast-pathed: no work when absent.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stripStrayModelMacros(text: string): string;
|
|
@@ -29,4 +29,20 @@ export function sanitizeContent(raw) {
|
|
|
29
29
|
s = s.replace(/\{"success"\s*:\s*(true|false)\s*,\s*"output"\s*:\s*"[\s\S]*$/m, "");
|
|
30
30
|
return s.trim();
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Strip stray model self-annotation macros that leak into the user-facing answer
|
|
34
|
+
* but are NOT instructed anywhere in the prompt. Currently: a trailing
|
|
35
|
+
* `\confidence{NN}` macro emitted intermittently by grok-build. Conservative —
|
|
36
|
+
* only the `\confidence{...}` form is removed, so legitimate LaTeX/code in an
|
|
37
|
+
* answer (e.g. `\frac{a}{b}`) is untouched. Fast-pathed: no work when absent.
|
|
38
|
+
*/
|
|
39
|
+
export function stripStrayModelMacros(text) {
|
|
40
|
+
if (!text || !text.includes("\\confidence"))
|
|
41
|
+
return text;
|
|
42
|
+
// Trailing form (most common) — also swallow the whitespace/newline before it.
|
|
43
|
+
let out = text.replace(/\s*\\confidence\s*\{[^}]*\}\s*$/i, "");
|
|
44
|
+
// Any remaining mid-text occurrences.
|
|
45
|
+
out = out.replace(/\\confidence\s*\{[^}]*\}/gi, "");
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
32
48
|
//# sourceMappingURL=text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stripStrayModelMacros } from "./text.js";
|
|
3
|
+
describe("stripStrayModelMacros", () => {
|
|
4
|
+
it("strips a trailing \\confidence{NN} macro and the blank line before it", () => {
|
|
5
|
+
const input = "Root cause: parseInt radix.\n\nFix: use radix 10.\n\n\\confidence{85}";
|
|
6
|
+
expect(stripStrayModelMacros(input)).toBe("Root cause: parseInt radix.\n\nFix: use radix 10.");
|
|
7
|
+
});
|
|
8
|
+
it("strips a mid-text \\confidence macro too", () => {
|
|
9
|
+
expect(stripStrayModelMacros("answer \\confidence{90} continues")).toBe("answer continues");
|
|
10
|
+
});
|
|
11
|
+
it("is a no-op when no macro is present (fast path)", () => {
|
|
12
|
+
const clean = "A normal answer with `\\frac{a}{b}` LaTeX that must survive.";
|
|
13
|
+
expect(stripStrayModelMacros(clean)).toBe(clean);
|
|
14
|
+
});
|
|
15
|
+
it("does not touch other backslash macros (conservative)", () => {
|
|
16
|
+
const latex = "Use \\frac{1}{2} and \\sum_{i}.";
|
|
17
|
+
expect(stripStrayModelMacros(latex)).toBe(latex);
|
|
18
|
+
});
|
|
19
|
+
it("handles empty / falsy input", () => {
|
|
20
|
+
expect(stripStrayModelMacros("")).toBe("");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=text.test.js.map
|
package/dist/src/usage/ledger.js
CHANGED
|
@@ -63,26 +63,59 @@ async function ensureUsageFile(filePath) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* In-process serialization per usage.json path.
|
|
68
|
+
*
|
|
69
|
+
* proper-lockfile guards CROSS-process access, but its FS-mtime staleness
|
|
70
|
+
* check can admit a second concurrent critical section under CPU starvation:
|
|
71
|
+
* a holder that is descheduled long enough fails to refresh the lock mtime,
|
|
72
|
+
* a competitor deems the lock stale and steals it, and two reserve() bodies
|
|
73
|
+
* run at once. Reproduced under 6-process contention — exactly one extra
|
|
74
|
+
* reservation slips past a $1.00 cap (6 × $0.18 = $1.08 > cap).
|
|
75
|
+
*
|
|
76
|
+
* A JS promise-chain mutex makes same-process reserve/commit/release bursts
|
|
77
|
+
* strictly serial. This is the dominant real case (10 parallel tool calls in
|
|
78
|
+
* one CLI process) and closes the in-process overshoot window entirely; the
|
|
79
|
+
* file lock still handles genuine multi-process access.
|
|
80
|
+
*/
|
|
81
|
+
const pathMutex = new Map();
|
|
82
|
+
function withPathMutex(filePath, fn) {
|
|
83
|
+
const prev = pathMutex.get(filePath) ?? Promise.resolve();
|
|
84
|
+
// Run regardless of the prior caller's outcome (success or rejection).
|
|
85
|
+
const run = prev.then(fn, fn);
|
|
86
|
+
// Stored tail swallows rejection so one failed caller never rejects queued
|
|
87
|
+
// ones; prune the map entry when this tail is the last in the chain.
|
|
88
|
+
const tail = run.then(() => { }, () => { });
|
|
89
|
+
pathMutex.set(filePath, tail);
|
|
90
|
+
void tail.then(() => {
|
|
91
|
+
if (pathMutex.get(filePath) === tail)
|
|
92
|
+
pathMutex.delete(filePath);
|
|
93
|
+
});
|
|
94
|
+
return run;
|
|
95
|
+
}
|
|
66
96
|
/**
|
|
67
97
|
* Execute a function under exclusive file lock on usage.json.
|
|
68
|
-
* The lock prevents racing readers/writers across CLI processes
|
|
98
|
+
* The lock prevents racing readers/writers across CLI processes; the
|
|
99
|
+
* in-process mutex serializes concurrent callers within this process.
|
|
69
100
|
*/
|
|
70
101
|
async function withLock(filePath, fn) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
return withPathMutex(filePath, async () => {
|
|
103
|
+
await ensureUsageFile(filePath);
|
|
104
|
+
const releaseLock = await lockfile.lock(filePath, {
|
|
105
|
+
retries: { retries: 10, minTimeout: 10, maxTimeout: 100 },
|
|
106
|
+
stale: 5_000,
|
|
107
|
+
realpath: false,
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
const state = (await atomicReadJSON(filePath)) ?? emptyState();
|
|
111
|
+
const { next, result } = await fn(state);
|
|
112
|
+
await atomicWriteJSON(filePath, next);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await releaseLock();
|
|
117
|
+
}
|
|
76
118
|
});
|
|
77
|
-
try {
|
|
78
|
-
const state = (await atomicReadJSON(filePath)) ?? emptyState();
|
|
79
|
-
const { next, result } = await fn(state);
|
|
80
|
-
await atomicWriteJSON(filePath, next);
|
|
81
|
-
return result;
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
await releaseLock();
|
|
85
|
-
}
|
|
86
119
|
}
|
|
87
120
|
/**
|
|
88
121
|
* Reserve projected token spend against the monthly cap.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|