diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 5f4fc8844..d15167939 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -20,8 +20,6 @@ "ignore": [ // Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts. "packages/natives/native/index.js", - // Generated by `packages/coding-agent/scripts/generate-docs-index.ts`. - "packages/coding-agent/src/internal-urls/docs-index.generated.ts", // Embedded HTML asset shipped as a static template, not a code module. "packages/coding-agent/src/export/html/template.js", // Generated/owned upstream — see packages/ai/scripts/generate-models.ts. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..772bd36cd --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,107 @@ +# The list of vouched (or actively denounced) users for this repository. +# +# Only vouched users can open PRs here; unvouched/denounced PRs are +# auto-closed by .github/workflows/vouch-pr.yml (mitchellh/vouch check-pr). +# Issues are intentionally NOT gated here — robomp triages those. +# +# Write-access collaborators and bots are auto-allowed and need no entry. +# A denounced user ("-handle") is always blocked, even if also listed. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optional platform prefix: `platform:username` (default platform: github). +# - Denounce by prefixing with minus: `-username` / `-platform:username`. +# - Optional free-text reason after a space following the handle. +# +# Maintainers manage this list by commenting `!vouch` / `!denounce [user]` +# on a discussion (see .github/workflows/vouch-manage.yml). +# +# Seed (2026-06-19): authors with >=2 merged PRs in the prior 6 months. +# Audit found 0 denounce-worthy actors; all reverts were maintainer +# technical rollbacks, not abuse. See the PR/commit history for provenance. + +a-glapinski +ak4153 +apoc +AsafMah +azais-corentin +basedcorp99 +BayLee4 +bjin +cagedbird043 +cexll +chan1103 +chuaaron +ckumar1 +daandden +daaximus +danzaio +DarkPhilosophy +DeprecatedLuke +djdembeck +dmarsh-gusto +elikoga +enieuwy +ForeverYoungPp +GratefulDave +H4vC +HabibPro1999 +handlecusion +haosenwang1018 +hezhiyang2000 +infernix +inprealpha +itertea +jchristman +jiwangyihao +kamafozilov +KamijoToma +Kukkerem +ldx +lederniermagicien +loftiskg +lyc-aon +makoMakoGo +masonc15 +maximhar +maxvisionai +metaphorics +MikeeI +Mokto +mouyase +muness +nnk97 +ogrodev +oldschoola +Parsifa1 +phanthh +pidevxplay +qfrtt +ravshansbox +rburketaylor +RensTillmann +riverpilot +romanalexander +RzNmKX +scarthread +segmentationf4u1t +shoucandanghehe +shyndman +sit +slact +smileynet +sundbp +superhedge22 +tc97222 +tdiant +tsagi2045 +turbomolli +usr-bin-roygbiv +vmcall +VoidChecksum +voiys +watzon +WodenJay +wolfiesch +zakhar-kogan +zamorakpds diff --git a/.github/workflows/vouch-manage.yml b/.github/workflows/vouch-manage.yml new file mode 100644 index 000000000..e7ce41b11 --- /dev/null +++ b/.github/workflows/vouch-manage.yml @@ -0,0 +1,44 @@ +name: Vouch (manage) + +# Let maintainers vouch/denounce/unvouch by commenting on a Discussion: +# !vouch vouch the discussion author +# !vouch @user [reason] vouch a specific user +# !denounce [@user] [reason] +# !unvouch [@user] +# Only collaborators with admin/maintain/write are honored (triage EXCLUDED; +# upstream's default `roles` includes triage, which we override below). +# +# Commits the VOUCHED.td change back to the default branch using the stock +# GITHUB_TOKEN (no GitHub App needed). NOTE: this works only while the default +# branch is UNPROTECTED — GITHUB_TOKEN cannot bypass branch protection. If you +# protect the branch later, switch back to a GitHub App token on a bypass list. + +on: + discussion_comment: + types: [created] + +# Serialize writes to VOUCHED.td so concurrent vouches don't clobber. +concurrency: + group: vouch-manage + cancel-in-progress: false + +permissions: + contents: write # commit VOUCHED.td + discussions: write # read the comment / acknowledge + +jobs: + manage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: mitchellh/vouch/action/manage-by-discussion@v1 + with: + discussion-number: ${{ github.event.discussion.number }} + comment-node-id: ${{ github.event.comment.node_id }} + vouch-keyword: "!vouch" + denounce-keyword: "!denounce" + unvouch-keyword: "!unvouch" + roles: admin,maintain,write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vouch-pr.yml b/.github/workflows/vouch-pr.yml new file mode 100644 index 000000000..6afa86cdc --- /dev/null +++ b/.github/workflows/vouch-pr.yml @@ -0,0 +1,51 @@ +name: Vouch (PR gate) + +# Auto-close PRs from unvouched or denounced users. Issues are left alone +# (robomp triages those). Runs under `pull_request_target` so the token can +# act on fork PRs; this job does NO checkout and runs NO PR code — it only +# reads .github/VOUCHED.td from the base repo and calls the GitHub API. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review] + +permissions: + contents: read # read VOUCHED.td from the base branch + pull-requests: write # close + comment + issues: write # add the `vouched` label (labels use the Issues API) + +concurrency: + group: vouch-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - id: vouch + uses: mitchellh/vouch/action/check-pr@v1 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + require-vouch: true # block unvouched, not only denounced + # vouched-file: .github/VOUCHED.td (default) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Survivors of the gate (vouched, or auto-allowed collaborators/bots) get + # a FRESH `vouched` label on every (re)open / ready-for-review. robomp + # reviews ONLY on that label event (ROBOMP_PR_REVIEW_TRIGGER=vouched_label), + # so review is always triggered by a just-validated PR, never a stale label. + - name: Label vouched PRs for robomp review + if: ${{ steps.vouch.outputs.status == 'vouched' || steps.vouch.outputs.status == 'allowed' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + gh label create vouched --repo "$REPO" --color 2da44e --description "Passed the vouch gate" --force + # remove+add so a fresh `labeled` event fires even when the label + # persisted across close/reopen (re-adding an existing label emits no + # event). The check above just re-validated, so trust is never stale. + gh pr edit "$PR" --repo "$REPO" --remove-label vouched || true + gh pr edit "$PR" --repo "$REPO" --add-label vouched diff --git a/.gitignore b/.gitignore index de59ee70f..e56ced111 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,9 @@ out.html pi-*.html # Generated files -packages/coding-agent/src/internal-urls/docs-index.generated.ts packages/coding-agent/src/export/html/tool-views.generated.js packages/natives/npm/ +packages/coding-agent/src/utils/mupdf-wasm.wasm /runs/ python/omp-rpc/src/omp_rpc.egg-info/ # parallel-agent worktrees diff --git a/.omp/skills/tool-prompt-optimization/SKILL.md b/.omp/skills/tool-prompt-optimization/SKILL.md new file mode 100644 index 000000000..b45244ac7 --- /dev/null +++ b/.omp/skills/tool-prompt-optimization/SKILL.md @@ -0,0 +1,118 @@ +--- +name: tool-prompt-optimization +description: Optimize the description prompts an AI agent reads to learn its built-in tools (the `.md` files under prompts/tools/). Two halves: (1) measure how much of a prompt is already inferable from the tool's JSON parameter schema + name, to prune redundancy with evidence; (2) house authoring rules for what belongs in a tool prompt vs what stays in code. Use when auditing, trimming, writing, or reviewing tool prompts, deciding what schema field descriptions already cover, or testing schema-vs-prompt overlap before deleting prompt lines. +--- + +# Tool Prompt Optimization + +A tool's description prompt and its parameter schema overlap. Whatever a model can reconstruct from the **schema + tool name + a blank outline** is a *prune candidate* — the schema may already teach it. This skill measures that overlap so you prune with evidence, not vibes. A candidate is never an automatic delete (see caveats — history first). + +Core move: give a model only `(name, JSON schema, outline)` and have it predict the prompt body. Lines it predicts reliably are *prune candidates*. Lines it never recovers are *load-bearing* — keep them. + +## Run the probe + +`scripts/probe.ts` routes through `@oh-my-pi/pi-ai` (`completeSimple`) so model/auth/provider behavior matches production. + +```bash +bun .omp/skills/tool-prompt-optimization/scripts/probe.ts \ + --schema --template --name +``` + +- `--schema` and `--template` are the only required inputs (file path or inline value). +- No `--model` → 3-model panel (`fireworks/kimi-k2.7-code`, `anthropic/claude-opus-4-8`, `openai/gpt-5.5`) × `--samples` (default 3). Needs `FIREWORKS_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`. +- `--model p/id,p/id` overrides the panel; `--samples N`, `--max-tokens`, `--json` tune it. +- Programmatic: `import { probe } from "./scripts/probe.ts"` → `{ prompt, results: [{ model, samples: [{ text, stopReason, usage, error }] }] }`. + +### Builtin shortcut (preferred for this repo's tools) + +Skip building the two inputs by hand — `scripts/probe-builtin.ts` instantiates the live tool, pulls the EXACT wire schema (`toolWireSchema`) and rendered prompt (`tool.description`), and derives the outline for you: + +```bash +bun .omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts --tool [--no-summary] [--show] +``` + +- `--show` prints the resolved schema + derived outline + real prompt and exits (no API calls) — use it to eyeball inputs before spending tokens. +- `--no-summary` runs the ablation (blank the summary line) directly. +- `--samples` / `--model` / `--max-tokens` / `--json` forward to the panel; output ends with the REAL prompt so you can diff in place. +- It bypasses the settings allowlist via the factory map, so gated tools (`irc`, `github`, …) resolve. If a tool refuses to construct (an availability gate like a missing `gh` CLI), fall back to the manual inputs below. + +## Build the two inputs + +**Schema** — use the *wire* schema the model actually sees, not a hand-sketch. For this repo's arktype tool schemas: + +```ts +import { arkToWireSchema } from "@oh-my-pi/pi-ai"; // or toolWireSchema(tool) +JSON.stringify(arkToWireSchema(toolSchema), null, 2); +``` + +Include `required` and `additionalProperties: false` — omitting them makes the model infer looser usage than the real tool. + +**Template (outline)** — the real `.md`'s structure with bodies blanked: the one-line summary, then each section tag with `...` inside. + +``` +Structural code search via native ast-grep AST matching. + + +... + + + +... + + + +... + +``` + +## Interpret results + +Bucket every line of the real prompt against the predictions: + +- **Prune candidate** — content that is STABLE across samples AND agrees across models AND restates the schema (param names, types, "required", value examples already in a field `description`, clamp ranges already stated). The schema teaches it; the prompt repeats it. +- **Keep** — content no model recovers: defaults and their direction (`gitignore` default true), cross-tool routing/escalation ("NEVER shell out to `find`/`fd` → use this tool", "broad exploration → Task subagent"), exact output format (mtime sort, grouping, `artifact://` truncation), worked anti-patterns, and hard constraints invisible to a type (the AST metavariable grammar, C++ trailing `;`). + +A single sample is noise. Only treat overlap that is **stable across samples and models** as a prune *candidate* — and a candidate is not a verdict until its history clears (see caveats). You MUST NOT delete a line on inferability alone. + +## Caveats — read before deleting anything + +- **`git blame` before cutting — MUST, not SHOULD.** Many prompt lines were added on purpose after a real failure: a model that hallucinated a flag, shelled out, scanned the repo root, fabricated an anchor. They look redundant precisely because they now prevent the mistake. You MUST `git blame` (and read the commit/issue) every line you intend to cut; the history tells you whether it restates the schema or is scar tissue from an incident. Keep scar tissue. Inferability is necessary for pruning, NEVER sufficient. +- **Memorization ≠ inference.** Public repos (this one included) may be in training data, so a model can *recite* `ast-grep.md` it never *inferred*. Tell: predictions naming repo-specific details absent from the schema (exact tool names, internal URI schemes, the `Task` subagent) are memorized, not derived — discount them. +- **The outline leaks.** The summary line and section names are themselves hints. To isolate *schema-alone* inferability, run an ablation: a second pass with no summary line and generic section tags. Content that survives only with the summary present is "summary-inferable", not "schema-inferable". + +## Verdict pattern + +Per tool: predictions reproduce parameter mechanics and generic usage (already in the schema) but miss defaults, output shape, cross-tool routing, anti-patterns, and domain grammar. Prune the first set (after `git blame` clears each line); keep the second. Self-documenting flag tools (e.g. `find`) prune heavily; DSL/capability tools (e.g. `read`, `ast_grep`) barely at all. + +## Tool Prompt Authoring + +Tool prompts are not API docs. They teach the agent **when to reach for the tool, what shape its inputs take, and which failure modes are the agent's responsibility**. Everything else — engine internals, recovery heuristics, fallback chains, performance tuning — stays in code. + +### Describe surface, not machinery + +The agent picks tools from prose, not source. Tell it WHEN and WHY; NEVER HOW the tool works internally. + +- `read.md` enumerates every source it covers (file/dir/archive/sqlite/PDF/URL) so the agent stops reaching for `cat`/`curl`/`tar`. It does NOT mention the chunker, the binary sniffer, or the cache layer. +- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." No mention of the LSP wire protocol, server lifecycle, or capability negotiation. +- `ast_edit`: teaches metavariable syntax + workflow ("Loosest existence check: `pat: 'executeBash'` with narrow paths"). Does NOT explain the AST engine, query compilation, or tree-sitter grammar selection. +- `hashline.md` (this repo): teaches the **patch grammar** (anchors, ops, payloads, ranges) and the **edit shapes** that succeed. Hides `tryRecoverHashlineWithCache`, the fuzz factor, the bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`. The agent never learns those names — it just sees "the tool resolved your typo" or "the anchor was stale, re-read". + +If the agent's behavior shouldn't change based on a detail, the detail does NOT belong in the prompt. Each sentence MUST shift a decision the agent makes. + +### Anatomy of a good tool prompt + +1. **One-line purpose.** What problem it solves, in the agent's vocabulary. Not "wraps libfoo with X" — instead "compact, line-anchored edit format". +2. **Input grammar / surface.** Operators, parameters, selectors. Concrete syntax the agent will emit verbatim. +3. **Worked examples.** 3–8 patterns covering the common shapes. Each example IS the explanation — don't narrate it twice. +4. **Failure shapes the agent owns.** Things the agent can fix by changing its input (stale anchors, missing payload prefix, fabricated hash). Skip failures the engine recovers from silently. +5. **Anti-patterns.** WRONG/RIGHT pairs for the mistakes that cost retries. Drawn from real failures, not imagined ones. +6. **`` recap.** 3–6 lines of the load-bearing rules, in case the agent skips the body. + +### What stays out + +- Implementation file names, function names, module layout. +- Recovery, retry, normalization, caching, fuzz matching. +- Performance characteristics ("this is O(n)") unless they change the agent's strategy. +- Telemetry, logging, debug flags, env vars the agent cannot set. +- Version history, deprecated parameters, "previously this worked differently". +- Cross-tool plumbing ("this calls `read` under the hood") unless the agent must coordinate them. diff --git a/.omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts b/.omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts new file mode 100755 index 000000000..189408b55 --- /dev/null +++ b/.omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +/** + * Builtin shortcut for the prompt-inference probe. + * + * Instead of hand-writing a tool's JSON schema + outline, point this at a live + * builtin tool name. It resolves the tool (direct constructor for availability-gated + * `github`/`irc`, else the BUILTIN_TOOLS/HIDDEN_TOOLS factory map), pulls the EXACT wire + * schema the model sees (`toolWireSchema`) and the rendered prompt (`tool.description`), + * derives an outline by blanking section bodies, then runs the same `probe()` panel. + * + * Usage: + * bun probe-builtin.ts --tool [--no-summary] [--show] + * --tool builtin tool name (e.g. irc, github, read). Required. + * --no-summary ablation: blank the one-line summary too (isolate schema-alone). + * --show print resolved schema + outline + real prompt and exit (no API calls). + * --samples / --model / --max-tokens / --json forwarded to probe(). + * + * The heavy coding-agent import lives here; probe.ts stays pi-ai-only. + */ +import { parseArgs } from "node:util"; +import { toolWireSchema } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { BUILTIN_TOOLS, GithubTool, HIDDEN_TOOLS, IrcTool, type Tool, type ToolFactory, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { probe } from "./probe.ts"; + +const OPEN_TAG = /^<[a-z_][\w-]*>$/i; +const CLOSE_TAG = /^<\/[a-z_][\w-]*>$/i; +const MD_HEADER = /^#{1,6}\s/; + +/** Keep the summary + every section header/tag; collapse each body run to a single `...`. */ +function deriveOutline(description: string, dropSummary: boolean): string { + const lines = description.split("\n"); + let i = 0; + const summary: string[] = []; + const isHeader = (l: string): boolean => { + const t = l.trim(); + return OPEN_TAG.test(t) || CLOSE_TAG.test(t) || MD_HEADER.test(l); + }; + while (i < lines.length && !isHeader(lines[i])) { + summary.push(lines[i]); + i++; + } + const parts: string[] = [dropSummary ? "" : summary.join("\n").trim()]; + let pendingBody = false; + for (; i < lines.length; i++) { + const line = lines[i]; + const t = line.trim(); + if (OPEN_TAG.test(t) || MD_HEADER.test(line)) { + parts.push("", line); + pendingBody = false; + } else if (CLOSE_TAG.test(t)) { + parts.push(line); + pendingBody = false; + } else if (t !== "") { + if (!pendingBody) { + parts.push("..."); + pendingBody = true; + } + } + } + return parts.join("\n").trim(); +} + +async function resolveTool(name: string): Promise { + await Settings.init({ inMemory: true, cwd: process.cwd() }); + const settings = Settings.isolated({}); + // Enriched stub: we only read `.parameters` / `.description`, never `execute`, so the + // availability-gated factories (irc needs a registry + agent id; github needs `gh`) can + // still construct. Factory map bypasses the settings allowlist (`isToolAllowed`). + const session = { + cwd: process.cwd(), + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings, + taskDepth: 0, + getAgentId: () => "Probe", + agentRegistry: {}, + } as ToolSession; + // `github`/`irc` map to `*.createIf`, which gates on external availability (gh CLI) or a + // live agent registry. We only read `.parameters`/`.description`, so direct-construct those + // two — keeps the probe gh-independent. Everything else goes through the factory map. + const direct: Record Tool> = { + github: s => new GithubTool(s), + irc: s => new IrcTool(s), + }; + const key = name.toLowerCase(); + const directCtor = direct[key]; + if (directCtor) return directCtor(session); + const factories: Record = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS }; + const factory = factories[key]; + if (!factory) { + const names = Object.keys(factories).sort().join(", "); + throw new Error(`unknown builtin tool "${name}". available: ${names}`); + } + const tool = await factory(session); + if (!tool) { + throw new Error(`tool "${name}" did not construct here — blocked by an availability gate (e.g. ssh, or a memory backend that isn't configured). Fall back to the manual --schema/--template path.`); + } + return tool; +} + +async function main(): Promise { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + tool: { type: "string" }, + "no-summary": { type: "boolean" }, + show: { type: "boolean" }, + samples: { type: "string" }, + model: { type: "string" }, + "max-tokens": { type: "string" }, + json: { type: "boolean" }, + }, + allowPositionals: false, + }); + + if (!values.tool) { + console.error("usage: bun probe-builtin.ts --tool [--no-summary] [--show] [--samples N] [--model p/id,...] [--max-tokens N] [--json]"); + process.exit(2); + } + + const tool = await resolveTool(values.tool); + const schema = toolWireSchema(tool); + const realPrompt = tool.description ?? ""; + const outline = deriveOutline(realPrompt, Boolean(values["no-summary"])); + + if (values.show) { + console.log(`# tool: ${tool.name}\n\n## wire schema\n\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\`\n`); + console.log(`## derived outline\n${outline}\n`); + console.log(`## real prompt (${Buffer.byteLength(realPrompt)} bytes)\n${realPrompt}`); + return; + } + + const run = await probe({ + schema, + template: outline, + name: tool.name, + samples: values.samples ? Number(values.samples) : undefined, + models: values.model ? values.model.split(",").map(s => s.trim()).filter(Boolean) : undefined, + maxTokens: values["max-tokens"] ? Number(values["max-tokens"]) : undefined, + }); + + if (values.json) { + console.log(JSON.stringify({ ...run, realPrompt }, null, 2)); + return; + } + + for (const result of run.results) { + console.log(`\n############ ${result.model} ############`); + result.samples.forEach((s, i) => { + const tag = s.error ? `ERROR: ${s.error}` : s.stopReason; + console.log(`\n----- sample ${i + 1}/${result.samples.length} [${tag}] -----`); + console.log(s.error ? "" : s.text); + }); + } + console.log(`\n############ REAL PROMPT (${tool.name}) ############\n${realPrompt}`); +} + +if (import.meta.main) { + await main(); +} diff --git a/.omp/skills/tool-prompt-optimization/scripts/probe.ts b/.omp/skills/tool-prompt-optimization/scripts/probe.ts new file mode 100755 index 000000000..34b59466f --- /dev/null +++ b/.omp/skills/tool-prompt-optimization/scripts/probe.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env bun +/** + * Schema -> prompt inference probe. + * + * Given a tool's JSON parameter schema + a description-prompt outline ("template"), + * ask one or more models to reconstruct the full description. Whatever they reliably + * predict is inferable from the schema/outline alone — i.e. a candidate to PRUNE from + * the hand-written prompt. Run several samples and several models: trust only content + * that is STABLE across samples AND agrees across models. + * + * Routes through @oh-my-pi/pi-ai (`completeSimple`) rather than raw HTTP so model + * resolution, auth, and provider quirks match production. + * Per-provider env keys (_API_KEY) are resolved automatically; temperature is never sent. + * Caller passes two things: a JSON schema and a template. Everything else has defaults. + * With no `--model`, probes a 3-model panel (Fireworks Kimi, Claude Opus, GPT) x 3 samples. + * + * CLI: + * bun probe.ts --schema --template [options] + * --name tool name shown to the model (default "the tool") + * --samples independent samples per model (default 3) + * --model

override panel; comma-separated for several + * --max-tokens output cap (default 1200) + * --json emit JSON instead of human-readable blocks + * + * Programmatic: import { probe } from "./probe.ts" + */ +import { parseArgs } from "node:util"; +import { completeSimple } from "@oh-my-pi/pi-ai"; +import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; +import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; + +/** Default 3-model panel when the caller does not pin a model. */ +const DEFAULT_MODELS = ["fireworks/kimi-k2.7-code", "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; +const DEFAULT_SAMPLES = 3; + +const SYSTEM_PROMPT = [ + "You write the description prompt that an AI coding agent reads to learn one of its built-in tools.", + "You are given ONLY the tool name and its JSON parameter schema, plus a fixed description outline.", + "Fill in the outline: replace every `...` and write the body of every named section, grounded strictly in the schema.", + "Output ONLY the finished description as markdown. No preamble, no commentary, no surrounding code fence.", +].join("\n"); + +export interface ProbeOptions { + /** JSON Schema for the tool's parameters (object or JSON string). */ + schema: unknown; + /** Description outline: a one-line summary + section skeleton (with `...` placeholders). */ + template: string; + /** Tool name surfaced to the model. */ + name?: string; + /** Independent samples per model. Only content stable across samples is trustworthy. */ + samples?: number; + /** `provider/id` list. Defaults to the 3-model panel. */ + models?: string[]; + maxTokens?: number; + signal?: AbortSignal; +} + +export interface ProbeSample { + text: string; + stopReason: AssistantMessage["stopReason"]; + usage?: AssistantMessage["usage"]; + error?: string; +} + +export interface ProbeModelResult { + model: string; + samples: ProbeSample[]; +} + +export interface ProbeRun { + prompt: string; + results: ProbeModelResult[]; +} + +function resolveModel(ref: string): Model { + const slash = ref.indexOf("/"); + if (slash === -1) throw new Error(`model must be "provider/id", got: ${ref}`); + // Runtime-validated below: getBundledModel returns undefined for an unknown provider/id. + const provider = ref.slice(0, slash) as GeneratedProvider; + const id = ref.slice(slash + 1); + const model = getBundledModel(provider, id); + if (!model) throw new Error(`unknown bundled model: ${ref}`); + return model; +} + +function extractText(content: AssistantMessage["content"]): string { + let out = ""; + for (const block of content) { + if (block.type === "text") out += block.text; + } + return out.trim(); +} + +function buildUserPrompt(name: string, schema: unknown, template: string): string { + const schemaText = typeof schema === "string" ? schema : JSON.stringify(schema, null, 2); + return [ + `Tool name: ${name}`, + "", + "JSON parameter schema:", + "```json", + schemaText, + "```", + "", + "Description outline to complete (write the body of every section; output ONLY the finished description):", + "", + template, + ].join("\n"); +} + +export async function probe(opts: ProbeOptions): Promise { + const refs = opts.models && opts.models.length > 0 ? opts.models : DEFAULT_MODELS; + const name = opts.name ?? "the tool"; + const sampleCount = Math.max(1, opts.samples ?? DEFAULT_SAMPLES); + const userPrompt = buildUserPrompt(name, opts.schema, opts.template); + + const drawOne = async (model: Model): Promise => { + try { + const response = await completeSimple( + model, + { + systemPrompt: [SYSTEM_PROMPT], + messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }], + }, + { + maxTokens: opts.maxTokens ?? 1200, + disableReasoning: true, + signal: opts.signal, + }, + ); + const text = extractText(response.content); + const sample: ProbeSample = { text, stopReason: response.stopReason, usage: response.usage }; + if (response.stopReason === "error") sample.error = response.errorMessage ?? "unknown error"; + return sample; + } catch (err) { + return { text: "", stopReason: "error", error: err instanceof Error ? err.message : String(err) }; + } + }; + + const results = await Promise.all( + refs.map(async (ref): Promise => { + let model: Model; + try { + model = resolveModel(ref); + } catch (err) { + return { model: ref, samples: [{ text: "", stopReason: "error", error: err instanceof Error ? err.message : String(err) }] }; + } + const samples = await Promise.all(Array.from({ length: sampleCount }, () => drawOne(model))); + return { model: `${model.provider}/${model.id}`, samples }; + }), + ); + + return { prompt: userPrompt, results }; +} + +async function resolveInput(value: string): Promise { + const file = Bun.file(value); + if (await file.exists()) return (await file.text()).trim(); + return value; +} + +function formatUsage(usage: AssistantMessage["usage"] | undefined): string { + if (!usage) return ""; + const out = typeof usage.output === "number" ? usage.output : undefined; + return out === undefined ? "" : `, ${out} tok`; +} + +async function main(): Promise { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + schema: { type: "string" }, + template: { type: "string" }, + name: { type: "string" }, + samples: { type: "string" }, + model: { type: "string" }, + "max-tokens": { type: "string" }, + json: { type: "boolean" }, + }, + allowPositionals: false, + }); + + if (!values.schema || !values.template) { + console.error( + "usage: bun probe.ts --schema --template [--name N] [--samples 3] [--model p/id,p/id] [--max-tokens 1200] [--json]", + ); + process.exit(2); + } + + const schemaRaw = await resolveInput(values.schema); + let schema: unknown = schemaRaw; + try { + schema = JSON.parse(schemaRaw); + } catch { + // Leave as raw text — caller may pass a non-JSON schema notation. + } + const template = await resolveInput(values.template); + + const run = await probe({ + schema, + template, + name: values.name, + samples: values.samples ? Number(values.samples) : undefined, + models: values.model ? values.model.split(",").map(s => s.trim()).filter(Boolean) : undefined, + maxTokens: values["max-tokens"] ? Number(values["max-tokens"]) : undefined, + }); + + if (values.json) { + console.log(JSON.stringify(run, null, 2)); + return; + } + + for (const result of run.results) { + console.log(`\n############ ${result.model} ############`); + result.samples.forEach((s, i) => { + const tag = s.error ? `ERROR: ${s.error}` : `${s.stopReason}${formatUsage(s.usage)}`; + console.log(`\n----- sample ${i + 1}/${result.samples.length} [${tag}] -----`); + console.log(s.error ? "" : s.text); + }); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..66f25b399 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to oh-my-pi + +Thanks for your interest in contributing. This project uses a lightweight +**vouch** system to decide who can open pull requests. Please read this before +opening a PR. + +## TL;DR + +- **Issues are open to everyone.** File bugs, feature requests, and questions + freely — they are triaged automatically. +- **Pull requests require a vouch.** A PR whose author is not vouched (or is + denounced) is **closed automatically**. If you are not yet vouched, do **not** + open a PR to get noticed — it will be closed on sight. Start a Discussion and + ask to be vouched first (see below). + +## Who can open PRs + +A pull request is accepted when its author is any of: + +- a repository collaborator (write access or above), or a bot; or +- listed — without a leading `-` — in [`.github/VOUCHED.td`](.github/VOUCHED.td). + +Anyone **denounced** (prefixed with `-` in that file) is always blocked. + +## Getting vouched + +1. Open a [Discussion](../../discussions) (or comment on an existing one) + describing what you'd like to contribute. +2. A maintainer vouches you by commenting **`!vouch`** (vouches the discussion + author) or **`!vouch @your-handle`** on that discussion. +3. Once you appear in `.github/VOUCHED.td`, open your PR — it stays open and is + reviewed. + +Maintainers may also `!denounce [@user]` and `!unvouch [@user]`. Only +collaborators with admin/maintain/write can run these commands. + +## What happens to your PR + +| You are… | Result | +| --- | --- | +| Vouched (or a collaborator) | PR stays open → automated review → human review | +| Not vouched | PR closed with a comment — get vouched, then reopen or open a new PR | +| Denounced | PR closed | + +Pushing more commits to an open, vouched PR is fine — it remains vouched. + +## The VOUCHED.td file + +[`.github/VOUCHED.td`](.github/VOUCHED.td) is the source of truth: one handle per +line, sorted alphabetically, optionally `platform:handle`, with `-` marking a +denouncement and an optional reason after the handle. The format follows +[mitchellh/vouch](https://github.com/mitchellh/vouch); the denouncement list is +intentionally public so other projects can reuse our prior knowledge of bad +actors. diff --git a/Cargo.lock b/Cargo.lock index c1bfd6579..9a85bad09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,9 +244,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "16.0.7" +version = "16.1.2" dependencies = [ "anyhow", "ast-grep-core", @@ -2384,7 +2384,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "16.0.7" +version = "16.1.2" dependencies = [ "async-trait", "libc", @@ -2396,7 +2396,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "16.0.7" +version = "16.1.2" dependencies = [ "anyhow", "arboard", @@ -2444,7 +2444,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "16.0.7" +version = "16.1.2" dependencies = [ "anyhow", "brush-builtins", diff --git a/Cargo.toml b/Cargo.toml index e32bf226d..398a3547e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "16.0.7" +version = "16.1.2" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/Dockerfile b/Dockerfile index da8583a13..2f7f8ffb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ WORKDIR /pi # matched path under /pi/ (requires syntax 1.7-labs). COPY --parents \ package.json bun.lock bunfig.toml \ + patches/*.patch \ tsconfig.base.json tsconfig.json \ Cargo.toml Cargo.lock rust-toolchain.toml \ packages/*/package.json \ @@ -169,6 +170,7 @@ WORKDIR /pi # only re-runs when a package.json / lockfile changes. COPY --parents \ package.json bun.lock bunfig.toml \ + patches/*.patch \ tsconfig.base.json tsconfig.json \ packages/*/package.json \ packages/tsconfig.workspace.json \ diff --git a/Dockerfile.robomp b/Dockerfile.robomp index f79fca4b9..b6444dee2 100644 --- a/Dockerfile.robomp +++ b/Dockerfile.robomp @@ -28,6 +28,9 @@ WORKDIR /work # --filter robomp-web` to hydrate just the dashboard's node_modules. COPY package.json bun.lock ./ COPY python/robomp/web/package.json ./python/robomp/web/package.json +# Root package.json declares patchedDependencies, so the patch file must be +# present before any root-level `bun install` (even a filtered one). +COPY patches/*.patch ./patches/ RUN bun install --filter robomp-web COPY --exclude=node_modules --exclude=dist python/robomp/web/ ./python/robomp/web/ RUN bun --cwd=python/robomp/web run build diff --git a/README.md b/README.md index 823c14741..f4fa65676 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,23 @@ Split a job across workers and get typed results back. task fans out into isolat _[Watch the capture ↗](https://omp.sh/clips/irc.mp4)_ -### 06 · Read a pdf on arxiv, why not? +### 06 · A second model, watching every turn. + +Pair a reviewer model to the 'advisor' role and it reads every turn the main agent takes, injecting notes inline — a quiet aside, a concern, or a hard blocker. It runs on its own context and its own model, so it catches what the doer rushed past. The main agent sees the note and course-corrects, or tells you why it won't. + +![omp TUI: /advisor status shows the advisor running on openai-codex/gpt-5.5; after the main agent scopes a catch to ENOENT instead of swallowing every error, an amber 'Advisor 1 note (concern)' card warns the fix no longer matches the user's literal acceptance criterion.](https://omp.sh/clips/advisor-poster.webp) + +_[Watch the capture ↗](https://omp.sh/clips/advisor.mp4)_ + +### 07 · Hand someone the link, they're in. + +/collab puts your live session on a relay and hands back a link — and a QR. A teammate joins from another terminal with omp join, or just opens it in a browser. Share read-write to pair on the same agent, or /collab view for a read-only link anyone can watch but no one can steer. Frames are sealed client-side; the relay never sees your keys. + +![omp TUI: /collab view prints 'Collab session started!' with an omp join command, a my.omp.sh browser link, the note 'Anyone with this link can watch the session but cannot prompt the agent', and a large scannable QR code.](https://omp.sh/clips/collab-poster.webp) + +_[Watch the capture ↗](https://omp.sh/clips/collab.mp4)_ + +### 08 · Read a pdf on arxiv, why not? web_search chains fourteen ranked providers and hands whatever URLs it finds straight to read. Arxiv PDFs, GitHub pages, Stack Overflow threads come back as structured markdown with anchors intact — the same tool surface you use on local files. Cite, follow, quote, never lose where you came from. @@ -141,45 +157,45 @@ web_search chains fourteen ranked providers and hands whatever URLs it finds str _[Watch the capture ↗](https://omp.sh/clips/web.mp4)_ -### 07 · Unapologetically native. Even on Windows. +### 09 · Unapologetically native. Even on Windows. Other agents shell out to rg, grep, find, and bash. On many machines those binaries don't exist, and on the ones where they do, every call costs a fork-exec round-trip. omp links the real implementations into the process. ripgrep, glob, find: in-process. brush is the bash, with sessions that survive across calls. The same omp binary runs on macOS, Linux, and Windows — no WSL bridge. -### 08 · Code review with priorities and a verdict +### 10 · Code review with priorities and a verdict Get a clear verdict on whether the change ships, with every issue ranked P0 through P3 and scored for confidence. /review spawns dedicated reviewer subagents that sweep branches, single commits, or uncommitted work in parallel. You tackle what blocks release first; nothing important hides in a wall of prose. -### 09 · Hashline: edit by content hash +### 11 · Hashline: edit by content hash Perfect edits, fewer tokens. The model points at anchors instead of retyping the lines it wants to change, so whitespace battles and string-not-found loops just stop happening. Edit a stale file and the anchors diverge — we reject the patch before it corrupts anything. Grok 4 Fast spends 61% fewer output tokens on the same work. -### 10 · GitHub is just another filesystem +### 12 · GitHub is just another filesystem Other harnesses bolt on gh_issue_view, gh_pr_view, gh_search — each with its own parameters the agent has to learn and you have to debug. We skipped that. read already handles paths; PRs are paths. One interface to teach the model, one surface to keep correct. -### 11 · Hindsight: memory the agent curates +### 13 · Hindsight: memory the agent curates The agent remembers your codebase between sessions. It writes facts mid-run with retain, pulls them back with recall, and compresses each session into a mental model that loads on the first turn of the next one. Project-scoped by default, so what it learns about this repo stays with this repo. -### 12 · ACP: editor-drivable agent +### 14 · ACP: editor-drivable agent Run omp inside Zed and you get the same agent you drive from the terminal — reading the buffer you're actually looking at, writing through the editor's save path, spawning shells in the editor's terminal. Destructive tools pause for a permission prompt you can answer once and forget. No bridge, no plugin, no second brain to keep in sync. -### 13 · Inherits what your other tools already wrote +### 15 · Inherits what your other tools already wrote Every other agent ships an importer and expects you to convert. omp reads the eight formats already on disk in their native shape — Cursor MDC, Cline .clinerules, Codex AGENTS.md, Copilot applyTo, and the rest. No migration script, no YAML-to-TOML port, no "supported subset" footnotes. The config your team wrote last quarter still works tonight. -### 14 · omp commit: atomic splits, validated messages +### 16 · omp commit: atomic splits, validated messages omp reads the working tree through git_overview, git_file_diff, and git_hunk, then splits unrelated changes into atomic commits ordered by their dependencies. Cycles are rejected before anything is written. Source files score above tests, docs, and configs, so the headline commit is the one that matters. Lock files are excluded from analysis entirely. -### 15 · Read PRs. _Walk skills._ Pull JSON out of subagents. +### 17 · Read PRs. _Walk skills._ Pull JSON out of subagents. Twelve internal schemes — `pr://`, `issue://`, `agent://`, `skill://`, `rule://`, and the rest — resolve transparently inside every FS-shaped tool the agent already calls. `read pr://1428` returns the same shape as `read src/foo.ts`. `search` walks a diff like a directory. `agent:///findings.0.path` pulls a field out of a subagent's output by path. ![omp TUI reading pr://can1357/oh-my-pi/1063 and then /diff/1, showing hunk headers, added lines, and a [MODIFIED] (+12 -0) summary.](https://omp.sh/captures/pr.webp) -### 16 · Conflict resolution, made easy. +### 18 · Conflict resolution, made easy. Each merge conflict becomes one URL. The agent writes `@theirs`, `@ours`, or `@base` to `conflict://N` and the file resolves cleanly. Bulk form: `conflict://*`. @@ -187,7 +203,7 @@ Each merge conflict becomes one URL. The agent writes `@theirs`, `@ours`, or `@b _[Watch the capture ↗](https://omp.sh/clips/conflict.mp4)_ -### 17 · Preview, then accept. +### 19 · Preview, then accept. `ast_edit` returns a _(proposed)_ card with the replacement count. The change is staged. The agent calls `resolve` with a reason; the TUI turns it into an **Accept** card and the disk move happens — atomic, all or nothing. @@ -195,7 +211,7 @@ _[Watch the capture ↗](https://omp.sh/clips/conflict.mp4)_ _[Watch the capture ↗](https://omp.sh/clips/codemod.mp4)_ -### 18 · Drives a _real browser_. _Or your Slack?_ +### 20 · Drives a _real browser_. _Or your Slack?_ Stealth's on by default, so pages see a normal user instead of a headless bot. The same API drives any Electron app in place — point it at Slack and the agent reads your DMs the way it reads the web. @@ -529,6 +545,15 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP | **[brush-core-vendored](crates/brush-core-vendored)** | Vendored fork of [brush-shell](https://github.com/reubeno/brush) for embedded bash execution | | **[brush-builtins-vendored](crates/brush-builtins-vendored)** | Vendored bash builtins (cd, echo, test, printf, read, export, etc.) | +## Contributing + +Issues are open to everyone. **Pull requests require a vouch** — PRs from +unvouched or denounced authors are closed automatically. If you're not yet +vouched, open a [Discussion](https://github.com/can1357/oh-my-pi/discussions) +and ask a maintainer to `!vouch` you rather than opening a PR (which would be +closed on sight). See **[CONTRIBUTING.md](CONTRIBUTING.md)** and +[`.github/VOUCHED.td`](.github/VOUCHED.td) for the full policy. + --- ## License diff --git a/biome.json b/biome.json index bd20dcfb1..b6bc9f1a5 100644 --- a/biome.json +++ b/biome.json @@ -60,7 +60,6 @@ "!**/vendor/**/*", "!**/node_modules/**/*", "!**/test-sessions.ts", - "!**/docs-index.generated.ts", "!**/agent_pb.ts", "!.worktrees/**/*", "!.wt/**/*" diff --git a/bun.lock b/bun.lock index ec6746d7a..ee1e83249 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,13 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "@oh-my-pi/snapcompact": "catalog:", "@opentelemetry/api": "catalog:", }, @@ -38,11 +39,12 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "arktype": "catalog:", "partial-json": "catalog:", "zod": "catalog:", @@ -53,7 +55,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -67,7 +69,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "16.0.7", + "version": "16.1.2", "bin": { "omp": "src/cli.ts", }, @@ -98,11 +100,12 @@ "arktype": "catalog:", "chalk": "catalog:", "diff": "catalog:", - "fflate": "catalog:", + "fast-xml-parser": "catalog:", "handlebars": "catalog:", "linkedom": "catalog:", "lru-cache": "catalog:", - "markit-ai": "catalog:", + "mammoth": "catalog:", + "mupdf": "catalog:", "puppeteer-core": "catalog:", "turndown": "catalog:", "turndown-plugin-gfm": "catalog:", @@ -134,7 +137,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -145,7 +148,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "16.0.7", + "version": "16.1.2", "bin": { "mnemopi": "src/cli.ts", }, @@ -171,7 +174,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "16.0.7", + "version": "16.1.2", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -179,11 +182,12 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", @@ -191,7 +195,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "16.0.7", + "version": "16.1.2", "bin": { "omp-stats": "./src/index.ts", }, @@ -217,7 +221,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "16.0.7", + "version": "16.1.2", "bin": { "omp-swarm": "src/cli.ts", }, @@ -233,7 +237,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -274,10 +278,9 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "16.0.7", + "version": "16.1.2", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", - "beautiful-mermaid": "catalog:", "handlebars": "catalog:", "winston": "catalog:", "winston-daily-rotate-file": "catalog:", @@ -288,7 +291,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "16.0.7", + "version": "16.1.2", "devDependencies": { "@types/bun": "catalog:", }, @@ -311,7 +314,6 @@ }, "patchedDependencies": { "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch", - "beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch", }, "catalog": { "@agentclientprotocol/sdk": "0.25.0", @@ -325,18 +327,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.0.7", - "@oh-my-pi/omp-stats": "16.0.7", - "@oh-my-pi/pi-agent-core": "16.0.7", - "@oh-my-pi/pi-ai": "16.0.7", - "@oh-my-pi/pi-catalog": "16.0.7", - "@oh-my-pi/pi-coding-agent": "16.0.7", - "@oh-my-pi/pi-mnemopi": "16.0.7", - "@oh-my-pi/pi-natives": "16.0.7", - "@oh-my-pi/pi-tui": "16.0.7", - "@oh-my-pi/pi-utils": "16.0.7", - "@oh-my-pi/pi-wire": "16.0.7", - "@oh-my-pi/snapcompact": "16.0.7", + "@oh-my-pi/hashline": "16.1.2", + "@oh-my-pi/omp-stats": "16.1.2", + "@oh-my-pi/pi-agent-core": "16.1.2", + "@oh-my-pi/pi-ai": "16.1.2", + "@oh-my-pi/pi-catalog": "16.1.2", + "@oh-my-pi/pi-coding-agent": "16.1.2", + "@oh-my-pi/pi-mnemopi": "16.1.2", + "@oh-my-pi/pi-natives": "16.1.2", + "@oh-my-pi/pi-tui": "16.1.2", + "@oh-my-pi/pi-utils": "16.1.2", + "@oh-my-pi/pi-wire": "16.1.2", + "@oh-my-pi/snapcompact": "16.1.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", @@ -355,11 +357,11 @@ "@typescript/native-preview": "7.0.0-dev.20260609.1", "@xterm/headless": "^6.0.0", "arktype": "^2.2.0", - "beautiful-mermaid": "^1.1.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", "diff": "^9.0.0", + "fast-xml-parser": "^5.9.0", "fastembed": "2.1.0", "fflate": "0.8.3", "ghostty-web": "^0.4.0", @@ -368,8 +370,9 @@ "lint-staged": "^17.0.7", "lru-cache": "11.5.1", "lucide-react": "^1.17.0", + "mammoth": "^1.12.0", "marked": "^18.0.5", - "markit-ai": "0.5.3", + "mupdf": "^1.27.0", "onnxruntime-node": "1.26.0", "partial-json": "^0.1.7", "postcss": "^8.5.15", @@ -460,8 +463,6 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], - "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], @@ -470,7 +471,7 @@ "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], @@ -860,10 +861,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], - "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], - - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@ts-morph/common": ["@ts-morph/common@0.29.0", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -936,8 +933,6 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], - "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], - "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], @@ -988,8 +983,6 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1034,8 +1027,6 @@ "electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="], - "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], - "emnapi": ["emnapi@1.11.1", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1062,8 +1053,6 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1080,12 +1069,8 @@ "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], - "file-stream-rotator": ["file-stream-rotator@0.6.1", "", { "dependencies": { "moment": "^2.29.1" } }, "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ=="], - "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], - "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], @@ -1126,8 +1111,6 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1210,12 +1193,8 @@ "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], - "markit-ai": ["markit-ai@0.5.3", "", { "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "exifr": "^7.1.3", "fast-xml-parser": "^5.5.9", "jszip": "^3.10.1", "mammoth": "^1.9.0", "mupdf": "^1.27.0", "music-metadata": "^11.12.3", "rss-parser": "^3.13.0", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2" }, "bin": { "markit": "dist/main.js" } }, "sha512-h4nhn6a/SNXEdc3kLVtL37TspxjUNCNL0OM7LRWxd389ZByI/B7bjNNgxFdVAT0O+H7ZekSwLdVe/lws1l2AZQ=="], - "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], - "media-typer": ["media-typer@2.0.0", "", {}, "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q=="], - "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -1240,8 +1219,6 @@ "mupdf": ["mupdf@1.27.0", "", {}, "sha512-vEPUYwZeu5NgiFLz4e20R7Vp2pNY7szirGEvTxHyQQpQs6ab4DeGdonwT6sH1JZG5EhyHSrojZrZn2/0ee6qZQ=="], - "music-metadata": ["music-metadata@11.13.0", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^2.0.0", "debug": "^4.4.3", "file-type": "^21.3.4", "media-typer": "^2.0.0", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw=="], - "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -1322,16 +1299,12 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], - "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], @@ -1384,14 +1357,12 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], - "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], - "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -1404,8 +1375,6 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], - "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], "ts-morph": ["ts-morph@28.0.0", "", { "dependencies": { "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g=="], @@ -1428,8 +1397,6 @@ "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], - "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -1448,8 +1415,6 @@ "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], - "win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="], - "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], "winston-daily-rotate-file": ["winston-daily-rotate-file@5.0.0", "", { "dependencies": { "file-stream-rotator": "^0.6.1", "object-hash": "^3.0.0", "triple-beam": "^1.4.1", "winston-transport": "^4.7.0" }, "peerDependencies": { "winston": "^3" } }, "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw=="], @@ -1464,8 +1429,6 @@ "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1514,13 +1477,11 @@ "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], @@ -1560,18 +1521,12 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@huggingface/transformers/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], @@ -1590,8 +1545,6 @@ "fastembed/onnxruntime-node/tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="], - "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/crates/brush-builtins-vendored/LICENSE b/crates/brush-builtins-vendored/LICENSE index 3d3cb7866..a27368d14 100644 --- a/crates/brush-builtins-vendored/LICENSE +++ b/crates/brush-builtins-vendored/LICENSE @@ -1,6 +1,17 @@ -MIT License +This directory contains a vendored, locally-patched copy of brush-builtins from +the brush shell project (https://github.com/reubeno/brush), used under the MIT +License. It is wired into the workspace via `[patch.crates-io]` so these +modifications can be maintained against the published release (brush-builtins +0.2.0). -Copyright (c) 2024 reuben olinsky + Copyright (c) 2024 reuben olinsky + +Local modifications include: implementing previously-stubbed builtins, the +`wait` builtin (PID targeting plus the `-f`/`-n`/`-p` flags), and cancellation +support for the `read` builtin via poll. The remaining logic tracks upstream so +behavior matches the published crate. + +MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/brush-core-vendored/LICENSE b/crates/brush-core-vendored/LICENSE index 3d3cb7866..b9909c384 100644 --- a/crates/brush-core-vendored/LICENSE +++ b/crates/brush-core-vendored/LICENSE @@ -1,6 +1,18 @@ -MIT License +This directory contains a vendored, locally-patched copy of brush-core from the +brush shell project (https://github.com/reubeno/brush), used under the MIT +License. It is wired into the workspace via `[patch.crates-io]` so these +modifications can be maintained against the published release (brush-core +0.5.0). -Copyright (c) 2024 reuben olinsky + Copyright (c) 2024 reuben olinsky + +Local modifications include: Windows path handling fixes (drive-alias and +rooted-path normalization in globs), an async background-PID fix for the nohup +wrapper, treating `/dev/null` as poll-ready to avoid an infinite poll on macOS, +and indentation/formatting standardization. The remaining logic tracks upstream +so behavior matches the published crate. + +MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 310816aed..5fa1f533e 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -172,7 +172,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV16_0_7")] +#[napi(js_name = "__piNativesV16_1_2")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-shell/ATTRIBUTION-RTK.md b/crates/pi-shell/ATTRIBUTION-RTK.md deleted file mode 100644 index 0be0b52ac..000000000 --- a/crates/pi-shell/ATTRIBUTION-RTK.md +++ /dev/null @@ -1,46 +0,0 @@ -# Third-party attribution — RTK - -Portions of the shell-output minimizer adapt algorithms from **RTK** -(`rtk-ai/rtk`), used under the MIT License, which is compatible with this -workspace's MIT License. - -## Ported component - -- **Upstream:** [`rtk-ai/rtk`](https://github.com/rtk-ai/rtk) @ commit - `878af7de99e0ba71da2e8fd996f6b52a1836e06c` -- **Upstream path:** `src/cmds/python/pytest_cmd.rs` -- **Local path:** `crates/pi-shell/src/minimizer/filters/python.rs` -- **What was adapted:** the `build_pytest_summary` algorithm — re-implemented - here as the pytest state machine (`filter_pytest`, `pytest_success`, - `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves failures, - errors, and the final summary line; strips header framing, progress dots, and - verbose `PASSED` rows; and falls through unchanged on unknown-state lines - (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters never - cause data loss. - -## License (MIT) - -RTK is distributed under the MIT License. A copy of the upstream license text -is reproduced below for the pinned revision above. - -``` -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` diff --git a/crates/pi-shell/Cargo.toml b/crates/pi-shell/Cargo.toml index 74cec362f..be2cccc4d 100644 --- a/crates/pi-shell/Cargo.toml +++ b/crates/pi-shell/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true authors.workspace = true repository.workspace = true # Portions of the minimizer adapt MIT-licensed algorithms from rtk-ai/rtk. -# See ATTRIBUTION-RTK.md at this crate root (packaged on publish). +# See NOTICE at this crate root (packaged on publish). [lints] workspace = true diff --git a/crates/pi-shell/NOTICE b/crates/pi-shell/NOTICE new file mode 100644 index 000000000..821ec3763 --- /dev/null +++ b/crates/pi-shell/NOTICE @@ -0,0 +1,35 @@ +This file documents third-party attribution for the shell-output minimizer in +this crate, which adapts an algorithm from RTK (https://github.com/rtk-ai/rtk), +used under the MIT License. + + Copyright (c) the RTK authors (rtk-ai/rtk) + +Adapted component: the `build_pytest_summary` algorithm from the upstream +`src/cmds/python/pytest_cmd.rs` (pinned at commit +878af7de99e0ba71da2e8fd996f6b52a1836e06c), re-implemented here in +`src/minimizer/filters/python.rs` as the pytest state machine (`filter_pytest`, +`pytest_success`, `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves +failures, errors, and the final summary line; strips header framing, progress +dots, and verbose `PASSED` rows; and falls through unchanged on unknown-state +lines (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters +never cause data loss. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/pi-shell/src/minimizer/engine.rs b/crates/pi-shell/src/minimizer/engine.rs index d228d8d6c..f7033ac53 100644 --- a/crates/pi-shell/src/minimizer/engine.rs +++ b/crates/pi-shell/src/minimizer/engine.rs @@ -1097,10 +1097,15 @@ strip_lines_matching = [".*"] } let out = apply("rails db:migrate", &input, 0, &cfg); assert!(out.changed); - // The def's max_lines=40 produces ONE truncation marker. - // filter_rake's head_tail marker ("lines omitted") must NOT appear. - assert!(!out.text.contains("lines omitted"), "double truncation detected: {:#?}", out.text); - assert!(out.text.contains("lines truncated")); + // Only the def's max_lines=40 cap should fire; filter_rake's head_tail must + // NOT also condense. Both now emit `[…Nln elided…]`, so a single marker + // proves no double truncation (two would mean both stages fired). + assert_eq!( + out.text.matches("ln elided…]").count(), + 1, + "double truncation detected: {:#?}", + out.text + ); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/binary_tools.rs b/crates/pi-shell/src/minimizer/filters/binary_tools.rs index b0c1e60e1..5154ec180 100644 --- a/crates/pi-shell/src/minimizer/filters/binary_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/binary_tools.rs @@ -73,7 +73,7 @@ mod tests { // 50 head + 20 tail + 1 marker = 71 lines let line_count = out.text.lines().count(); assert_eq!(line_count, HEAD_LINES + TAIL_LINES + 1, "got {line_count} lines: {out:?}"); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); // Head anchor preserved. assert!(out.text.starts_with("00000000: 00000000")); } @@ -95,7 +95,7 @@ mod tests { let context = ctx("strings", "strings /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] @@ -105,7 +105,7 @@ mod tests { let context = ctx("od", "od -c /bin/ls", &cfg); let out = filter(&context, &input, 0); assert!(out.changed); - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/bun.rs b/crates/pi-shell/src/minimizer/filters/bun.rs index af86b5710..d1f1e6d59 100644 --- a/crates/pi-shell/src/minimizer/filters/bun.rs +++ b/crates/pi-shell/src/minimizer/filters/bun.rs @@ -253,9 +253,9 @@ fn compact_bun_check_output(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) } let omitted = nonzero_exits.len() + diagnostics.len(); if omitted > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(omitted - 40).to_string()); - out.push_str(" diagnostic lines omitted\n"); + out.push_str(" diagnostic lines elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/cloud.rs b/crates/pi-shell/src/minimizer/filters/cloud.rs index 5495585b3..527f381fc 100644 --- a/crates/pi-shell/src/minimizer/filters/cloud.rs +++ b/crates/pi-shell/src/minimizer/filters/cloud.rs @@ -474,7 +474,7 @@ fn compact_aws_generic(root: &Value) -> Option { let mut out = compact_named_rows(&columns.iter().map(String::as_str).collect::>(), &values); if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more {name}", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} {name} elided…]", rows.len() - MAX_AWS_ROWS); } return Some(out); } @@ -559,7 +559,7 @@ fn compact_named_rows(headers: &[&str], rows: &[Vec]) -> String { out.push('\n'); } if rows.len() > MAX_AWS_ROWS { - let _ = writeln!(out, "... +{} more rows", rows.len() - MAX_AWS_ROWS); + let _ = writeln!(out, "[…{} rows elided…]", rows.len() - MAX_AWS_ROWS); } out } @@ -761,9 +761,7 @@ fn compact_aws_dynamodb_items(items: &[&serde_json::Map]) -> Stri out.push('\n'); } if items.len() > 40 { - out.push_str("… "); - out.push_str(&(items.len() - 40).to_string()); - out.push_str(" item(s) omitted …\n"); + let _ = writeln!(out, "[…{} items elided…]", items.len() - 40); } let _ = writeln!(out, "{} item(s)", items.len()); out @@ -1010,7 +1008,7 @@ fn compact_line(line: &str, max_chars: usize) -> String { let edge = max_chars / 2; let start: String = chars.iter().take(edge).collect(); let end: String = chars.iter().skip(chars.len() - edge).collect(); - format!("{start} … {} chars omitted … {end}", chars.len() - edge * 2) + format!("{start}[…{}ch elided…]{end}", chars.len() - edge * 2) } fn looks_like_table(input: &str) -> bool { @@ -1073,7 +1071,7 @@ fn compact_delimited_table(input: &str, max_rows: usize) -> String { } } if data_rows > max_rows { - out.push(format!("… {} more rows", data_rows - max_rows)); + out.push(format!("[…{} rows elided…]", data_rows - max_rows)); } join_lines(out) } @@ -1114,7 +1112,7 @@ fn compact_psql_table(input: &str) -> String { } if data_rows > MAX_PSQL_ROWS { - out.push(format!("... +{} more rows", data_rows - MAX_PSQL_ROWS)); + out.push(format!("[…{} rows elided…]", data_rows - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) @@ -1152,7 +1150,7 @@ fn compact_psql_expanded(input: &str) -> String { } flush_record(&mut out, &mut current, records); if records > MAX_PSQL_ROWS { - out.push(format!("... +{} more records", records - MAX_PSQL_ROWS)); + out.push(format!("[…{} records elided…]", records - MAX_PSQL_ROWS)); } out.extend(row_count_lines); join_lines(out) diff --git a/crates/pi-shell/src/minimizer/filters/docker.rs b/crates/pi-shell/src/minimizer/filters/docker.rs index bc27d7c70..d6e81d2bd 100644 --- a/crates/pi-shell/src/minimizer/filters/docker.rs +++ b/crates/pi-shell/src/minimizer/filters/docker.rs @@ -745,9 +745,9 @@ fn compact_table(input: &str, visible_rows: usize) -> String { out.push_str(line.trim_end()); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&(lines.len() - 1 - visible_rows).to_string()); - out.push_str(" more rows\n"); + out.push_str(" rows elided…]\n"); out } @@ -908,7 +908,7 @@ mod tests { let out = filter(&compose_ctx, &input, 0).text; assert!(out.contains("20 rows")); assert!(out.contains("svc-0")); - assert!(out.contains("… 8 more rows")); + assert!(out.contains("[…8 rows elided…]")); } #[test] @@ -988,7 +988,7 @@ mod tests { assert!(out.contains("api-1 | request 0 complete")); assert!(out.contains("api-1 | WARN cache miss")); assert!(out.contains("worker | failed to process job")); - assert!(out.contains("omitted")); + assert!(out.contains("elided")); } #[test] @@ -1000,7 +1000,7 @@ mod tests { } let out = compact_table(&input, 10); assert!(out.contains("25 rows")); - assert!(out.contains("… 15 more rows")); + assert!(out.contains("[…15 rows elided…]")); } fn ctx<'a>( @@ -1071,7 +1071,7 @@ mod tests { assert!(out.contains("25 rows"), "docker --format table output should still compact: {out}"); assert!( - out.contains("… 13 more rows"), + out.contains("[…13 rows elided…]"), "docker --format table should keep table omission: {out}" ); } @@ -1390,7 +1390,7 @@ mod tests { } let out = filter(&kubectl_ctx, &input, 0).text; assert!( - out.contains("lines omitted") || out.contains("lines truncated"), + out.contains("ln elided…]"), "kubectl apply output should be head/tail condensed: {out}" ); } diff --git a/crates/pi-shell/src/minimizer/filters/dotnet.rs b/crates/pi-shell/src/minimizer/filters/dotnet.rs index 51ef76248..0838532e3 100644 --- a/crates/pi-shell/src/minimizer/filters/dotnet.rs +++ b/crates/pi-shell/src/minimizer/filters/dotnet.rs @@ -184,9 +184,9 @@ fn compact_format_json(input: &str) -> Option { out.push('\n'); } if rows.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(rows.len() - 40).to_string()); - out.push_str(" more diagnostics\n"); + out.push_str(" diagnostics elided…]\n"); } Some(out) } diff --git a/crates/pi-shell/src/minimizer/filters/gh.rs b/crates/pi-shell/src/minimizer/filters/gh.rs index 7ca39e0b3..914f16c27 100644 --- a/crates/pi-shell/src/minimizer/filters/gh.rs +++ b/crates/pi-shell/src/minimizer/filters/gh.rs @@ -319,7 +319,7 @@ mod tests { // First failures stay verbatim with their actionable URLs. assert!(out.text.contains("X\tcheck0\t1s\thttps://ci.test/0")); // The output is capped, not 501 lines of raw rows. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); assert!(out.text.lines().count() < 60); assert!(!out.text.contains("https://ci.test/499")); } diff --git a/crates/pi-shell/src/minimizer/filters/git.rs b/crates/pi-shell/src/minimizer/filters/git.rs index 3320433fd..251f65d8c 100644 --- a/crates/pi-shell/src/minimizer/filters/git.rs +++ b/crates/pi-shell/src/minimizer/filters/git.rs @@ -209,7 +209,7 @@ fn compact_diff_listing(input: &str, mode: DiffListingMode) -> String { out.push_str(entry); out.push('\n'); } - let _ = writeln!(out, "… {} files omitted …", entries.len() - DIFF_LISTING_LIMIT); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - DIFF_LISTING_LIMIT); out } @@ -512,9 +512,7 @@ fn format_status_summary(summary: &StatusSummary) -> String { out.push('\n'); } if summary.paths.len() > 40 { - out.push_str("… "); - out.push_str(&(summary.paths.len() - 40).to_string()); - out.push_str(" paths omitted\n"); + let _ = writeln!(out, "[…{} paths elided…]", summary.paths.len() - 40); } out } @@ -531,9 +529,7 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String { for entry in entries.iter().take(head) { push_log_entry(&mut out, entry); } - out.push_str("… "); - out.push_str(&(entries.len() - head - tail).to_string()); - out.push_str(" commits omitted …\n"); + let _ = writeln!(out, "[…{} commits elided…]", entries.len() - head - tail); for entry in entries.iter().skip(entries.len() - tail) { push_log_entry(&mut out, entry); } @@ -566,8 +562,8 @@ struct LogEntry { subject: String, body: Vec, /// Body lines dropped past the per-commit body cap, surfaced as an explicit - /// `[+N lines omitted]` marker instead of being silently lost. - omitted: usize, + /// `[…Nln elided…]` marker instead of being silently lost. + elided: usize, } /// Soft cap on rendered subject/body line width. Long commit subjects and body @@ -587,10 +583,8 @@ fn push_log_entry(out: &mut String, entry: &LogEntry) { out.push_str(&primitives::truncate_line(line, LOG_LINE_WIDTH)); out.push('\n'); } - if entry.omitted > 0 { - out.push_str(" [+"); - out.push_str(&entry.omitted.to_string()); - out.push_str(" lines omitted]\n"); + if entry.elided > 0 { + let _ = writeln!(out, " […{}ln elided…]", entry.elided); } } @@ -611,7 +605,7 @@ fn parse_log_entries(input: &str) -> Vec { hash: short_hash(hash), subject: subject.to_string(), body: Vec::new(), - omitted: 0, + elided: 0, }); continue; } @@ -627,12 +621,12 @@ fn parse_log_entries(input: &str) -> Vec { entry.subject = trimmed.to_string(); } else if !is_git_trailer(trimmed) { // Real (non-trailer) body lines past the 3-line cap are tallied so - // `push_log_entry` can emit an explicit `[+N lines omitted]` marker + // `push_log_entry` can emit an explicit `[…Nln elided…]` marker // rather than dropping them silently. if entry.body.len() < 3 { entry.body.push(trimmed.to_string()); } else { - entry.omitted += 1; + entry.elided += 1; } } } @@ -930,9 +924,7 @@ pub(crate) fn compact_diff_output(input: &str) -> String { out.push('\n'); } if files.len() > 20 { - out.push_str("… "); - out.push_str(&(files.len() - 20).to_string()); - out.push_str(" files omitted from stat\n"); + let _ = writeln!(out, "[…{} files from stat elided…]", files.len() - 20); } out.push_str(&format_file_count(files.len())); out.push_str(" changed, "); @@ -956,21 +948,15 @@ pub(crate) fn compact_diff_output(input: &str) -> String { out.push('\n'); } if hunk.lines.len() > 6 { - out.push_str(" … "); - out.push_str(&(hunk.lines.len() - 6).to_string()); - out.push_str(" changed lines omitted\n"); + let _ = writeln!(out, " […{} changed lines elided…]", hunk.lines.len() - 6); } } if file.hunks.len() > 8 { - out.push_str(" … "); - out.push_str(&(file.hunks.len() - 8).to_string()); - out.push_str(" hunks omitted\n"); + let _ = writeln!(out, " […{} hunks elided…]", file.hunks.len() - 8); } } if files.len() > 12 { - out.push_str("\n… "); - out.push_str(&(files.len() - 12).to_string()); - out.push_str(" files omitted from changes\n"); + let _ = writeln!(out, "\n[…{} files from changes elided…]", files.len() - 12); } out } @@ -1311,7 +1297,7 @@ fn condense_diff_stat(input: &str) -> String { out.push('\n'); } if entries.len() > 20 { - let _ = writeln!(out, "… {} files omitted …", entries.len() - 20); + let _ = writeln!(out, "[…{} files elided…]", entries.len() - 20); } out } @@ -1528,7 +1514,7 @@ fn condense_worktree(input: &str) -> String { /// /// Listing-shaped lines (` []`, plus the `(bare)` and /// `(detached HEAD)` variants) get a leading `$HOME` abbreviated to `~` and are -/// capped with an omitted-count marker. Any non-listing output (`add`'s +/// capped with an elided-count marker. Any non-listing output (`add`'s /// "Preparing worktree…"/"HEAD is now at …" confirmations, errors) is left to /// `condense_noisy_output`/passthrough so its meaning is preserved. fn condense_worktree_with_home(input: &str, home: &str) -> String { @@ -1554,9 +1540,7 @@ fn condense_worktree_with_home(input: &str, home: &str) -> String { out.push('\n'); } if entries.len() > WORKTREE_LIMIT { - out.push_str("… "); - out.push_str(&(entries.len() - WORKTREE_LIMIT).to_string()); - out.push_str(" worktrees omitted …\n"); + let _ = writeln!(out, "[…{} worktrees elided…]", entries.len() - WORKTREE_LIMIT); } out } @@ -1920,7 +1904,7 @@ mod tests { input.push('\n'); } let out = filter(&ctx, &input, 0); - assert!(out.text.contains("… 22 commits omitted …")); + assert!(out.text.contains("[…22 commits elided…]")); assert!(out.text.contains("abcdef1 message 0")); assert!(!out.text.contains("message 47")); assert!(out.text.contains("abcdef1 message 69")); @@ -1983,7 +1967,7 @@ mod tests { } #[test] - fn log_body_over_cap_shows_omitted_marker() { + fn log_body_over_cap_shows_elided_marker() { let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log", &cfg); // Subject + 5 body lines: 3 kept, 2 past the cap surfaced as a marker. @@ -1995,7 +1979,7 @@ mod tests { assert!(out.text.contains("body line one")); assert!(out.text.contains("body line three")); assert!(!out.text.contains("body line four"), "{:?}", out.text); - assert!(out.text.contains("[+2 lines omitted]"), "{:?}", out.text); + assert!(out.text.contains("[…2ln elided…]"), "{:?}", out.text); } #[test] @@ -2062,7 +2046,7 @@ mod tests { assert!(out.text.contains("src/file-0.rs\n")); assert!(out.text.contains("src/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 6 files omitted …")); + assert!(out.text.contains("[…6 files elided…]")); } #[test] @@ -2091,7 +2075,7 @@ mod tests { assert!(out.text.contains("R100\told-0.rs\tnew-0.rs\n")); assert!(out.text.contains("M\tpath-1.rs\n")); assert!(!out.text.contains("path-20.rs\n")); - assert!(out.text.contains("… 4 files omitted …")); + assert!(out.text.contains("[…4 files elided…]")); } #[test] @@ -2139,7 +2123,7 @@ mod tests { assert!(out.text.contains("1\t0\tsrc/file-0.rs\n")); assert!(out.text.contains("20\t5\tsrc/file-19.rs\n")); assert!(!out.text.contains("src/file-20.rs\n")); - assert!(out.text.contains("… 2 files omitted …")); + assert!(out.text.contains("[…2 files elided…]")); } #[test] @@ -2695,13 +2679,13 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details. assert!(out.text.contains("fatal:"), "error header must survive: {:?}", out.text); assert!(out.text.contains("badref"), "offending ref must survive: {:?}", out.text); - assert!(!out.text.contains("commits omitted"), "must not fabricate commit listing on error"); + assert!(!out.text.contains("commits elided"), "must not fabricate commit listing on error"); } #[test] fn log_oneline_short_run_emits_all_entries() { // A short log that fits within head+tail should emit all entries - // without any "omitted" line, and each entry should carry the + // without any "elided" line, and each entry should carry the // 7-char short hash followed by the subject. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("log"), "git log -5", &cfg); @@ -2712,7 +2696,7 @@ commit abcdef1234567890\nAuthor: A \nDate: today\n feat: first\ncomm let out = filter(&ctx, input, 0); - assert!(!out.text.contains("commits omitted")); + assert!(!out.text.contains("commits elided")); assert!(out.text.contains("abcdef1 feat: first"), "{:?}", out.text); assert!(out.text.contains("1111111 fix: second"), "{:?}", out.text); assert!(out.text.contains("2222222 chore: third"), "{:?}", out.text); @@ -2780,8 +2764,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma assert!(out.contains("(bare)")); assert!(out.contains("(detached HEAD)")); assert!(!out.contains("/home/alice"), "no absolute $HOME paths should survive: {out:?}"); - // 22 entries → capped at 20 with a 2-omitted marker. - assert!(out.contains("… 2 worktrees omitted …"), "{out:?}"); + // 22 entries → capped at 20 with a 2-elided marker. + assert!(out.contains("[…2 worktrees elided…]"), "{out:?}"); assert_eq!(out.lines().filter(|l| l.starts_with('~')).count(), 20); } @@ -2799,7 +2783,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma #[test] fn worktree_porcelain_passthrough() { // `git worktree list --porcelain` emits machine-readable records; - // the minimizer must not insert "… N lines omitted …" markers. + // the minimizer must not insert "[…Nln elided…]" markers. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list --porcelain", &cfg); let input = "worktree /home/user/project\nHEAD abc1234def\nbranch \ @@ -2825,7 +2809,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma fn worktree_non_porcelain_still_condensed() { // Normal (non-porcelain) listing should still go through the condenser. // Build 22 listing-shaped entries so the cap (20) is exceeded; the condenser - // would insert an "… N worktrees omitted …" marker that a passthrough would + // would insert an "[…N worktrees elided…]" marker that a passthrough would // never emit. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("worktree"), "git worktree list", &cfg); @@ -2835,8 +2819,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma } let out = filter(&ctx, &input, 0); assert!( - out.text.contains("worktrees omitted"), - "non-porcelain output over the cap must show omitted marker; got: {:?}", + out.text.contains("worktrees elided"), + "non-porcelain output over the cap must show elided marker; got: {:?}", out.text ); } diff --git a/crates/pi-shell/src/minimizer/filters/glab.rs b/crates/pi-shell/src/minimizer/filters/glab.rs index 7ca413aaa..6b8bcb730 100644 --- a/crates/pi-shell/src/minimizer/filters/glab.rs +++ b/crates/pi-shell/src/minimizer/filters/glab.rs @@ -211,11 +211,11 @@ fn filter_release_list(input: &str) -> Option { // Append omission marker when there are more releases than shown. let omitted = total.map_or(0, |t| t.saturating_sub(count)); if omitted > 0 { - let _ = writeln!(filtered, "… {omitted} releases omitted …"); + let _ = writeln!(filtered, "[…{omitted} releases elided…]"); } else if has_more { // Total not parsed from preamble but a 21st row was observed; signal // truncation. - filtered.push_str("… releases omitted (showing first 20) …\n"); + filtered.push_str("[…releases elided…]\n"); } Some(filtered) @@ -661,8 +661,8 @@ section_end:1711234600:build_script[0K let out = filter(&ctx, &input, 0); assert!(out.changed); - // Output must signal that releases were omitted. - assert!(out.text.contains("omitted"), "expected omission marker, got: {}", out.text); + // Output must signal that releases were elided. + assert!(out.text.contains("elided"), "expected omission marker, got: {}", out.text); // First release present, 21st not shown verbatim in the list. assert!(out.text.contains("Release 1")); assert!(!out.text.contains("Release 21 [")); diff --git a/crates/pi-shell/src/minimizer/filters/go.rs b/crates/pi-shell/src/minimizer/filters/go.rs index e1e893cec..05b32bf5b 100644 --- a/crates/pi-shell/src/minimizer/filters/go.rs +++ b/crates/pi-shell/src/minimizer/filters/go.rs @@ -341,9 +341,9 @@ fn summarize_golangci_json(line: &str) -> Option { out.push_str(")\n"); } if issues.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(issues.len() - 40).to_string()); - out.push_str(" more issues\n"); + out.push_str(" issues elided…]\n"); } Some(out) } @@ -506,6 +506,22 @@ mod tests { let out = filter_golangci_lint(input); assert!(out.contains("golangci-lint: 1 issues")); assert!(out.contains("main.go:7:2: unreachable code (govet)")); + + // Match up-to-40 limits, testing elison formatting + let mut many_issues = r#"{"Issues":["#.to_string(); + for i in 0..42 { + if i > 0 { + many_issues.push(','); + } + let _ = write!( + many_issues, + r#"{{"FromLinter":"govet","Text":"err {i}","Pos":{{"Filename":"main.go","Line":{i},"Column":2}}}}"# + ); + } + many_issues.push_str("]}"); + let out_many = filter_golangci_lint(&many_issues); + assert!(out_many.contains("golangci-lint: 42 issues")); + assert!(out_many.contains("[…2 issues elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/gt.rs b/crates/pi-shell/src/minimizer/filters/gt.rs index e5d6c5baf..3c76e995c 100644 --- a/crates/pi-shell/src/minimizer/filters/gt.rs +++ b/crates/pi-shell/src/minimizer/filters/gt.rs @@ -91,9 +91,7 @@ fn compact_log(input: &str) -> String { } if omitted_entries > 0 { - out.push_str("… "); - out.push_str(&omitted_entries.to_string()); - out.push_str(" entries omitted …\n"); + let _ = writeln!(out, "[…{omitted_entries} entries elided…]"); } primitives::head_tail_lines(&out, 80, 24) @@ -172,13 +170,13 @@ fn dense_sync_summary(input: &str, exit_code: i32) -> Option { // head_tail_lines bound that compact_noisy_command (the fallback) applies. // Without a cap every name lands on one unbounded line, defeating the // minimizer's bounding guarantee. Show the first DELETED_NAME_CAP names and - // summarize the rest as `+N more` (the count above stays exact). + // summarize the rest as `[…N names elided…]` (the count above stays exact). const DELETED_NAME_CAP: usize = 20; let shown = deleted_names.len().min(DELETED_NAME_CAP); let names = deleted_names[..shown].join(", "); let _ = write!(summary, " ({names}"); if deleted_names.len() > DELETED_NAME_CAP { - let _ = write!(summary, ", +{} more", deleted_names.len() - DELETED_NAME_CAP); + let _ = write!(summary, " […{} names elided…]", deleted_names.len() - DELETED_NAME_CAP); } summary.push(')'); } @@ -344,7 +342,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("abc1230")); - assert!(out.text.contains("entries omitted")); + assert!(out.text.contains("entries elided")); assert!(!out.text.contains("user@example.com")); } @@ -439,7 +437,8 @@ mod tests { fn sync_dense_summary_caps_deleted_name_list() { // A long-lived stack cleanup can delete hundreds of merged branches at // once. The dense summary must stay bounded: cap the inline name list and - // summarize the remainder as `+N more`, never emit one unbounded line. + // summarize the remainder as `[…N names elided…]`, never emit one unbounded + // line. let cfg = MinimizerConfig { enabled: true, ..Default::default() }; let ctx = test_ctx(Some("sync"), &cfg); let mut input = String::from("Synced with remote\n"); @@ -452,9 +451,9 @@ mod tests { assert!(out.changed); // Exact deleted count is preserved. assert!(out.text.contains("ok sync: 1 synced, 500 deleted")); - // First names stay visible; the rest collapse to a `+N more` marker. + // First names stay visible; the rest collapse to a `[…N names elided…]` marker. assert!(out.text.contains("feat/merged-0")); - assert!(out.text.contains("+480 more")); + assert!(out.text.contains("[…480 names elided…]")); // Bounded: a single short line, not 500 names concatenated. assert!(!out.text.contains("feat/merged-499")); let longest = out.text.lines().map(str::len).max().unwrap_or(0); diff --git a/crates/pi-shell/src/minimizer/filters/js_tools.rs b/crates/pi-shell/src/minimizer/filters/js_tools.rs index 01828f959..60e88f074 100644 --- a/crates/pi-shell/src/minimizer/filters/js_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/js_tools.rs @@ -219,9 +219,9 @@ fn filter_prettier(input: &str, exit_code: i32) -> String { push_line(&mut out, error); } if errors.len() > 20 { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(errors.len() - 20).to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } return out; } @@ -391,9 +391,9 @@ fn push_file_list(out: &mut String, files: &[String], limit: usize) { push_line(out, file); } if files.len() > limit { - out.push_str("... +"); + out.push_str("[…"); out.push_str(&(files.len() - limit).to_string()); - out.push_str(" more files\n"); + out.push_str(" files elided…]\n"); } } @@ -404,6 +404,8 @@ fn push_line(out: &mut String, line: &str) { #[cfg(test)] mod tests { + use std::fmt::Write as _; + use super::*; use crate::minimizer::MinimizerConfig; @@ -532,4 +534,29 @@ mod tests { assert!(out.contains("P3009")); assert!(!out.contains("Datasource")); } + + #[test] + fn prettier_errors_truncation_is_canonical() { + let mut input = String::new(); + for i in 1..=25 { + let _ = writeln!(input, "error in file{i}.js: line {i}"); + } + let out = filter_prettier(&input, 1); + assert!(out.contains("[…5 errors elided…]\n")); + assert!(!out.contains("more errors")); + assert!(!out.contains("... +")); + } + + #[test] + fn file_list_truncation_is_canonical() { + let mut files = Vec::new(); + for i in 1..=35 { + files.push(format!("src/file{i}.tsx")); + } + let mut out = String::new(); + push_file_list(&mut out, &files, 30); + assert!(out.contains("[…5 files elided…]\n")); + assert!(!out.contains("more files")); + assert!(!out.contains("... +")); + } } diff --git a/crates/pi-shell/src/minimizer/filters/jvm.rs b/crates/pi-shell/src/minimizer/filters/jvm.rs index 954c8b443..deb7e9645 100644 --- a/crates/pi-shell/src/minimizer/filters/jvm.rs +++ b/crates/pi-shell/src/minimizer/filters/jvm.rs @@ -606,7 +606,7 @@ impl<'a> SurefireBlock<'a> { /// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of a /// failing test run; on builds with hundreds of failures this can be large. Cap -/// entries at [`max_mvn_failing_classes`] and emit `\n… +N more failures\n` +/// entries at [`max_mvn_failing_classes`] and emit `\n[…N failures elided…]\n` /// immediately before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, @@ -648,14 +648,14 @@ impl FailuresSummaryCap { } } - /// Pre-emit the `… +N more failures` tail when the aggregate + /// Pre-emit the `[…N failures elided…]` tail when the aggregate /// `[ERROR] Tests run:` line is about to be written, then close the summary. fn handle_aggregate(&mut self, line: &str, out: &mut String) { if !self.in_summary || !AGG.is_match(line) { return; } if self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } self.in_summary = false; self.emitted = 0; @@ -665,7 +665,7 @@ impl FailuresSummaryCap { /// End-of-stream tail emission for cases where the AGG line never arrives. fn finish(&self, out: &mut String) { if self.in_summary && self.dropped > 0 { - let _ = write!(out, "\n… +{} more failures\n", self.dropped); + let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped); } } } @@ -744,7 +744,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -903,7 +903,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { block.finish(&mut out); summary.finish(&mut out); if dropped_failing > 0 { - let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n"); + let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n"); } out } @@ -1602,7 +1602,7 @@ fn filter_gradle_dependencies(input: &str) -> String { let _ = writeln!(result, " {dep}"); } if deps.len() > cap { - let _ = writeln!(result, " ... +{} more", deps.len() - cap); + let _ = writeln!(result, " […{} dependencies elided…]", deps.len() - cap); } } @@ -1994,7 +1994,7 @@ mod tests { "capped class second per-test block dropped (re-arm inherits drop); got:\n{o}" ); assert!( - o.contains("… +1 more failing test classes"), + o.contains("[…1 failing test classes elided…]"), "tail counts one class, not one per failure; got:\n{o}" ); } @@ -2214,7 +2214,7 @@ mod tests { "Fail{n} exception dropped; got:\n{o}" ); } - assert!(o.contains("… +2 more failing test classes"), "tail emitted; got:\n{o}"); + assert!(o.contains("[…2 failing test classes elided…]"), "tail emitted; got:\n{o}"); } #[test] @@ -2235,7 +2235,7 @@ mod tests { "Fail{n} dropped under cap=0; got:\n{o}" ); } - assert!(o.contains("+5 more failing test classes"), "tail counts all 5; got:\n{o}"); + assert!(o.contains("[…5 failing test classes elided…]"), "tail counts all 5; got:\n{o}"); } #[test] @@ -2256,7 +2256,7 @@ mod tests { for n in 4..=5 { assert!(!o.contains(&format!("ClassA.test{n}:25")), "entry {n} dropped; got:\n{o}"); } - let tail_idx = o.find("… +2 more failures").expect("tail must appear"); + let tail_idx = o.find("[…2 failures elided…]").expect("tail must appear"); let agg_idx = o .find("[ERROR] Tests run: 100") .expect("aggregate must appear"); diff --git a/crates/pi-shell/src/minimizer/filters/lint.rs b/crates/pi-shell/src/minimizer/filters/lint.rs index f89ac0f54..38d804534 100644 --- a/crates/pi-shell/src/minimizer/filters/lint.rs +++ b/crates/pi-shell/src/minimizer/filters/lint.rs @@ -540,9 +540,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if entries.len() > 12 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(entries.len() - 12).to_string()); - out.push_str(" more\n"); + out.push_str(" diagnostics elided…]\n"); } } @@ -551,9 +551,9 @@ pub fn group_diagnostics(input: &str) -> String { out.push('\n'); } if ungrouped.len() > 40 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(ungrouped.len() - 40).to_string()); - out.push_str(" ungrouped lines omitted\n"); + out.push_str(" ungrouped lines elided…]\n"); } out } @@ -701,7 +701,7 @@ mod tests { } let out = group_diagnostics(&input); assert!(out.contains("src/main.rs (20 diagnostics)")); - assert!(out.contains("… 8 more")); + assert!(out.contains(" […8 diagnostics elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/listing.rs b/crates/pi-shell/src/minimizer/filters/listing.rs index a130a7dd6..f3f50fbda 100644 --- a/crates/pi-shell/src/minimizer/filters/listing.rs +++ b/crates/pi-shell/src/minimizer/filters/listing.rs @@ -173,16 +173,16 @@ fn compact_grep_grouped( out.push('\n'); } if matches.len() > 4 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(matches.len() - 4).to_string()); - out.push_str(" more in file\n"); + out.push_str(" matches in file elided…]\n"); } } let omitted_files = grouped.len().saturating_sub(shown_files); let omitted_matches = match_count.saturating_sub(shown_matches); if omitted_files > 0 || omitted_matches > 0 { - out.push_str("\n… "); + out.push_str("\n[…"); out.push_str(&omitted_matches.to_string()); out.push_str(" matches"); if omitted_files > 0 { @@ -190,7 +190,7 @@ fn compact_grep_grouped( out.push_str(&omitted_files.to_string()); out.push_str(" files"); } - out.push_str(" omitted\n"); + out.push_str(" elided…]\n"); } for line in ungrouped { out.push_str(line); @@ -365,14 +365,14 @@ fn compact_find_output_inner(input: &str, paths: &[&str]) -> String { push_wrapped_names(&mut out, names, 4, 24); } if grouped.len() > 16 { - out.push_str("\n… "); + out.push_str("\n[…"); out.push_str(&(grouped.len() - 16).to_string()); - out.push_str(" dirs omitted\n"); + out.push_str(" dirs elided…]\n"); } if skipped_noise > 0 { - out.push_str("… "); + out.push_str("[…"); out.push_str(&skipped_noise.to_string()); - out.push_str(" noisy paths omitted\n"); + out.push_str(" noisy paths elided…]\n"); } out } @@ -407,9 +407,9 @@ fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_n } out.push('\n'); if names.len() > max_names { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(names.len() - max_names).to_string()); - out.push_str(" more\n"); + out.push_str(" names elided…]\n"); } } @@ -453,9 +453,9 @@ fn compact_ls_output(input: &str) -> Option { } let shown = dir_count.min(12) + file_count.min(36); if entries.len() > shown { - out.push_str("… "); + out.push_str("[…"); out.push_str(&(entries.len() - shown).to_string()); - out.push_str(" entries omitted\n"); + out.push_str(" entries elided…]\n"); } out.push('\n'); out.push_str(&file_count.to_string()); @@ -622,9 +622,9 @@ fn summarize_cargo_toml(input: &str) -> Option { out.push('\n'); } if dependencies.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(dependencies.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -705,9 +705,9 @@ fn summarize_package_json(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -752,9 +752,9 @@ fn summarize_go_mod(input: &str) -> Option { out.push('\n'); } if deps.len() > 15 { - out.push_str(" … "); + out.push_str(" […"); out.push_str(&(deps.len() - 15).to_string()); - out.push_str(" more\n"); + out.push_str(" dependencies elided…]\n"); } Some(out) } @@ -1265,7 +1265,7 @@ mod tests { let out = filter(&ctx, &input, 0); assert!(out.text.starts_with("grep: 20 matches in 20 files")); assert!(out.text.contains("17: pub fn run(...) -> Result<()> {")); - assert!(out.text.contains("matches in 8 files omitted")); + assert!(out.text.contains("matches in 8 files elided…]")); } #[test] @@ -1447,7 +1447,7 @@ mod tests { let out = filter(&ctx, &input, 0); assert!(out.text.starts_with("find: 31 paths in 3 dirs")); assert!(out.text.contains("src/module0/ file0.rs file1.rs")); - assert!(out.text.contains("1 noisy paths omitted")); + assert!(out.text.contains("[…1 noisy paths elided…]")); assert!(!out.text.contains("target/debug")); } @@ -1488,7 +1488,7 @@ mod tests { out.text .contains("Filesystem 1K-blocks Used Available Use% Mounted on") ); - assert!(out.text.contains("… 13 lines omitted …")); + assert!(out.text.contains("[…13ln elided…]")); assert!(out.text.contains("/dev/disk35")); } diff --git a/crates/pi-shell/src/minimizer/filters/pkg.rs b/crates/pi-shell/src/minimizer/filters/pkg.rs index 386081175..44f1e0440 100644 --- a/crates/pi-shell/src/minimizer/filters/pkg.rs +++ b/crates/pi-shell/src/minimizer/filters/pkg.rs @@ -251,7 +251,7 @@ fn compact_package_tree_output(input: &str) -> String { out.push_str(line); out.push('\n'); } - let _ = writeln!(out, "… {} package entries omitted …", lines.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", lines.len() - PACKAGE_TREE_HEAD_LINES); out } @@ -292,7 +292,7 @@ fn summarize_package_rows(rows: Vec) -> Option { out.push('\n'); } if rows.len() > PACKAGE_TREE_HEAD_LINES { - let _ = writeln!(out, "… {} package entries omitted …", rows.len() - PACKAGE_TREE_HEAD_LINES); + let _ = writeln!(out, "[…{} package entries elided…]", rows.len() - PACKAGE_TREE_HEAD_LINES); } Some(out) } @@ -793,7 +793,7 @@ mod tests { assert!(out.text.contains("├── dep000@1.0.0")); assert!(out.text.contains("├── dep078@1.0.0")); assert!(!out.text.contains("├── dep089@1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -808,7 +808,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -825,7 +825,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("react 19.0.0")); assert!(out.text.contains("└─ dependent000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] @@ -889,7 +889,7 @@ mod tests { assert!(out.text.contains("pkg000 1.0.0")); assert!(out.text.contains("pkg078 1.0.78")); assert!(!out.text.contains("pkg089 1.0.89")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -905,7 +905,7 @@ mod tests { assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("project v1.0.0")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -920,7 +920,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("requests 2.32.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -951,7 +951,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("pkg000==1.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -966,7 +966,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 91 entries\n")); assert!(out.text.contains("dep000==2.0.0")); - assert!(out.text.contains("… 11 package entries omitted …")); + assert!(out.text.contains("[…11 package entries elided…]")); } #[test] @@ -996,7 +996,7 @@ mod tests { let out = filter(&context, &input, 0); assert!(out.text.starts_with("package tree/list: 92 entries\n")); assert!(out.text.contains("pkg000")); - assert!(out.text.contains("… 12 package entries omitted …")); + assert!(out.text.contains("[…12 package entries elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/python.rs b/crates/pi-shell/src/minimizer/filters/python.rs index 943875a66..c4ce279e8 100644 --- a/crates/pi-shell/src/minimizer/filters/python.rs +++ b/crates/pi-shell/src/minimizer/filters/python.rs @@ -2,7 +2,7 @@ //! //! Ported from rtk-ai/rtk@878af7de99e0ba71da2e8fd996f6b52a1836e06c //! Path: `src/cmds/python/pytest_cmd.rs` -//! License: MIT (compatible with workspace MIT). See `ATTRIBUTION-RTK.md` at +//! License: MIT (compatible with workspace MIT). See `NOTICE` at //! the `pi-shell` crate root. //! //! The pytest state machine (`filter_pytest`, `pytest_success`, @@ -20,9 +20,9 @@ use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; /// sections). Mirrors RTK's `MAX_PYTEST_FAILURES` (== `CAP_WARNINGS` == 10), /// re-derived for the minimizer's streaming, line-based renderer: once this /// many traceback blocks have been emitted, further blocks are suppressed and -/// a single `+N more failures` overflow marker stands in for the remainder. -/// The compact `FAILED …` short-summary one-liners are NOT capped here — they -/// name every failed test cheaply and stay intact. +/// a single `[…N failures elided…]` overflow marker stands in for the +/// remainder. The compact `FAILED …` short-summary one-liners are NOT capped +/// here — they name every failed test cheaply and stay intact. /// /// The `=== ERRORS ===` section (collection / fixture-setup errors) is capped /// by a SEPARATE counter (see `filter_pytest`): pytest renders ERRORS *before* @@ -172,15 +172,15 @@ fn filter_pytest(input: &str, exit_code: i32) -> String { // overflow markers so the compact output reads top-to-bottom. let error_overflow = error_blocks.saturating_sub(MAX_PYTEST_FAILURES); if error_overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&error_overflow.to_string()); - out.push_str(" more errors\n"); + out.push_str(" errors elided…]\n"); } let overflow = failure_blocks.saturating_sub(MAX_PYTEST_FAILURES); if overflow > 0 { - out.push_str("… +"); + out.push_str("[…"); out.push_str(&overflow.to_string()); - out.push_str(" more failures\n"); + out.push_str(" failures elided…]\n"); } if has_content(&out) { @@ -575,7 +575,7 @@ mod tests { assert!(out.contains("test_case_0"), "got: {out}"); assert!(out.contains("test_case_9"), "got: {out}"); // The overflow marker accounts for the remaining 2 verbose blocks. - assert!(out.contains("… +2 more failures"), "got: {out}"); + assert!(out.contains("[…2 failures elided…]"), "got: {out}"); // The traceback bodies for the capped blocks are gone, but the compact // short-summary one-liners for every test survive uncapped. let assert_false_lines = out.matches("E assert False").count(); @@ -664,17 +664,17 @@ mod tests { let assert_lines = out.matches("E assert 0 == 100").count(); assert_eq!(assert_lines, 5, "all 5 assertion bodies must survive: {out}"); // 5 < cap, so there is NO failure overflow stealing real tracebacks. - assert!(!out.contains("more failures"), "no failures should be capped: {out}"); + assert!(!out.contains("failures elided"), "no failures should be capped: {out}"); // Collection errors are still surfaced (under their own cap of 10). assert!(out.contains("ERROR collecting tests/test_imp_0.py"), "got: {out}"); - assert!(!out.contains("more errors"), "9 errors < cap, no error overflow: {out}"); + assert!(!out.contains("errors elided"), "9 errors < cap, no error overflow: {out}"); assert!(out.contains("pytest: 5 failed, 9 errors"), "got: {out}"); } #[test] fn pytest_caps_error_blocks_independently_of_failures() { // 12 collection-error banners (ERRORS section) exceed the cap and yield - // their OWN `+N more errors` marker, while the 2 real failure tracebacks + // their OWN `[…N errors elided…]` marker, while the 2 real failure tracebacks // in the FAILURES section are untouched (their counter is separate). let mut input = String::from( "============================= test session starts \ @@ -706,9 +706,9 @@ mod tests { let out = filter_pytest(&input, 1); // Error overflow marker fires for the 2 capped collection banners… - assert!(out.contains("… +2 more errors"), "got: {out}"); + assert!(out.contains("[…2 errors elided…]"), "got: {out}"); // …but NOT a failure overflow — both real tracebacks render in full. - assert!(!out.contains("more failures"), "failures must be uncapped here: {out}"); + assert!(!out.contains("failures elided"), "failures must be uncapped here: {out}"); assert!(out.contains("test_real_0"), "got: {out}"); assert!(out.contains("test_real_1"), "got: {out}"); let assert_false = out.matches("E assert False").count(); diff --git a/crates/pi-shell/src/minimizer/filters/ruby.rs b/crates/pi-shell/src/minimizer/filters/ruby.rs index 9e5fe8bdd..6f2026e8f 100644 --- a/crates/pi-shell/src/minimizer/filters/ruby.rs +++ b/crates/pi-shell/src/minimizer/filters/ruby.rs @@ -75,7 +75,7 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { let mut out = String::new(); // Each rendered failure block is buffered separately so the total can be - // capped at MAX_RENDERED_FAILURES with a `+N more failures` marker. The + // capped at MAX_RENDERED_FAILURES with a `[…N failures elided…]` marker. The // buffered blocks are rendered into `out` the moment the `Failures:` section // ends — at the summary line or the `Failed examples:` boundary — so real // rspec ordering (`Failures:` details, then summary, then `Failed examples:`) @@ -139,8 +139,8 @@ fn filter_rspec(input: &str, exit_code: i32) -> String { } /// Render the buffered `Failures:` detail blocks into `out`, capped at -/// `MAX_RENDERED_FAILURES` with a `+N more failures` marker. Runs at most once -/// — guarded by `rendered` — so the section is emitted exactly where the +/// `MAX_RENDERED_FAILURES` with a `[…N failures elided…]` marker. Runs at most +/// once — guarded by `rendered` — so the section is emitted exactly where the /// `Failures:` block ends (the summary line or `Failed examples:` boundary), /// preserving real rspec section ordering. fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mut bool) { @@ -157,14 +157,14 @@ fn render_rspec_blocks(out: &mut String, blocks: &mut Vec, rendered: &mu out.push_str(block); } if total > MAX_RENDERED_FAILURES { - push_line(out, &format!("+{} more failures", total - MAX_RENDERED_FAILURES)); + push_line(out, &format!("[…{} failures elided…]", total - MAX_RENDERED_FAILURES)); } blocks.clear(); } /// Cap on rendered failure blocks in non-JSON rspec output. rtk uses the same /// limit (`MAX_RSPEC_FAILURES = 5`) — failure blocks carry full context, so a -/// handful is enough before collapsing to a `+N more failures` marker. +/// handful is enough before collapsing to a `[…N failures elided…]` marker. const MAX_RENDERED_FAILURES: usize = 5; fn flush_rspec_block(blocks: &mut Vec, current: &mut String) { @@ -896,7 +896,7 @@ mod tests { assert!(out.contains("Example number 1 fails")); assert!(out.contains("Example number 5 fails")); assert!(!out.contains("Example number 6 fails")); - assert!(out.contains("+2 more failures")); + assert!(out.contains("[…2 failures elided…]")); assert!(out.contains("7 examples, 7 failures")); } diff --git a/crates/pi-shell/src/minimizer/filters/rust_tools.rs b/crates/pi-shell/src/minimizer/filters/rust_tools.rs index 498fb5184..906ec13fd 100644 --- a/crates/pi-shell/src/minimizer/filters/rust_tools.rs +++ b/crates/pi-shell/src/minimizer/filters/rust_tools.rs @@ -79,7 +79,7 @@ fn condense_rustfmt(input: &str, exit_code: i32) -> String { out.push('\n'); } if total > 3 { - let _ = writeln!(out, " … {} more", total - 3); + let _ = writeln!(out, " […{} files elided…]", total - 3); } out } @@ -125,7 +125,7 @@ mod tests { assert!(out.changed); assert!(out.text.contains("50 files reformatted")); assert!(out.text.contains("src/file_0.rs")); - assert!(out.text.contains("… 47 more")); + assert!(out.text.contains("[…47 files elided…]")); // Diff bodies must be elided. assert!(!out.text.contains("old line")); // Savings ratio ≥ 0.7 @@ -154,7 +154,7 @@ mod tests { let out = filter(&context, &input, 1); assert!(out.changed); // head_tail_lines(input, 80, 40) keeps 120 lines + marker. - assert!(out.text.contains("lines omitted")); + assert!(out.text.contains("ln elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/filters/system.rs b/crates/pi-shell/src/minimizer/filters/system.rs index c2ffb1c3f..f1e4f19a6 100644 --- a/crates/pi-shell/src/minimizer/filters/system.rs +++ b/crates/pi-shell/src/minimizer/filters/system.rs @@ -1,6 +1,6 @@ //! Conservative text filters for system-style commands. -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Write as _}; use super::git; use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; @@ -358,9 +358,7 @@ fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String { for line in lines.iter().take(head) { push_counted_line(&mut out, &line.original, line.count); } - out.push_str("… "); - out.push_str(&(lines.len() - head - tail).to_string()); - out.push_str(" unique lines omitted …\n"); + let _ = writeln!(out, "[…{} unique lines elided…]", lines.len() - head - tail); for line in lines.iter().skip(lines.len() - tail) { push_counted_line(&mut out, &line.original, line.count); } @@ -750,9 +748,7 @@ fn compact_format_output(input: &str) -> String { out.push('\n'); } if files.len() > 50 { - out.push_str("… "); - out.push_str(&(files.len() - 50).to_string()); - out.push_str(" more files\n"); + let _ = writeln!(out, "[…{} files elided…]", files.len() - 50); } } if errors.is_empty() && summary.is_empty() && files.is_empty() { @@ -1073,7 +1069,7 @@ Time 0.42s assert!(out.text.contains("errors:")); assert!(out.text.contains("failed to parse src/bad.py")); assert!(out.text.contains("files:")); - assert!(out.text.contains("more files")); + assert!(out.text.contains("[…50 files elided…]")); } #[test] diff --git a/crates/pi-shell/src/minimizer/plan.rs b/crates/pi-shell/src/minimizer/plan.rs index c7e15dce3..1cab90049 100644 --- a/crates/pi-shell/src/minimizer/plan.rs +++ b/crates/pi-shell/src/minimizer/plan.rs @@ -28,7 +28,7 @@ use brush_parser::{ ParserOptions, SourceInfo, ast::{ AndOr, Command, CommandPrefixOrSuffixItem, CompoundListItem, IoFileRedirectTarget, - IoRedirect, Pipeline, Program, SeparatorOperator, Word, + IoRedirect, Pipeline, Program, SeparatorOperator, SimpleCommand, Word, }, }; @@ -66,21 +66,24 @@ pub enum CommandPlan { /// Parse `command` with `brush-parser` and classify its structure. #[must_use] pub fn analyze(command: &str) -> CommandPlan { - let trimmed = command.trim(); - if trimmed.is_empty() { + if command.trim().is_empty() { return CommandPlan::Unsupported; } + let Some(program) = parse(command) else { + return CommandPlan::Unsupported; + }; + classify(&program) +} +/// Parse `command` with the same `brush-parser` configuration the vendored +/// runtime uses, discarding error detail. Returns `None` on any syntax error +/// or unsupported construct. +fn parse(command: &str) -> Option { let options = ParserOptions::default(); let source_info = SourceInfo::default(); let reader = std::io::Cursor::new(command.as_bytes()); let mut parser = brush_parser::Parser::new(reader, &options, &source_info); - - let Ok(program) = parser.parse_program() else { - return CommandPlan::Unsupported; - }; - - classify(&program) + parser.parse_program().ok() } fn classify(program: &Program) -> CommandPlan { @@ -216,51 +219,115 @@ fn io_redirect_is_safe(io: &IoRedirect) -> bool { } } +/// True when every part of a simple command is safe to reconstruct through +/// `brush`'s `Display` impl: no command/process substitutions and no here-doc +/// in the command word, prefix, or suffix (those re-emit in forms that diverge +/// from the source). The full reconstruction is still re-parse-verified by +/// [`reconstruction_reparses_to_same_shape`] before any segment is executed. +fn simple_command_is_safe(simple: &SimpleCommand) -> bool { + if let Some(prefix) = simple.prefix.as_ref() + && prefix + .0 + .iter() + .any(|item| !command_prefix_or_suffix_item_is_safe(item)) + { + return false; + } + if let Some(suffix) = simple.suffix.as_ref() + && suffix + .0 + .iter() + .any(|item| !command_prefix_or_suffix_item_is_safe(item)) + { + return false; + } + if let Some(word) = simple.word_or_name.as_ref() + && word_has_command_substitution(word) + { + return false; + } + true +} + fn simple_segment(pipeline: &Pipeline) -> Option<(String, String)> { if pipeline.timed.is_some() || pipeline.bang || pipeline.seq.is_empty() { return None; } - // For multi-stage pipes inside a chain segment, identify the segment by its - // first stage's program. The downstream per-segment minimizer::apply will - // detect the pipeline at runtime via plan::CommandPlan::Piped and pass it - // through unchanged — so a piped segment is safely captured but never - // rewritten. This keeps the chain decomposable when even one inner stage - // uses a pipe (e.g. `ls | head -10 && git status`). - let first = pipeline.seq.first()?; - match first { - Command::Simple(simple) => { - if simple.prefix.as_ref().is_some_and(|prefix| { - prefix - .0 - .iter() - .any(|item| !command_prefix_or_suffix_item_is_safe(item)) - }) { - return None; - } - if simple.suffix.as_ref().is_some_and(|suffix| { - suffix - .0 - .iter() - .any(|item| !command_prefix_or_suffix_item_is_safe(item)) - }) { - return None; - } - - let program_word = simple.word_or_name.as_ref()?; - if word_has_command_substitution(program_word) { - return None; - } - let program = program_word.to_string(); - if program.trim().is_empty() { - return None; - } - Some((pipeline.to_string(), program)) - }, - // Compound shell syntax (if / for / while / subshell / { ... }) is - // not something the minimizer should touch. - Command::Compound(..) | Command::Function(_) | Command::ExtendedTest(..) => None, + // Every stage must be a Display-safe simple command. Compound stages + // (`if` / `for` / `while` / subshells / `{ … }`) and unsafe words/redirects + // (here-docs, substitutions) do not round-trip through `Display`. Validating + // only `seq.first()` once let a compound later stage through — e.g. + // `git log … | while read x; do … done` — and the reconstructed segment then + // failed to execute with "syntax error at end of input". + for command in &pipeline.seq { + let Command::Simple(simple) = command else { + return None; + }; + if !simple_command_is_safe(simple) { + return None; + } } + + // Identify the segment by its first stage's program word. Multi-stage pipes + // are captured but never rewritten (runtime detects `CommandPlan::Piped`), + // keeping the chain decomposable when an inner stage pipes (e.g. + // `ls | head -10 && git status`). + let Command::Simple(first) = pipeline.seq.first()? else { + return None; + }; + let program_word = first.word_or_name.as_ref()?; + let program = program_word.to_string(); + if program.trim().is_empty() { + return None; + } + + // The chain runner re-executes this reconstructed string verbatim. brush's + // `Display` is not a guaranteed inverse of its parser, so re-parse the + // reconstruction and require the same pipeline shape before committing to + // segmentation. Any divergence (a lossy compound terminator today, a future + // `Display` change tomorrow) falls back to the unsegmented whole-command + // path instead of failing at execution. + let command = pipeline.to_string(); + if !reconstruction_reparses_to_same_shape(&command, pipeline.seq.len()) { + return None; + } + Some((command, program)) +} + +/// Confirm a reconstructed segment string re-parses to the *same shape* it was +/// built from: exactly one sequential top-level pipeline with `expected_stages` +/// commands, no `&&`/`||`/`;`/`&` continuation, and no `!`/`time` modifier. +/// +/// This is a syntax/shape guard, not a proof of full semantic equivalence. It +/// guarantees the chain runner never executes a reconstruction that fails to +/// parse or that `Display` reshaped into different top-level structure. brush's +/// `Display` is not a guaranteed inverse of its parser — compound terminators +/// (`while … done`) and quoted here-doc close tags re-emit in forms that fail +/// to re-parse — so a divergent segment drops back to the unsegmented +/// whole-command path instead of blowing up at execution with +/// "pi-natives:command: syntax error". The per-stage `simple_command_is_safe` +/// whitelist already excludes constructs whose `Display` is value-lossy +/// (substitutions, here-docs); words carry raw source text and round-trip +/// verbatim. +fn reconstruction_reparses_to_same_shape(reconstructed: &str, expected_stages: usize) -> bool { + let Some(program) = parse(reconstructed) else { + return false; + }; + let mut items = program.complete_commands.iter().flat_map(|cl| cl.0.iter()); + let Some(CompoundListItem(and_or, separator)) = items.next() else { + return false; + }; + // A second top-level item, a trailing `&` (Async), or an `&&`/`||` + // continuation all mean `Display` reshaped the command. + if items.next().is_some() + || !and_or.additional.is_empty() + || matches!(separator, SeparatorOperator::Async) + { + return false; + } + let pipeline = &and_or.first; + !pipeline.bang && pipeline.timed.is_none() && pipeline.seq.len() == expected_stages } fn classify_pipeline(pipeline: &Pipeline) -> Option { @@ -453,4 +520,50 @@ mod tests { assert_eq!(analyze(""), CommandPlan::Unsupported); assert_eq!(analyze(" "), CommandPlan::Unsupported); } + + #[test] + fn compound_later_pipeline_stage_is_not_segmented() { + // Regression: a pipeline whose *later* stage is a compound command + // (`while`/`for`/`if`/subshell) must never be segmented. The chain runner + // re-executes `pipeline.to_string()`, and brush's `Display` drops the + // compound terminator, so the reconstruction failed to re-parse and blew + // up at execution with "syntax error at end of input". Validating only + // `seq.first()` (a simple `git`/`seq`) let it slip through. + assert_not_chain( + "echo start && git log --oneline | while read h; do echo \"$h\"; done | head -3", + ); + assert_not_chain("seq 3 | while read n; do echo \"$n\"; done && echo done"); + assert_not_chain("printf x && ls | for f in a b; do echo $f; done"); + // The reported shape classifies as Compound, so it runs whole and + // unsegmented via the single path (no reconstruction, no minimization). + assert_eq!( + analyze("echo a && seq 2 | while read n; do echo $n; done"), + CommandPlan::Compound, + ); + } + + #[test] + fn chain_segments_reparse_cleanly() { + // Every command string the chain runner will execute must itself re-parse + // to a single pipeline — the contract enforced by + // `reconstruction_reparses_to_same_shape`. A segment whose `Display` + // reconstruction diverges is dropped rather than emitted. + for command in [ + "git diff --stat && git diff --name-only", + "ls -lh *.txt | head -5 && git status --short", + "grep -c foo bar 2>/dev/null && echo done", + "FOO=1 git status ; bun test", + ] { + let Some(segments) = chain_of(analyze(command)) else { + panic!("{command:?} should classify as Chain"); + }; + for segment in &segments { + assert!( + parse(&segment.command).is_some(), + "segment {:?} from {command:?} did not re-parse", + segment.command, + ); + } + } + } } diff --git a/crates/pi-shell/src/minimizer/primitives.rs b/crates/pi-shell/src/minimizer/primitives.rs index 6e494b4ed..4dfa2f074 100644 --- a/crates/pi-shell/src/minimizer/primitives.rs +++ b/crates/pi-shell/src/minimizer/primitives.rs @@ -93,9 +93,9 @@ pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(lines.len() - tail) { out.push_str(line); out.push('\n'); @@ -292,9 +292,9 @@ pub fn head_lines_only(input: &str, head: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); out } @@ -307,9 +307,9 @@ pub fn tail_lines_only(input: &str, tail: usize) -> String { } let omitted = lines.len() - tail; let mut out = String::new(); - out.push_str("… "); + out.push_str("[…"); out.push_str(&omitted.to_string()); - out.push_str(" lines omitted …\n"); + out.push_str("ln elided…]\n"); for line in lines.iter().skip(omitted) { out.push_str(line); out.push('\n'); @@ -330,9 +330,9 @@ pub fn max_lines(input: &str, max: usize) -> String { out.push_str(line); out.push('\n'); } - out.push_str("… "); + out.push_str("[…"); out.push_str(&dropped.to_string()); - out.push_str(" lines truncated …\n"); + out.push_str("ln elided…]\n"); out } @@ -380,7 +380,7 @@ mod tests { #[test] fn head_tail_marks_omitted_lines() { let out = head_tail_lines("1\n2\n3\n4\n5\n", 2, 1); - assert_eq!(out, "1\n2\n… 2 lines omitted …\n5\n"); + assert_eq!(out, "1\n2\n[…2ln elided…]\n5\n"); } #[test] @@ -397,7 +397,7 @@ mod tests { .collect::>() .join("\n"); let out = head_tail_cap(&input, CapClass::List); - assert!(out.contains("lines omitted")); + assert!(out.contains("ln elided…]")); assert!(out.lines().count() <= CapClass::List.lines() + 1); } diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 44d9e3bad..9ac8a7ff8 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -2237,6 +2237,67 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }] assert!(!output.contains("unterminated")); } + /// Regression: a `&&` / `;` chain whose later pipeline stage is a compound + /// command (`while … done`) must execute instead of failing with + /// "pi-natives:command: syntax error at end of input". The segmented chain + /// runner rebuilt each segment via the brush AST `Display` impl, but only + /// validated the *first* pipeline stage — so a compound later stage was + /// reconstructed without its terminator and re-run as invalid shell. Such a + /// command now bails out of segmentation and runs whole via the single path. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn compound_stage_in_chain_runs_via_single_path() { + let root = unique_temp_dir("compound-chain"); + let minimizer = printf_minimizer(&root.join("minimizer.toml"), None); + let (result, output) = run_command_capture( + "printf 'start\\n' && seq 5 | while read n; do echo \"n=$n\"; done | head -2", + None, + Some(minimizer), + CancelToken::default(), + ) + .await; + let _ = std::fs::remove_dir_all(&root); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(output, "start\nn=1\nn=2\n"); + assert!(!output.contains("syntax error")); + // Ran whole (unsegmented), so nothing was minimized. + assert!(result.minimized.is_none()); + } + + /// A segment that carries a file redirect is still segmented, and the brush + /// `Display` reconstruction the runner executes must round-trip through + /// brush's own parser **without losing the redirect**. `echo hidden + /// >/dev/null` suppresses its own stdout: if the reconstruction dropped the + /// redirect, `hidden` would leak into the captured output. Proves the + /// reconstruction path is semantically sound for the redirect-bearing + /// shapes the per-stage whitelist accepts (not just syntactically + /// parseable). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn segmented_chain_with_redirect_executes_correctly() { + let root = unique_temp_dir("redirect-chain"); + let minimizer = printf_minimizer(&root.join("minimizer.toml"), None); + let (result, output) = run_command_capture( + "echo hidden >/dev/null && printf 'hello\\n'", + None, + Some(minimizer), + CancelToken::default(), + ) + .await; + let _ = std::fs::remove_dir_all(&root); + assert_eq!(result.exit_code, Some(0)); + // The redirect survived reconstruction: segment 1's stdout went to + // /dev/null, so only segment 2's output is captured. + assert!(!output.contains("hidden"), "redirect must suppress segment-1 stdout"); + assert_eq!(output, "hello\n"); + let minimized = result + .minimized + .expect("redirect chain should be minimized"); + assert_eq!(minimized.original_text, "hello\n"); + assert_eq!(minimized.text, "HI\n"); + assert!(!output.contains("syntax error")); + } + #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn segmented_chain_exceeding_aggregate_capture_cap_stays_raw() { diff --git a/docs/advisor-watchdog.md b/docs/advisor-watchdog.md index 0ed4768bf..d64d10eab 100644 --- a/docs/advisor-watchdog.md +++ b/docs/advisor-watchdog.md @@ -9,6 +9,7 @@ The advisor is not a second executor. It cannot edit files, run commands, approv - [`src/advisor/runtime.ts`](../packages/coding-agent/src/advisor/runtime.ts) - [`src/advisor/advise-tool.ts`](../packages/coding-agent/src/advisor/advise-tool.ts) - [`src/advisor/watchdog.ts`](../packages/coding-agent/src/advisor/watchdog.ts) +- [`src/advisor/transcript-recorder.ts`](../packages/coding-agent/src/advisor/transcript-recorder.ts) - [`src/prompts/advisor/system.md`](../packages/coding-agent/src/prompts/advisor/system.md) - [`src/prompts/advisor/advise-tool.md`](../packages/coding-agent/src/prompts/advisor/advise-tool.md) - [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) @@ -203,4 +204,22 @@ The advisor has its own append-only context. Before each advisor prompt, `AgentS 2. if promotion cannot fit enough context, compact the advisor's own message history 3. if compaction has no candidates or still cannot fit, re-prime from the current bounded primary transcript -The advisor transcript is in-memory for the session. It is retained while the session runs so `/advisor dump` can inspect it, but advisor state is not a replacement for the primary persisted transcript. +The advisor's live context is in-memory and append-only; it is retained while the session runs so `/advisor dump` can inspect it, and is independently promoted/compacted/re-primed (above). It is not a replacement for the primary persisted transcript. + +## Transcript persistence and observability + +The advisor is a passive reviewer with its own model usage, so — like a task subagent — every finalized advisor turn is appended to a JSONL inside the owning session's artifacts dir: + +- main session: `/__advisor.jsonl` +- subagent advisor (`advisor.subagents: true`): `//__advisor.jsonl` + +The path is derived from the session file (not the artifacts dir, which subagents share with their parent), so each advisor writes a distinct file. The reserved `__advisor` stem cannot collide with a task subagent's `.jsonl` (task id allocation reserves it). + +Why a file: + +- **Usage attribution.** `omp stats` scans each session folder recursively, so advisor assistant turns (with their usage/cost) are attributed to the same project/session like any other subagent. Advisor "session update" prompts are persisted as `synthetic`, agent-attributed user messages so they never inflate user-message metrics. +- **Observability.** The Agent Hub discovers `__advisor.jsonl` on open and shows it as a read-only `advisor`-kind transcript under its owning session. + +The file follows session switches: on `/new`, resume/switch, and branch the recorder reopens at the new session's path on the next advisor turn; before a `/drop` deletes the old artifacts dir the recorder feed is detached and drained so a queued write cannot recreate the deleted file. The on-disk log is append-only and independent of the in-memory context — re-primes and compaction never truncate it. + +The advisor is never a peer. The `advisor`-kind registry ref is excluded from every agent-facing surface — the `irc` peer roster and broadcast targets, the subagent peer prompt, and the `history://` index/lookup/completions — and cannot be messaged (`irc send` and collab chat refuse it) or revived/killed from the Agent Hub or collab. It is observability only. diff --git a/docs/collab.md b/docs/collab.md index e86a709a3..ec3a50a92 100644 --- a/docs/collab.md +++ b/docs/collab.md @@ -20,6 +20,8 @@ Collab session started! The browser line is click-to-join (an OSC 8 hyperlink to the full `https://` deep link): the relay serves the web guest client at `/`, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works: +Running `/collab` or `/collab view` starts or displays the active hosting session, rendering both the terminal/browser join links and their corresponding QR codes. + ``` /join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU… ``` @@ -30,9 +32,9 @@ The guest's previous session is restored on `/leave` (or when the host stops). | Command | Effect | |---|---| -| `/collab` | Start sharing (or re-print the link when already hosting) | +| `/collab` | Start sharing full-control (or re-print the link/QR when already hosting) | | `/collab ` | Start sharing through a specific relay (`relay.example.com`, `ws://localhost:7475`) | -| `/collab view` | Print a read-only (view-only) link (starts sharing first if needed) | +| `/collab view` | Start sharing read-only (or re-print the link/QR when already hosting) | | `/collab status` | Show link + participants | | `/collab stop` | Stop sharing | | `/join ` | Join a shared session as a guest | @@ -40,19 +42,29 @@ The guest's previous session is restored on `/leave` (or when the host stops). ## Link format +Accepted by `/join ` and `omp join ""`: + ``` -https://host[:port]/# → browser deep link (printed by /collab; /join accepts it too) -. → default relay (my.omp.sh) -host[:port]/r/. → custom relay, wss:// inferred -ws://localhost:7475/r/. → plain ws, allowed for localhost only +. → default relay (wss://my.omp.sh) +# → legacy bare form +host[:port]/r/. → custom relay, wss:// inferred +host[:port]/r/# → legacy direct relay form +https://host[:port]/r/. → direct relay URL, normalized to wss:// +wss://host[:port]/r/. → direct websocket relay URL +ws://localhost:7475/r/. → direct plain ws, localhost only +https://host[:port]/# → browser deep link when web UI and relay share a host +https://web-host[:port][/]/# → browser UI wrapper with relay link in the fragment +https://web.example/collab/#relay.example.com/r/. → web UI and relay on different hosts ``` -The trailing `.` part is the room secret, base64url-encoded, in one of two strengths: +`` / `` are parsed recursively as any accepted link above. For `http(s)` browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets `https://web.example/collab/#relay.example.com/r/.` open the web UI at `web.example` while joining `wss://relay.example.com/r/`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/#` still means relay `relay.example.com`. + +The trailing `.` or `#` part is the room secret, base64url-encoded, in one of two strengths: - **Full link** — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control. - **View-only link** — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only. -The room secret is dot-joined rather than `#`-joined: RFC 3986 forbids a raw `#` inside a URL fragment, so strict URL stacks (macOS Foundation behind terminal click-to-open) percent-encode a second `#` to `%23` and break the link. Parsers leniently accept the legacy `#` form and the mangled `%23` form. In the browser deep link, everything after the `#` — room id and key — is a URL fragment: it never appears in any HTTP request, and neither secret is ever sent to the relay. +The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw `#` inside a URL fragment; parsers still accept legacy `#` forms and `%23`-mangled legacy deep links. ## End-to-end encryption @@ -85,11 +97,14 @@ Known v1 limit for guests: a turn already streaming when you join becomes visibl `packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at `/`, which is what makes the `/collab` deep link click-to-join: `https:///#` loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run `bun run dev` in the package for a local instance, `bun run mock-host` for an offline scripted host to develop against, and `bun run build` to emit a static `dist/` deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment. +Set `collab.webUrl` when the browser UI is hosted separately from the websocket relay. When empty, `/collab` derives `http(s)://host[:port]` from `collab.relayUrl`; explicit web UI URLs must use `https://` except for `http://localhost` development origins. The generated browser URL still carries the relay-specific collab link in the fragment. + ## Settings | Setting | Default | Meaning | |---|---|---| | `collab.relayUrl` | `wss://my.omp.sh` | Relay used by `/collab` when no relay is passed inline | +| `collab.webUrl` | empty | Browser UI URL for `/collab` links; empty derives from relay; explicit `http://` is allowed only for localhost | | `collab.displayName` | OS username | Name shown to other participants | | `share.serverUrl` | `https://my.omp.sh/s` | Share viewer/upload base used by `/share` (links are `/#`) | | `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload | diff --git a/docs/compaction.md b/docs/compaction.md index 84a0cb0c5..84adb67f6 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -134,8 +134,8 @@ The automatic paths are intentionally different: - The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the **model id** when the model line was measured: Claude reads X.org `8x13` glyphs on an 11px advance (extra letter-spacing, black ink — `11on16-bw`; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic's 4,784 visual-token cap, older lines stay at 1568px), Gemini reads `8x13` glyphs on a 22px pitch (extra leading, black ink — `8on22-bw` at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same `8on22-bw` shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read `8x13` glyphs on a 16px pitch (`8on16-bw` at 1568px — kimi's processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown → `11on16-bw`, Google → `8on22-bw`, OpenAI-compatible → `8on22-bw`); billing (per-family patch/budget formulas, OpenAI's `detail: "original"` hint) always follows the API carrying the request, computed for the resolved frame size. The `snapcompact.shape` setting (default `auto`) forces one of the research-eval variants instead: square grids (`8x8r`/`8x8u`/`6x6u`/`5x8` × sentence-hue/black ink) or the per-model eval winners (`6x12-dim`, `8x13-bw`, `8on16-bw`, `8on22-bw`, `11on16-bw`, and the two-column word-wrapped `doc-8on16-bw`/`-sent`/`-sent-dim`, where `dim` prints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider's image billing. The same setting governs inline system-prompt/tool-result imaging (`snapcompact.systemPrompt`, `snapcompact.toolResults`). - Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`). -- Frames persist under `CompactionEntry.preserveData.snapcompact` and are re-attached to the `compactionSummary` message as image blocks on every context rebuild; the entry's `summary` is a deterministic reading guide (grid geometry, role tags, truncation notes) plus the usual file-operation lists. -- Later compactions carry earlier frames forward. The frame budget is provider-aware (`providerFrameBudget`): the per-provider image cap clamped to 8 (`MAX_FRAMES`) — OpenRouter hard-caps requests at 8 images and silently drops the excess, unknown providers get a safe floor of 5. Beyond the budget the archive fades from the middle out: the earliest frame (session head — the original request, or the filmed summary of older history) is pinned, and the oldest *unpinned* frames are evicted. Pages of the *current* compaction that no longer fit are never rendered or dropped — the newest unframed slice survives verbatim as a text tail on the summary (`Archive.textTail`, capped at two frame capacities with middle elision) and is folded back into frames by the next compaction. If the previous compaction was text-based, its summary is printed at the head of the frame archive as `[Summary of earlier history]`. +- The snapcompact archive persists under `CompactionEntry.preserveData.snapcompact` as bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry's `summary` is just the short resume lead-in plus the usual file-operation list. +- Later compactions re-render from that bounded source text (`Archive.text`), not by carrying old PNGs forward blindly. `maxFrames` now defaults to `MAX_FRAMES_DEFAULT` (80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text. - No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary). - Rationale: the shape table comes from the snapcompact 200k-token evals in `packages/snapcompact`, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models. @@ -263,7 +263,7 @@ Cumulative behavior: - In split turns, includes turn-prefix file ops too. - `details.readFiles` excludes files also modified; `details.modifiedFiles` carries the rest (persisted shape is unchanged). -Summary text gets one `` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `… (N more files omitted)` line. +The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line. LLM-summary strategies append it as a `` tag (via `upsertFileOperations`); snapcompact renders it inside its summary template as a `FILES` section instead. ```xml diff --git a/docs/resolve-tool-runtime.md b/docs/resolve-tool-runtime.md index 298dc9953..d7a692863 100644 --- a/docs/resolve-tool-runtime.md +++ b/docs/resolve-tool-runtime.md @@ -1,6 +1,6 @@ # Resolve tool runtime internals -This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the tool-choice queue and `pushPendingAction`. +This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the pending-invoker registry and `pushPendingAction`. (Pending previews live in a separate non-forcing registry inside `ToolChoiceQueue`; only genuine hard forces use the consuming directive queue.) ## Scope and key files @@ -24,20 +24,32 @@ If no pending action exists, `resolve(action="apply")` fails with: `resolve(action="discard")` with no pending action succeeds instead, returning `Nothing to discard; no pending action remains.` — the desired end-state (no staged change) already holds. -## Pending actions use the tool-choice queue +## Pending previews use a non-forcing soft tool requirement -Preview producers call `queueResolveHandler(...)`, which pushes a one-shot forced `resolve` directive onto the session tool-choice queue and adds a `resolve-reminder` steering message. +Preview producers call `queueResolveHandler(...)`, which registers a non-forcing +pending invoker on the session (a stack keyed by a unique +`pending-action::` id — never clobbered by label). It does NOT force +`tool_choice` and does NOT inject a steering reminder. + +While a preview is pending, the session's `getToolChoice` callback +(`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) +carrying the resolve reminder, as a non-consuming peek. The agent runtime owns the +lifecycle: it injects the reminder once, runs with `tool_choice` unchanged, and +escalates to a one-turn forced `resolve` choice ONLY if the model fails to call +`resolve` that turn (skipping any detour tool batch first). A model that resolves +on the reminder pays no message-cache invalidation — the previous design forced +`tool_choice` on every preview, busting the provider message cache twice per cycle. Runtime behavior: -- the queued handler owns the pending `apply`/`reject` callbacks, -- `resolve` looks up the current queue invoker with `session.peekQueueInvoker()`, -- if the model rejects the forced tool choice, the queue directive is requeued, -- `resolve` does not maintain a separate pending-action stack. +- the pending invoker owns the `apply`/`reject` callbacks, +- `resolve` dispatches via `peekQueueInvoker() ?? peekPendingInvoker() ?? peekStandingResolveHandler()`, +- a genuine hard forced tool choice (queued via `nextToolChoice`) preempts the soft requirement, +- if an apply callback throws, the helper re-registers the same pending invoker (same id) so the preview can still be discarded or retried. -`resolve` also checks a standing resolve handler after the queue invoker; this is used by long-lived approval flows that are not ordinary preview tool calls. +`resolve` also checks a standing resolve handler after the invokers; this is used by long-lived approval flows that are not ordinary preview tool calls. -Multiple pending previews therefore follow the active tool-choice queue ordering, not an independent pending-action store. If an apply callback throws, the queued helper re-pushes the same resolve directive and reminder so the preview can still be discarded or retried. +Multiple pending previews stack as unique-keyed invokers and resolve independently (head-first), not through forced tool-choice ordering. ## Built-in producer example (`ast_edit`) @@ -130,4 +142,4 @@ When `queueResolveHandler(...)` registers a preview, the agent runtime forces a - Keep `label` concise and specific; it is shown in resolve renderer output. - Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior. - Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices. -- If your tool can stage multiple previews, remember they are mediated by the tool-choice queue rather than a separate pending-action stack. +- If your tool can stage multiple previews, remember they stack as unique-keyed pending invokers (resolved head-first), not a forced tool-choice sequence and not a separate `pushPendingAction` stack. diff --git a/docs/theme.md b/docs/theme.md index 18fb4aebb..d26ba8039 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -95,6 +95,16 @@ Runtime precedence: Invalid override keys are ignored and logged (`logger.debug`). +#### Box-drawing borders + +All outlined chrome — tool-result frames, overlays, code fences, the editor, the welcome banner — draws with the `boxRound.*` tokens: rounded corners (`╭╮╰╯`) plus tee/cross junctions (`├┤┬┴┼`, which have no rounded Unicode form, so they are sourced from the `boxSharp.*` tokens). Markdown tables are the sole exception and keep the fully sharp `boxSharp.*` set (`┌┐└┘`). + +Override behavior follows from that split: + +- `boxRound.{topLeft,topRight,bottomLeft,bottomRight,horizontal,vertical}` restyle every border's corners and edges. +- `boxSharp.{cross,teeDown,teeUp,teeRight,teeLeft}` restyle dividers/junctions everywhere (rounded frames and tables alike). +- `boxSharp.{topLeft,topRight,bottomLeft,bottomRight}` now affect markdown table corners only. + ## Built-in vs custom theme sources Theme lookup order (`loadThemeJson`): diff --git a/docs/tools/ast-edit.md b/docs/tools/ast-edit.md index d2b417816..c027aeaeb 100644 --- a/docs/tools/ast-edit.md +++ b/docs/tools/ast-edit.md @@ -61,7 +61,7 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - compiles every rewrite pattern for that language, - parses each file, skips files with syntax-error trees, collects `replace_by(...)` edits for every match, enforces replacement and file caps, and returns textual before/after slices plus source ranges. 7. The TS wrapper deduplicates and caps parse errors, groups changes by file, and renders preview diff lines. -8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a forced `resolve` action and injects a `resolve-reminder` steering message. +8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a non-forcing pending `resolve` invoker. While it is pending the session surfaces a `SoftToolRequirement` carrying the resolve reminder; the agent runtime injects the reminder and forces `resolve` only if the model declines that turn (no per-preview `tool_choice` cache bust). 9. On `resolve(action: "apply")`, the queued callback reruns the same rewrite set with `dryRun: false`, recomputes counts, and returns an error result if the live result no longer matches the preview (`stalePreview`). The current implementation compares replacement totals and per-file counts after the rerun; if the new run has already written different counts, the result is marked error. 10. On a non-stale apply, the callback returns `Applied N replacements in M files.` (in hashline mode followed by fresh `[path#tag]` snapshot headers re-recorded from the post-apply content); on discard, `resolve` returns a discard message without mutating files. @@ -79,8 +79,8 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md# - Preview reads files and scans directories. - Apply rewrites files in place with `std::fs::write(...)`, but only when the computed output differs from the original source. - Session state (transcript, memory, jobs, checkpoints, registries) - - Queues a one-shot forced `resolve` tool choice through `queueResolveHandler(...)`. - - Adds a `resolve-reminder` steering message. + - Registers a non-forcing pending `resolve` invoker through `queueResolveHandler(...)`. + - Surfaces a `SoftToolRequirement` (with the resolve reminder) while pending; the agent runtime forces `resolve` only on non-compliance — no steering message and no per-preview forced tool choice. - User-visible prompts / interactive UI - Direct `ast_edit` results are previews. - Follow-up apply/discard is exposed through the hidden `resolve` tool. diff --git a/docs/tools/find.md b/docs/tools/find.md index 47a40f814..73d475886 100644 --- a/docs/tools/find.md +++ b/docs/tools/find.md @@ -22,7 +22,6 @@ | `hidden` | `boolean` | No | Whether hidden files are included. Defaults to `true` (`hidden ?? true`). | | `gitignore` | `boolean` | No | Whether `.gitignore` is respected during local native globbing. Defaults to `true`; set `false` to include gitignored files. | | `limit` | `number` | No | Max returned paths. Defaults to `200`; finite positive inputs are floored then clamped to `1..200`. | -| `timeout` | `number` | No | Timeout in seconds. Defaults to `5`; clamped to `0.5..60`. On timeout, returns partial matches collected so far with a timeout notice and `truncated: true`. | ## Outputs The tool returns a single text block plus structured `details`. @@ -51,7 +50,7 @@ The tool returns a single text block plus structured `details`. - glob in the first segment => search from `.` and, unless the pattern already starts with `**/`, prefix it with `**/`. - glob later in the path => split at the first glob-bearing segment. 6. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`. -7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`. +7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. An internal timeout of `5` seconds (`5000` ms) is built via `AbortSignal.timeout(...)`. 8. Execution then branches: - **Custom operations branch**: if `FindToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`. - **Built-in local branch**: the tool stats each target's `searchPath`. Exact-file inputs return immediately. Directory inputs call `natives.glob()` with `hidden`, `maxResults: effectiveLimit`, `sortByMtime: true`, `gitignore: useGitignore`, `recursive: false` (recursion comes from the `**/` prefix `parseFindPattern()` adds), and the combined abort signal; multi-target calls run their globs concurrently. @@ -79,12 +78,12 @@ The tool returns a single text block plus structured `details`. - Emits structured progress updates when `onUpdate` is provided. - Adds truncation / limit metadata to the tool result. - Background work / cancellation - - Local globbing is cancellable through the caller abort signal plus the configured internal timeout. + - Local globbing is cancellable through the caller abort signal plus the internal timeout. ## Limits & Caps - Default result limit: `200` (`DEFAULT_LIMIT` in `packages/coding-agent/src/tools/find.ts`). - Maximum result limit: `200` (`MAX_LIMIT`); larger inputs are clamped. -- Local glob timeout: default `5000` ms, clamped to `500..60_000` ms. +- Local glob timeout: fixed at `5000` ms. - Output byte cap: `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`). - Default generic line cap in `truncateHead()` is `3000`, but `find` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so byte size — not line count — is the practical output truncation cap. - Streaming update throttle: `200` ms between `onUpdate` emissions. @@ -97,7 +96,7 @@ The tool returns a single text block plus structured `details`. - `Searching from root directory '/' is not allowed` - `Limit must be a positive number` - `Path is not a directory: ...` - - timeout result text is `find timed out after s; returning partial matches — increase timeout or narrow pattern` and is returned as a successful, truncated partial result rather than an error. + - timeout result text is `find timed out after s; returning partial matches — narrow the pattern instead of retrying blindly` and is returned as a successful, truncated partial result rather than an error. - If the caller aborts, the local branch converts `AbortError` into `ToolAbortError`. - Non-`ENOENT` stat failures and other unexpected errors are rethrown. - Empty matches are not errors; they return the no-files text result. diff --git a/docs/tools/read.md b/docs/tools/read.md index f73bd34bd..4199bf7b0 100644 --- a/docs/tools/read.md +++ b/docs/tools/read.md @@ -7,7 +7,7 @@ - Model-facing prompt: `packages/coding-agent/src/prompts/tools/read.md` - Key collaborators: - `packages/coding-agent/src/tools/path-utils.ts` — split `path` from trailing selectors; normalize local paths. - - `packages/coding-agent/src/tools/archive-reader.ts` — detect `archive.ext:inner/path`, index archives, list/read entries. + - `packages/coding-agent/src/utils/zip.ts` — the unified ZIP/tar wrapper: detect `archive.ext:inner/path`, index archives, list/read entries. - `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite targets, parse selectors, render tables. - `packages/coding-agent/src/tools/fetch.ts` — URL parsing, fetch/render pipeline, URL cache/artifacts. - `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`. @@ -97,7 +97,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts ### Local text files - No selector: if summarization is enabled and the file is small enough, `#trySummarize()` calls `summarizeCode()`. - Guards: file size `<= 2 MiB` (`MAX_SUMMARY_BYTES`), line count `<= 20_000` (`MAX_SUMMARY_LINES`). - - Summary output keeps selected declarations and replaces elided spans with `...` or merged brace-pair lines containing `..`. When at least one span is elided, the text content ends with a footer like `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. + - Summary output keeps selected declarations and replaces elided spans with `…` or merged brace-pair lines containing `{ … }`. When at least one span is elided, the text content ends with a footer like `[…NNln elided; re-read needed ranges, e.g. :5-16,40-80]` using concrete ranges from the actual elisions. - When an elided block sits between matching brace lines, `#renderSummary()` may merge them into one anchored line rather than emitting separate opener/closer lines. - Explicit selector or summarization miss: streamed text read. - Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`. @@ -123,7 +123,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts - Syntax: `archive.ext`, `archive.ext:path/inside`, `archive.ext:path/inside:50-60`. - `openArchive()` branches by format: - tar/tgz reads the whole archive into memory (capped at `MAX_TAR_ARCHIVE_BYTES = 256 MiB`) and indexes it with `new Bun.Archive(bytes)` - - zip is indexed via ranged central-directory reads (`readZipEntries()`); entries are inflated on demand with `fflate.inflateSync()`, with declared member sizes capped at `MAX_ARCHIVE_MEMBER_BYTES = 64 MiB` + - zip is indexed via ranged central-directory reads (`readZipEntries()`); members are inflated on demand with raw DEFLATE (`node:zlib`), and the read tool caps individual extraction at `MAX_ARCHIVE_MEMBER_BYTES = 64 MiB` in `ArchiveReader.readFile()` - Archive paths normalize `/`, drop `.` segments, and reject `..`. - Directory reads list immediate children; files show `name` plus ` (size)` when size > 0. - Directory listing default limit is `500` entries in `#readArchiveDirectory()`. @@ -242,7 +242,7 @@ Notes: ... - URL mode performs HTTP fetches, binary refetches, and alternate-endpoint probes. - Subprocesses / native bindings - Uses Bun SQLite for `.db`/`.sqlite*`. - - Uses `Bun.Archive` for tar/tgz and `fflate` for zip. + - Uses `Bun.Archive` for tar/tgz; ZIP is framed in `packages/coding-agent/src/utils/zip.ts` over the `node:zlib` DEFLATE codec. - URL HTML rendering can delegate into site handlers and HTML-to-text backends from `packages/coding-agent/src/tools/fetch.ts`. - Session state - Records whole-file snapshots of local text reads into `session.fileSnapshotStore` for later stale-anchor recovery. diff --git a/docs/tools/resolve.md b/docs/tools/resolve.md index 6f733ae68..1027fa43a 100644 --- a/docs/tools/resolve.md +++ b/docs/tools/resolve.md @@ -34,9 +34,8 @@ ## Flow 1. Preview-producing code can call `queueResolveHandler(...)` with a label, source tool name, `apply(reason, extra?)` callback, and optional `reject(reason, extra?)` callback. 2. Modes can also register a standing resolve handler through `session.setStandingResolveHandler(...)`; `resolve.execute()` consults it only when no queued invoker is active. -3. `queueResolveHandler(...)` asks the session for a forced `resolve` tool choice and pushes it into the tool-choice queue with `pushOnce(...)`. -4. The queued entry is marked `now: true`; if the model rejects that forced tool choice, `onRejected` returns `requeue`, so the reminder comes back. -5. `queueResolveHandler(...)` also injects a `resolve-reminder` steering message: +3. `queueResolveHandler(...)` registers a non-forcing pending invoker on the session's tool-choice queue under a unique `pending-action::` id. It does NOT force a tool choice and does NOT steer a reminder. +4. While a preview is pending, the session's `getToolChoice` (`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) carrying the resolve reminder — a non-consuming peek. The agent runtime injects the reminder once and forces `tool_choice: resolve` for one turn only if the model declines (see `docs/resolve-tool-runtime.md`). The reminder text is: ```text @@ -44,27 +43,27 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ``` -6. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and fetches `session.peekQueueInvoker?.() ?? session.peekStandingResolveHandler?.()`. -7. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. -8. Otherwise it invokes the current handler with the full params object. -9. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. -10. For `apply`, it calls the producer's `apply(reason, extra)` callback. -11. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The queued preview integration uses this to re-push the resolve directive and steering reminder so the action remains pending. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. -12. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. -13. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. +5. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and dispatches via `session.peekQueueInvoker?.() ?? session.peekPendingInvoker?.() ?? session.peekStandingResolveHandler?.()`. +6. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds. +7. Otherwise it invokes the current handler with the full params object. +8. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`. +9. For `apply`, it calls the producer's `apply(reason, extra)` callback. +10. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The pending-preview integration uses this to re-register the same pending invoker (same id) so the action remains pending for discard or retry. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: ")`. +11. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message. +12. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool. ## Modes / Variants - `apply`: runs the pending action's `apply(reason, extra?)` callback and returns its content. - `discard` with reject callback: runs `reject(reason, extra?)` and returns that callback's content when non-`undefined`. - `discard` without reject callback, or with a reject callback returning `undefined`: returns the built-in `Discarded: ...` text payload. - `discard` with no pending action at all: returns `Nothing to discard; no pending action remains.` as a success result. -- Queued handler: one in-flight tool-choice queue invoker, used by preview producers such as `ast_edit`. +- Pending invoker: a non-forcing preview invoker in the pending-invoker registry (separate from the consuming directive queue), used by preview producers such as `ast_edit`. - Standing handler: long-lived mode-owned handler, used as a fallback when no queue invoker is active. ## Side Effects - Session state - - Consumes or invokes the current pending action through the session tool-choice queue or standing handler; `resolve` does not maintain its own stack. - - Adds a `resolve-reminder` steering message when a queued preview is registered. + - Consumes or invokes the current pending action through the pending invoker, tool-choice queue, or standing handler; `resolve` does not maintain its own stack. + - Does not steer a reminder or force a tool choice for previews — the reminder rides a non-forcing `SoftToolRequirement` and the agent runtime forces `resolve` only on non-compliance. - On queued apply failure, requeues the same pending action before rethrowing so the model can discard or retry instead of losing the pending preview. - User-visible prompts / interactive UI - The visible effect depends on the preview-producing tool and the resolve renderer. @@ -74,8 +73,8 @@ This is a preview. Call the `resolve` tool to apply or discard these changes. ## Limits & Caps - Hidden tool: `ResolveTool.hidden = true`, and normal requested-tool filtering removes `resolve`; `createTools(...)` adds it separately as a hidden tool. -- Exactly one active queue invoker is consulted per call via `session.peekQueueInvoker()`; if none exists, one standing handler may be consulted via `session.peekStandingResolveHandler()`. -- There is no independent queue depth cap in this tool; ordering follows the shared tool-choice queue and mode-owned standing handler lifecycle. +- Per call, `resolve` consults the in-flight hard-directive queue invoker (`session.peekQueueInvoker()`), then the non-forcing pending-preview invoker (`session.peekPendingInvoker()`), then a standing handler (`session.peekStandingResolveHandler()`). +- There is no independent depth cap in this tool; pending previews stack as unique-keyed invokers (resolved head-first), separate from the consuming directive queue and the mode-owned standing handler lifecycle. ## Errors - `apply` with no pending action or standing handler: throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`. `discard` in the same situation succeeds with `Nothing to discard; no pending action remains.` instead of erroring. diff --git a/docs/tools/write.md b/docs/tools/write.md index b37859e39..f7eb86df2 100644 --- a/docs/tools/write.md +++ b/docs/tools/write.md @@ -6,7 +6,7 @@ - Entry: `packages/coding-agent/src/tools/write.ts` - Model-facing prompt: `packages/coding-agent/src/prompts/tools/write.md` - Key collaborators: - - `packages/coding-agent/src/tools/archive-reader.ts` — parse `archive.ext:entry` selectors. + - `packages/coding-agent/src/utils/zip.ts` — the unified ZIP/tar wrapper: parse `archive.ext:entry` selectors and rewrite the archive whole. - `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite paths and perform row insert/update/delete. - `packages/coding-agent/src/tools/conflict-detect.ts` — parse `conflict://` URIs and splice recorded merge-conflict regions. - `packages/coding-agent/src/lsp/index.ts` — format-on-write and diagnostics writethrough. @@ -56,10 +56,10 @@ Single-shot result. 1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips pasted `[PATH#HASH]` headers and `LINE:` hashline prefixes from `content` when the session is in hashline display mode. 2. If `path` is an internal URL whose handler exposes `write`, the tool delegates directly to `handler.write(...)` and returns. 3. `conflict://...` paths are handled next by the merge-conflict resolver. Scope reads such as `conflict:///ours` are rejected as read-only; writable conflict URIs must omit the scope. -4. It calls `#resolveArchiveWritePath()` next. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/tools/archive-reader.ts`, checks candidate archive files on disk (longest match first), and falls back to the shortest candidate archive path even when the archive file does not exist yet. +4. It calls `#resolveArchiveWritePath()` next. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/utils/zip.ts`, checks candidate archive files on disk (longest match first), and falls back to the shortest candidate archive path even when the archive file does not exist yet. 5. Archive writes call `enforcePlanModeWrite(..., { op: exists ? "update" : "create" })`, then `#writeArchiveEntry()`. - The parent directory of the archive file is created with `fs.mkdir(..., { recursive: true })`. - - `.zip` archives are read with `fflate.unzipSync()`, the target entry is replaced in an in-memory map, and the archive is rewritten with `fflate.zipSync()` + `Bun.write()`. + - `.zip` archives are read with `unzip()`, the target entry is replaced in an in-memory map, and the archive is reframed with `zip()` + `Bun.write()` (both from `packages/coding-agent/src/utils/zip.ts`, over `node:zlib`). - `.tar`, `.tar.gz`, and `.tgz` archives are read with `Bun.Archive`, existing entries are copied into an object map, the target entry is replaced, and `Bun.Archive.write()` rewrites the archive. - `invalidateFsScanAfterWrite()` runs on the archive file path. 6. If the path is not treated as an archive, `execute()` calls `#resolveSqliteWritePath()`. That uses `parseSqlitePathCandidates()` and `isSqliteFile()` from `packages/coding-agent/src/tools/sqlite-reader.ts`. Existing non-SQLite files suppress the SQLite path interpretation. @@ -147,7 +147,7 @@ content: "" - May chmod a shebang file executable after a successful plain-file write. - Subprocesses / native bindings - Uses Bun SQLite bindings via `bun:sqlite`. - - Uses Bun archive APIs and lazily imports `fflate` for ZIP reads/writes. + - Uses `Bun.Archive` for tar; ZIP read/write is framed in `packages/coding-agent/src/utils/zip.ts` over the `node:zlib` DEFLATE codec. - May talk to configured LSP servers through `packages/coding-agent/src/lsp/index.ts`. - Session state (transcript, memory, jobs, checkpoints, registries) - Invalidates shared filesystem scan cache entries through `invalidateFsScanAfterWrite()`. diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 9290a9796..10fe737ce 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -262,7 +262,7 @@ width models in measure-vs-slice produced crashes. - Fast path: printable ASCII is one cell per code unit. - Anything past the ASCII prefix measures through `Bun.stringWidth` (CSI/OSC - stripped to zero); tabs are added back at `getDefaultTabWidth()` columns. + stripped to zero); tabs are added back at the fixed `DEFAULT_TAB_WIDTH` columns. - OSC 66 sized spans are added back as `scale × (explicit w ?? payload width)` — `Bun.stringWidth` would otherwise strip the whole span to zero. diff --git a/package.json b/package.json index b271dfe44..5aaed4161 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "type": "module", "packageManager": "bun@1.3.14", "patchedDependencies": { - "beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch", "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch" }, "workspaces": { @@ -25,18 +24,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.0.7", - "@oh-my-pi/omp-stats": "16.0.7", - "@oh-my-pi/pi-agent-core": "16.0.7", - "@oh-my-pi/pi-ai": "16.0.7", - "@oh-my-pi/pi-catalog": "16.0.7", - "@oh-my-pi/pi-coding-agent": "16.0.7", - "@oh-my-pi/pi-mnemopi": "16.0.7", - "@oh-my-pi/pi-natives": "16.0.7", - "@oh-my-pi/pi-tui": "16.0.7", - "@oh-my-pi/pi-utils": "16.0.7", - "@oh-my-pi/pi-wire": "16.0.7", - "@oh-my-pi/snapcompact": "16.0.7", + "@oh-my-pi/hashline": "16.1.2", + "@oh-my-pi/omp-stats": "16.1.2", + "@oh-my-pi/pi-agent-core": "16.1.2", + "@oh-my-pi/pi-ai": "16.1.2", + "@oh-my-pi/pi-catalog": "16.1.2", + "@oh-my-pi/pi-coding-agent": "16.1.2", + "@oh-my-pi/pi-mnemopi": "16.1.2", + "@oh-my-pi/pi-natives": "16.1.2", + "@oh-my-pi/pi-tui": "16.1.2", + "@oh-my-pi/pi-utils": "16.1.2", + "@oh-my-pi/pi-wire": "16.1.2", + "@oh-my-pi/snapcompact": "16.1.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", @@ -55,21 +54,22 @@ "@typescript/native-preview": "7.0.0-dev.20260609.1", "@xterm/headless": "^6.0.0", "arktype": "^2.2.0", - "beautiful-mermaid": "^1.1.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", "diff": "^9.0.0", "fflate": "0.8.3", "fastembed": "2.1.0", + "fast-xml-parser": "^5.9.0", "ghostty-web": "^0.4.0", "handlebars": "^4.7.9", "linkedom": "^0.18.12", "lint-staged": "^17.0.7", "lru-cache": "11.5.1", "lucide-react": "^1.17.0", + "mammoth": "^1.12.0", "marked": "^18.0.5", - "markit-ai": "0.5.3", + "mupdf": "^1.27.0", "onnxruntime-node": "1.26.0", "partial-json": "^0.1.7", "postcss": "^8.5.15", @@ -171,7 +171,7 @@ "lint:py": "ruff check python && ruff format --check python", "fix:py": "ruff check --fix python && ruff format python", "prepublishOnly": "bun run check", - "prepare": "bun run generate-docs-index && bun run build-tool-views", + "prepare": "bun run build-tool-views", "publish": "bun run prepublishOnly && npm publish -ws --access public", "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", "release": "bun scripts/release.ts", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a4828e4d9..df9134a80 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Fixed + +- Ensure deep-cloning of tool-call arguments respects own enumerable properties +- Prevent direct object references between agent message snapshots and streaming events + +## [16.1.0] - 2026-06-19 + +### Added + +- Added `SoftToolRequirement` support to `getToolChoice`: a host can require a tool by returning a soft requirement instead of a hard `ToolChoice`. The loop injects the supplied reminder once (leaving `tool_choice` on auto), and escalates to a one-turn forced choice — skipping any detour tool batch — only if the model fails to call the required tool, avoiding the provider message-cache invalidation of forcing every turn. +- Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs + +### Fixed + +- Improved token estimation accuracy for compaction summaries containing multi-block content + +## [16.0.11] - 2026-06-19 + +### Changed + +- Updated the display format for truncated file operation summaries + +## [16.0.8] - 2026-06-18 + +### Fixed + +- Stopped the compaction `` summary from tracking `scheme://` URLs — internal URIs (`conflict://`, `artifact://`, `local://`, `history://`, …) and web URLs are no longer recorded as files, and legacy entries rehydrated from older compaction summaries are dropped. + ## [16.0.6] - 2026-06-18 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index f0d0bbc65..cbc2c483c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "16.0.7", + "version": "16.1.2", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -39,6 +39,7 @@ "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "@oh-my-pi/snapcompact": "catalog:", "@opentelemetry/api": "catalog:" }, diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 9c962de0a..96ee303f4 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,8 +14,11 @@ import { resolveApiKeyOnce, seedApiKeyResolver, streamSimple, + stripSchemaDescriptions, + type ToolChoice, type ToolResultMessage, type TSchema, + toolWireSchema, validateToolArguments, zodToWireSchema, } from "@oh-my-pi/pi-ai"; @@ -37,7 +40,8 @@ import { signalListLabel, } from "@oh-my-pi/pi-ai/utils/harmony-leak"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; -import { sanitizeText } from "@oh-my-pi/pi-utils"; +import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector"; import { type AgentTelemetry, @@ -65,6 +69,7 @@ import type { AsideMessage, StreamFn, } from "./types"; +import { isSoftToolRequirement } from "./types"; import { yieldIfDue } from "./utils/yield"; /** Stop-details marker for a provider error after assistant content/tool args already streamed. */ @@ -81,6 +86,27 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted"); */ const MAX_PAUSED_TURN_CONTINUATIONS = 8; +/** + * Cap on consecutive forced escalations for a single soft tool requirement. + * A forced `toolChoice` guarantees the call, so this is purely defensive: if a + * model somehow never satisfies the requirement, give up forcing rather than + * spin the loop. Reset whenever the requirement id changes or clears. + */ +const MAX_SOFT_TOOL_ESCALATIONS = 3; + +/** + * Whether a hard `toolChoice` for a turn conflicts with a pending soft tool + * requirement — i.e. forbids tools (`"none"`) or forces a *different* specific + * tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model + * satisfy the requirement, so they do not conflict and the soft gate stays active. + */ +function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean { + if (choice === undefined) return false; + if (typeof choice === "string") return choice === "none"; + const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name; + return name !== requiredTool; +} + /** * Cadence (ms) for polling queued steering while an `interruptible` tool is in * flight, so a steer cuts the wait short instead of sitting idle until the @@ -124,22 +150,6 @@ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefi type AssistantContentBlock = AssistantMessage["content"][number]; type AssistantToolCallBlock = Extract; -type CloneableRecord = Record; - -function cloneUnknown(value: unknown): unknown { - if (Array.isArray(value)) return value.map(cloneUnknown); - if (!value || typeof value !== "object") return value; - const source = value as CloneableRecord; - const out: CloneableRecord = {}; - for (const [key, child] of Object.entries(source)) { - out[key] = cloneUnknown(child); - } - return out; -} - -function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] { - return cloneUnknown(args) as AssistantToolCallBlock["arguments"]; -} function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock { switch (block.type) { @@ -150,7 +160,7 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC case "redactedThinking": return { ...block }; case "toolCall": - return { ...block, arguments: cloneToolArguments(block.arguments) }; + return { ...block, arguments: structuredCloneJSON(block.arguments) }; } } @@ -166,10 +176,19 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage { }; } -function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent { +/** + * Deep-clone an assistant streaming event so subscribers get an immutable view. + * Pass `partialSnapshot` when the caller has already snapshotted `event.partial` + * (the `message_update` push sites alias it as the event's `message`) so the + * identical partial is not deep-cloned twice per streaming delta. + */ +function snapshotAssistantMessageEvent( + event: AssistantMessageEvent, + partialSnapshot?: AssistantMessage, +): AssistantMessageEvent { switch (event.type) { case "start": - return { ...event, partial: snapshotAssistantMessage(event.partial) }; + return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) }; case "text_start": case "text_delta": case "text_end": @@ -178,12 +197,12 @@ function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantM case "thinking_end": case "toolcall_start": case "toolcall_delta": - return { ...event, partial: snapshotAssistantMessage(event.partial) }; + return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) }; case "toolcall_end": return { ...event, toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock, - partial: snapshotAssistantMessage(event.partial), + partial: partialSnapshot ?? snapshotAssistantMessage(event.partial), }; case "done": return { ...event, message: snapshotAssistantMessage(event.message) }; @@ -517,9 +536,13 @@ function normalizeMessagesForProvider( }); } -export const INTENT_FIELD = "_i"; +const INTENT_FIELD_DESCRIPTION = "concise intent"; -function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown { +function injectIntentIntoSchema( + schema: unknown, + mode: "require" | "optional" = "require", + describeIntent = true, +): unknown { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; const schemaRecord = schema as Record; const propertiesValue = schemaRecord.properties; @@ -545,10 +568,9 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = return { ...schemaRecord, properties: { - [INTENT_FIELD]: { - type: "string", - description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines", - }, + [INTENT_FIELD]: describeIntent + ? { type: "string", description: INTENT_FIELD_DESCRIPTION } + : { type: "string" }, ...properties, }, ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}), @@ -559,26 +581,36 @@ export function normalizeTools( tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, + pruneDescriptions = false, ): Context["tools"] { injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1"; return tools?.map(t => { const intentMode = resolveIntentMode(t.intent); + const doInjectIntent = injectIntent && intentMode !== "omit"; + // When the full catalog is rendered into the system prompt, ship the tool + // specs without their descriptions (top-level + nested schema annotations) + // so they are not duplicated on the wire. Strip the STABLE wire schema (the + // memoized `stripSchemaDescriptions` result is reused across requests), then + // re-inject `_i` (without its hint, which `describeIntent: false` omits) so + // intent tracing keeps the field while no descriptions ride the wire. + if (pruneDescriptions) { + let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema; + if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema; + return { ...t, parameters, description: "" }; + } let parameters: TSchema = t.parameters; - if (injectIntent && intentMode !== "omit") { + if (doInjectIntent) { if (isZodSchema(parameters)) { - const wired = zodToWireSchema(parameters); - parameters = injectIntentIntoSchema(wired, intentMode) as TSchema; + parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema; } else if (isArkSchema(parameters)) { - const wired = arkToWireSchema(parameters); - parameters = injectIntentIntoSchema(wired, intentMode) as TSchema; + parameters = injectIntentIntoSchema(arkToWireSchema(parameters), intentMode) as TSchema; } else { parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema; } } const description = t.description ?? ""; - const injectExampleIntent = injectIntent && intentMode !== "omit"; const examplesBlock = exampleDialect - ? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined) + ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined) : ""; const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description; return { ...t, parameters, description: finalDescription }; @@ -714,6 +746,19 @@ async function runLoopBody( let harmonyTruncateResumeCount = 0; let pausedTurnContinuations = 0; + // Soft tool requirement lifecycle (reminder → escalate; see SoftToolRequirement). + // `forcedToolChoice` carries a one-turn escalation into the next model call. It + // overrides the static toolChoice but NEVER the host's hard getToolChoice(). + let softRequirementId: string | undefined; + let forcedToolChoice: ToolChoice | undefined; + let softEscalations = 0; + // Resolved once per logical turn at the fetch site below and reused across + // Harmony-leak re-samples (which re-enter the same turn) so the consuming + // getToolChoice is never advanced twice; the flag resets at the message boundary. + let hostToolChoice: ToolChoice | undefined; + let softRequiredTool: string | undefined; + let directiveResolvedForTurn = false; + // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { let hasMoreToolCalls = true; @@ -749,6 +794,41 @@ async function runLoopBody( await config.syncContextBeforeModelCall(currentContext); } + // Resolve the per-turn tool-choice directive ONCE per logical turn. The + // host hard-choice path (getToolChoice → nextToolChoice) is CONSUMING — it + // advances a generator on every call — so Harmony-leak retries, which + // re-sample the same turn via `continue` without a turn_end, must reuse the + // values fetched on the first attempt rather than double-advancing it. + // Fetched here (after pending-message flush + context sync, immediately + // before the call) so a throw in between cannot wedge an in-flight + // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement + // triggers the remind-then-escalate lifecycle: inject its reminder inline + // once per new id (toolChoice stays auto), and the gate below escalates to + // a forced choice only if the model declines. The host wrapper already + // dropped a soft requirement whose tool is inactive. + if (!directiveResolvedForTurn) { + const directive = signal?.aborted ? undefined : config.getToolChoice?.(); + const softReq = isSoftToolRequirement(directive) ? directive : undefined; + hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive; + softRequiredTool = softReq?.toolName; + if (softReq !== undefined) { + if (softReq.id !== softRequirementId) { + softRequirementId = softReq.id; + softEscalations = 0; + for (const reminder of softReq.reminder) { + stream.push({ type: "message_start", message: reminder }); + stream.push({ type: "message_end", message: reminder }); + currentContext.messages.push(reminder); + newMessages.push(reminder); + } + } + } else { + softRequirementId = undefined; + softEscalations = 0; + } + directiveResolvedForTurn = true; + } + // Stream assistant response let recovered: HarmonyRecoveredToolCall | undefined; let message: AssistantMessage; @@ -763,6 +843,8 @@ async function runLoopBody( stepCounter, streamFn, harmonyRetryAttempt, + hostToolChoice, + forcedToolChoice, ); harmonyRetryAttempt = 0; harmonyTruncateResumeCount = 0; @@ -779,6 +861,10 @@ async function runLoopBody( recovered = err.recovered; message = recovered.message; await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt); + // A recovered message completes the turn, so the abort-retry counter + // resets like the normal success path (the truncate-resume counter + // keeps accumulating for its cross-turn cap). + harmonyRetryAttempt = 0; } else { if (harmonyRetryAttempt >= 2) { await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt); @@ -799,6 +885,14 @@ async function runLoopBody( } newMessages.push(message); + // The escalation choice (if any) applied to the call above; clear it so + // only the single escalation turn carries the forced choice. + forcedToolChoice = undefined; + + // A fresh logical turn re-resolves the directive next iteration; a Harmony + // retry `continue`s before this line and keeps the cached value. + directiveResolvedForTurn = false; + if (message.stopReason === "error" || message.stopReason === "aborted") { // Create placeholder tool results for any tool calls in the aborted message // This maintains the tool_use/tool_result pairing that the API requires @@ -851,8 +945,51 @@ async function runLoopBody( hasMoreToolCalls = false; } + // A turn is compliant ONLY when it calls the required tool and nothing + // else — mirroring the forced-tool_choice turn, which can emit only that + // tool. A required+detour batch is treated as non-compliant so detour + // tools never run side effects while the requirement is still pending. + const calledOnlyRequiredTool = + softRequiredTool !== undefined && + toolCalls.length > 0 && + toolCalls.every(toolCall => toolCall.name === softRequiredTool); + const softGateActive = + softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool); + const softNonCompliant = softGateActive && !calledOnlyRequiredTool; + const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { + if (softNonCompliant && softRequiredTool !== undefined) { + if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) { + throw new Error( + `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`, + ); + } + // A soft-required tool is pending but the model called something else + // (or yielded). Do NOT execute the detour — pair each call with a + // skipped result and force the required tool next turn. This is the + // only turn that changes toolChoice; a model that complies with the + // reminder pays no message-cache invalidation. Re-engage so the loop + // never yields while the requirement is unmet. + for (const toolCall of toolCalls) { + const result = createAbortedToolResult( + toolCall, + stream, + "skipped", + `Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`, + ); + currentContext.messages.push(result); + newMessages.push(result); + toolResults.push(result); + recordSkippedTool(telemetry, { + toolCallId: toolCall.id, + toolName: toolCall.name, + status: "skipped", + }); + } + forcedToolChoice = { type: "tool", name: softRequiredTool }; + softEscalations++; + hasMoreToolCalls = true; + } else if (hasMoreToolCalls) { const executionResult = await executeToolCalls( currentContext, message, @@ -1000,6 +1137,8 @@ async function streamAssistantResponse( stepCounter: StepCounter, streamFn?: StreamFn, harmonyRetryAttempt = 0, + hostToolChoice?: ToolChoice, + forcedToolChoice?: ToolChoice, ): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; @@ -1013,6 +1152,9 @@ async function streamAssistantResponse( const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT); const exampleDialect = ownedDialect ?? preferredDialect(config.model.id); + // Owned/in-band dialects carry the catalog in the prompt as text and send no + // native `tools`, so description pruning only applies to native tool calling. + const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect; // Build LLM context — append-only mode caches system prompt + tools // AND keeps an append-only message log so prior-turn bytes are stable. let llmContext: Context; @@ -1021,12 +1163,13 @@ async function streamAssistantResponse( llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing, exampleDialect, + pruneToolDescriptions, }); } else { llmContext = { systemPrompt: context.systemPrompt, messages: normalizedMessages, - tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect), + tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions), }; } if (config.transformProviderContext) { @@ -1049,7 +1192,6 @@ async function streamAssistantResponse( const streamFunction = streamFn || streamSimple; - const dynamicToolChoice = config.getToolChoice?.(); const dynamicReasoning = config.getReasoning?.(); const dynamicDisableReasoning = config.getDisableReasoning?.(); const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model); @@ -1084,7 +1226,7 @@ async function streamAssistantResponse( const effectiveTemperature = harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature; // Owned tool calling sends no native tools, so any tool_choice would error. - const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice); + const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice); const effectiveReasoning = dynamicReasoning ?? config.reasoning; const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning; @@ -1263,10 +1405,15 @@ async function streamAssistantResponse( if (addedPartial) { context.messages[context.messages.length - 1] = partialMessage; completedToolCallIds.clear(); + // `message` and `assistantMessageEvent.partial` intentionally share one + // immutable snapshot of the streaming partial: every message_update + // consumer treats both as read-only, so cloning the identical partial + // twice per delta was pure waste. + const messageSnapshot = snapshotAssistantMessage(partialMessage); stream.push({ type: "message_update", - assistantMessageEvent: snapshotAssistantMessageEvent(event), - message: snapshotAssistantMessage(partialMessage), + assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot), + message: messageSnapshot, }); } else { context.messages.push(partialMessage); @@ -1291,10 +1438,15 @@ async function streamAssistantResponse( partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; config.onAssistantMessageEvent?.(partialMessage, event); + // `message` and `assistantMessageEvent.partial` intentionally share one + // immutable snapshot of the streaming partial: every message_update + // consumer treats both as read-only, so cloning the identical partial + // twice per delta was pure waste. + const messageSnapshot = snapshotAssistantMessage(partialMessage); stream.push({ type: "message_update", - assistantMessageEvent: snapshotAssistantMessageEvent(event), - message: snapshotAssistantMessage(partialMessage), + assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot), + message: messageSnapshot, }); } break; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 66a51c4e9..22e872ff0 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -39,7 +39,9 @@ import type { AsideMessage, StreamFn, ToolCallContext, + ToolChoiceDirective, } from "./types"; +import { isSoftToolRequirement } from "./types"; import { EventLoopKeepalive } from "./utils/yield"; /** @@ -223,6 +225,12 @@ export interface AgentOptions { /** Enable intent tracing schema injection/stripping in the harness. */ intentTracing?: boolean; + /** + * Strip tool descriptions from provider-bound tool specs (top-level + nested + * schema annotations). Use when the full catalog is rendered into the system + * prompt so descriptions are not duplicated on the wire. Native tool calling only. + */ + pruneToolDescriptions?: boolean; /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */ dialect?: Dialect; /** @@ -232,8 +240,8 @@ export interface AgentOptions { * the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}. */ abortOnFabricatedToolResult?: boolean; - /** Dynamic tool choice override, resolved per LLM call. */ - getToolChoice?: () => ToolChoice | undefined; + /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */ + getToolChoice?: () => ToolChoiceDirective | undefined; /** * Cursor exec handlers for local tool execution. @@ -336,9 +344,10 @@ export class Agent { #preferWebsockets?: boolean; #transformToolCallArguments?: (args: Record, toolName: string) => Record; #intentTracing: boolean; + #pruneToolDescriptions: boolean; #dialect?: Dialect; #abortOnFabricatedToolResult?: boolean; - #getToolChoice?: () => ToolChoice | undefined; + #getToolChoice?: () => ToolChoiceDirective | undefined; #onPayload?: SimpleStreamOptions["onPayload"]; #onResponse?: SimpleStreamOptions["onResponse"]; #onSseEvent?: SimpleStreamOptions["onSseEvent"]; @@ -407,6 +416,7 @@ export class Agent { this.#preferWebsockets = opts.preferWebsockets; this.#transformToolCallArguments = opts.transformToolCallArguments; this.#intentTracing = opts.intentTracing === true; + this.#pruneToolDescriptions = opts.pruneToolDescriptions === true; this.#dialect = opts.dialect; this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult; this.#getToolChoice = opts.getToolChoice; @@ -1009,10 +1019,13 @@ export class Agent { } : undefined; - const getToolChoice = () => { - const queuedToolChoice = this.#getToolChoice?.(); - if (queuedToolChoice !== undefined) { - return refreshToolChoiceForActiveTools(queuedToolChoice, this.#state.tools); + const getToolChoice = (): ToolChoiceDirective | undefined => { + const queued = this.#getToolChoice?.(); + if (queued !== undefined) { + if (isSoftToolRequirement(queued)) { + return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined; + } + return refreshToolChoiceForActiveTools(queued, this.#state.tools); } return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools); }; @@ -1059,6 +1072,7 @@ export class Agent { cursorOnToolResult, transformToolCallArguments: this.#transformToolCallArguments, intentTracing: this.#intentTracing, + pruneToolDescriptions: this.#pruneToolDescriptions, dialect: this.#dialect, abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult, appendOnlyContext: this.#appendOnlyContext, diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index 50e45b1d2..b074cb8c8 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -35,6 +35,8 @@ export interface BuildOptions { /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */ intentTracing: boolean; exampleDialect?: Dialect; + /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */ + pruneToolDescriptions?: boolean; } /** @@ -270,7 +272,8 @@ export class AppendOnlyContextManager { function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot { const systemPrompt = [...context.systemPrompt]; - const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? []; + const tools = + normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? []; return { systemPrompt, tools, @@ -291,6 +294,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil })), i: options.intentTracing, ex: options.exampleDialect, + pd: options.pruneToolDescriptions, }); let hash = 0; for (let i = 0; i < payload.length; i++) { diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 3be7f751b..99366e0f6 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -326,9 +326,16 @@ export function estimateTokens(message: AgentMessage): number { case "branchSummary": case "compactionSummary": { fragments.push(message.summary); - if (message.role === "compactionSummary" && message.images) { - // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. - extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + if (message.role === "compactionSummary") { + if (message.blocks) { + for (const block of message.blocks) { + if (block.type === "text") fragments.push(block.text); + else extra += snapcompact.FRAME_TOKEN_ESTIMATE; + } + } else if (message.images) { + // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. + extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + } } break; } diff --git a/packages/agent/src/compaction/messages.ts b/packages/agent/src/compaction/messages.ts index 149996e79..a23887ce9 100644 --- a/packages/agent/src/compaction/messages.ts +++ b/packages/agent/src/compaction/messages.ts @@ -51,7 +51,11 @@ export interface CompactionSummaryMessage { shortSummary?: string; tokensBefore: number; providerPayload?: ProviderPayload; - /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */ + /** Runtime-only ordered archive blocks for snapcompact: old text region, + * imaged middle, then new text region. When present, `summary` is already + * the final lead-in text (no legacy wrapper applied). */ + blocks?: (TextContent | ImageContent)[]; + /** Snapcompact image blocks, kept for display counts / legacy consumers. */ images?: ImageContent[]; timestamp: number; } @@ -101,14 +105,19 @@ export function createCompactionSummaryMessage( shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[], + blocks?: (TextContent | ImageContent)[], ): CompactionSummaryMessage { + const imageBlocks = + blocks?.filter((block): block is ImageContent => block.type === "image") ?? + (images && images.length > 0 ? images : undefined); return { role: "compactionSummary", summary, shortSummary, tokensBefore, providerPayload, - images: images && images.length > 0 ? images : undefined, + blocks: blocks && blocks.length > 0 ? blocks : undefined, + images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, timestamp: new Date(timestamp).getTime(), }; } @@ -182,13 +191,16 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined case "compactionSummary": return { role: "user", - content: [ - { - type: "text" as const, - text: renderCompactionSummaryContext(message.summary), - }, - ...(message.images ?? []), - ], + content: + message.blocks !== undefined + ? [{ type: "text" as const, text: message.summary }, ...message.blocks] + : [ + { + type: "text" as const, + text: renderCompactionSummaryContext(message.summary), + }, + ...(message.images ?? []), + ], attribution: "agent", providerPayload: message.providerPayload, timestamp: message.timestamp, diff --git a/packages/agent/src/compaction/pruning.ts b/packages/agent/src/compaction/pruning.ts index 58c3dccca..1ba9a7ac0 100644 --- a/packages/agent/src/compaction/pruning.ts +++ b/packages/agent/src/compaction/pruning.ts @@ -30,6 +30,24 @@ export interface PruneConfig { supersedeKey?: SupersedeKeyFn; /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */ pruneUseless?: boolean; + /** + * Compaction boundary: the `firstKeptEntryId` of the latest compaction on + * the branch. Entries at indices BEFORE this id are summarized away and never + * sent to the model, so mutating them only churns persisted history without + * shrinking the prompt — they are skipped. Undefined = no compaction (the + * whole branch is sent). + */ + keepBoundaryId?: string; + /** + * Prompt-cache guard. When set, a tool result whose all-message suffix + * (tokens of every message after it) EXCEEDS this is part of the warm, + * already-sent cache prefix: mutating it forces the provider to re-write the + * whole suffix (cacheWrite premium). Such results — including superseded and + * useless ones, which otherwise bypass {@link protectTokens} — are left for + * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined = + * no cache guard (legacy: superseded/useless prune at any depth). + */ + cacheWarmSuffixTokens?: number; } export const DEFAULT_PRUNE_CONFIG: PruneConfig = { @@ -66,10 +84,22 @@ export interface SupersedePruneConfig { pruneUseless?: boolean; /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */ suffixTokenLimit?: number; - /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */ + /** + * Prune all candidates when the last message is at least this old: the + * provider prompt cache is then cold, so re-writing it is free. MUST exceed + * the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted + * by the flush. Default 30 min — callers on long retention override it. + */ idleFlushMs?: number; /** Clock override for tests. */ now?: number; + /** + * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries + * before it are summarized away and never sent, so they are skipped in every + * path — including the idle flush — to avoid pointless history churn. + * Undefined = no compaction (the whole branch is sent). + */ + keepBoundaryId?: string; /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */ protectedTools: ProtectedToolMatcher[]; } @@ -103,6 +133,35 @@ function estimatePrunedSavings(tokens: number, notice: string): number { return Math.max(0, tokens - noticeTokens); } +/** + * For each entry index, the estimated token total of all *message* entries + * strictly after it — how much prompt-cache content the provider must re-write + * (cacheWrite premium) if that entry is mutated in place. Used to keep prune + * mutations inside the cheap-to-recache tail. + */ +function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] { + const suffix = new Array(entries.length); + let accumulated = 0; + for (let i = entries.length - 1; i >= 0; i--) { + suffix[i] = accumulated; + const entry = entries[i]; + if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage); + } + return suffix; +} + +/** + * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries + * before this index are summarized away by the latest compaction and never sent, + * so prune passes must not mutate them. Returns 0 when there is no boundary (no + * compaction → whole branch is sent) or the id is absent from `entries`. + */ +function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number { + if (keepBoundaryId === undefined) return 0; + const index = entries.findIndex(entry => entry.id === keepBoundaryId); + return index < 0 ? 0 : index; +} + interface SupersedeCandidate { entry: SessionMessageEntry; message: ToolResultMessage; @@ -183,7 +242,8 @@ function collectUselessResults( * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a * candidate is pruned now only when the suffix after it is small (tail case — * the read→edit→read loop) or when the context has been idle long enough that - * the provider cache is cold anyway (then ALL candidates flush). + * the provider cache is cold anyway (then all still-sent candidates flush). + * Never mutates entries before `keepBoundaryId` (summarized away — not sent). */ export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult { const toolCallsById = collectToolCallsById(entries); @@ -209,20 +269,24 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe const idle = lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS); + const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId); + let toPrune: SupersedeCandidate[]; if (idle) { - toPrune = candidates; + // Provider cache is cold (idle exceeds the retention TTL), so re-writing + // the sent region costs nothing. Entries before the compaction boundary + // are summarized away and never sent — skip them to avoid pointless churn. + toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex); } else { const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT; // suffixTokens[i] = estimated tokens of all messages strictly after entry i. - const suffixTokens = new Array(entries.length); - let accumulated = 0; - for (let i = entries.length - 1; i >= 0; i--) { - suffixTokens[i] = accumulated; - const entry = entries[i]; - if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage); - } - toPrune = candidates.filter(candidate => suffixTokens[candidate.index] <= suffixTokenLimit); + // Mutating a candidate re-writes its suffix in the warm cache, so prune only + // when that suffix is small (cheap-to-recache tail) and the candidate sits + // at/after the compaction boundary. + const suffixTokens = computeMessageSuffixTokens(entries); + toPrune = candidates.filter( + candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit, + ); } if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 }; @@ -262,6 +326,11 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = ) : undefined; + const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId); + const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens; + // All-message suffix per index, only when the cache guard is armed. + const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries); + for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const message = getToolResultMessage(entry); @@ -275,10 +344,23 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = continue; } - // Superseded and useless results are pruned first: they bypass the - // protect window (a stale copy of re-read content — or a result the - // tool itself flagged as carrying no information — is dead weight at - // any age). + // Prompt-cache guard: a result whose all-message suffix exceeds the + // warm-cache window sits in the already-sent cached prefix — mutating it + // re-writes the whole suffix (cacheWrite premium). Entries before the + // compaction boundary are summarized away (never sent). Both are skipped + // before any prune decision, so superseded/useless cannot reach a deep, + // still-cached copy; compaction/shake reclaim those when they rebuild. + const inWarmPrefix = + messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens; + if (inWarmPrefix || i < boundaryIndex) { + accumulatedTokens += tokens; + continue; + } + + // Superseded and useless results bypass the age-based protect window + // (a stale re-read copy, or a result the tool flagged as uninformative, + // is dead weight at any age) — but only within the cache-warm tail: the + // guard above already excluded deeper, still-cached copies. const superseded = supersededMessages?.has(message) ?? false; const useless = uselessMessages?.has(message) ?? false; const tooSmall = tokens < MIN_PRUNE_TOKENS; diff --git a/packages/agent/src/compaction/shake.ts b/packages/agent/src/compaction/shake.ts index f4535318d..7e0f2c5ed 100644 --- a/packages/agent/src/compaction/shake.ts +++ b/packages/agent/src/compaction/shake.ts @@ -31,6 +31,14 @@ export interface ShakeConfig { protectedTools: ProtectedToolMatcher[]; /** Minimum token size for a fenced/XML block to be eligible. */ fenceMinTokens: number; + /** + * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries + * before it are summarized away and never sent, so they are skipped — shaking + * them only churns persisted history. Undefined = no compaction (whole branch + * is sent). Note: shake still elides the warm cached prefix at/after the + * boundary — that is its job as a compaction-class reducer. + */ + keepBoundaryId?: string; } /** Auto-shake config: protects the live tail, conservative thresholds. */ @@ -289,9 +297,20 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig const toolCallsById = collectToolCallsById(entries); + // Entries before the compaction boundary are summarized away and never sent — + // shaking them only churns persisted history (no prompt/cache effect). + const boundaryIndex = + config.keepBoundaryId === undefined + ? 0 + : Math.max( + 0, + entries.findIndex(entry => entry.id === config.keepBoundaryId), + ); + const regions: ShakeRegion[] = []; for (let i = 0; i < n; i++) { const entry = entries[i]; + if (i < boundaryIndex) continue; const toolResult = getToolResultMessage(entry); // Useless-flagged results carry no information once consumed; they are // eligible even inside the protect-recent window. diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts index 82ef3cab8..ddd743344 100644 --- a/packages/agent/src/compaction/utils.ts +++ b/packages/agent/src/compaction/utils.ts @@ -76,6 +76,23 @@ export function stripReadSelector(path: string): string { return splitReadSelector(path).path; } +/** + * A real filesystem path never contains a `scheme://` URL. Tool-call paths that + * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`, + * `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix + * form — are session-scoped or remote resources, not files the post-compaction + * agent can re-ground on. Keep them out of the `` summary. + */ +const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i; + +/** + * Whether `path` references a `scheme://` URL (internal URI or web URL) rather + * than a filesystem path that belongs in the compaction `` summary. + */ +export function isUrlSchemePath(path: string): boolean { + return URL_SCHEME_RE.test(path); +} + /** * Extract file operations from tool calls in an assistant message. */ @@ -94,6 +111,10 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp const path = typeof args.path === "string" ? args.path : undefined; if (!path) continue; + // Internal URIs (conflict://, artifact://, local://, history://, …) and + // web URLs are not re-groundable files — keep them out of ``. + if (isUrlSchemePath(path)) continue; + switch (block.name) { case "read": fileOps.read.add(stripReadSelector(path)); @@ -113,8 +134,11 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp * Returns readFiles (files only read, not modified) and modifiedFiles. */ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { - const modified = new Set([...fileOps.edited, ...fileOps.written]); - const readOnly = [...fileOps.read].filter(f => !modified.has(f)).sort(); + // Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries + // rehydrated straight into `fileOps` from a pre-fix compaction summary) — only + // real files belong in ``. New tool-call scans are already filtered. + const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f))); + const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort(); const modifiedFiles = [...modified].sort(); return { readFiles: readOnly, modifiedFiles }; } @@ -149,7 +173,7 @@ export function formatFileOperations( const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } return prompt.render(fileOperationsTemplate, { files }); } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index e178ebc1d..c55d9998b 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -36,6 +36,43 @@ export type StreamFn = ( */ export type AsideMessage = AgentMessage | (() => AgentMessage | null); +/** + * A soft tool requirement: the host wants `toolName` called before the loop + * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost + * up front (changing `tool_choice` invalidates the provider message cache). + * Returned from {@link AgentLoopConfig.getToolChoice} in place of a hard + * {@link ToolChoice}: the loop injects `reminder` once when a new `id` becomes + * active, runs with `toolChoice` unchanged, and escalates to a one-turn forced + * choice only if the model fails to call `toolName`. Auto-clears when the host + * stops returning it or `toolName` is no longer an active tool. + */ +export interface SoftToolRequirement { + /** Discriminates a soft requirement from a hard {@link ToolChoice}. */ + soft: true; + /** + * Stable id of the *current* requirement. The loop injects `reminder` when + * this id first becomes active and again whenever it changes (e.g. one + * stacked preview resolves and the next becomes the head), but never + * re-injects for an unchanged id across turns. + */ + id: string; + /** Tool that must be called before the loop runs other tools or yields. */ + toolName: string; + /** Host-owned reminder messages, injected once per `id` activation. */ + reminder: AgentMessage[]; +} + +/** + * A per-turn tool-choice directive: either a hard provider {@link ToolChoice} + * (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate). + */ +export type ToolChoiceDirective = ToolChoice | SoftToolRequirement; + +/** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */ +export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement { + return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true; +} + /** * Configuration for the agent loop. */ @@ -203,6 +240,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * then strips from arguments before executing tools. */ intentTracing?: boolean; + /** + * Strip tool descriptions (top-level + nested schema annotations) from the + * provider-bound tool specs. Use when the full catalog is rendered into the + * system prompt instead, so descriptions are not duplicated on the wire. + */ + pruneToolDescriptions?: boolean; /** * Owned tool calling dialect. * @@ -246,10 +289,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions { onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise; /** - * Dynamic tool choice override, resolved per LLM call. - * When set and returns a value, overrides the static `toolChoice`. + * Dynamic tool-choice directive, resolved once per turn. Returns a hard + * {@link ToolChoice} (applied verbatim, overriding the static `toolChoice`), + * a {@link SoftToolRequirement} (the loop reminds-then-escalates instead of + * forcing `tool_choice` immediately, so a model that complies with the + * reminder pays no message-cache invalidation), or `undefined` to fall back + * to the static `toolChoice`. */ - getToolChoice?: () => ToolChoice | undefined; + getToolChoice?: () => ToolChoiceDirective | undefined; /** * Dynamic reasoning effort override, resolved per LLM call. diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 04700934b..cf6d42cf6 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop"; +import { agentLoop, agentLoopContinue, agentLoopDetailed } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentEvent, @@ -12,6 +12,7 @@ import type { import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; import { createAssistantMessage, createUserMessage } from "./helpers"; @@ -2095,3 +2096,138 @@ describe("agentLoopContinue with AgentMessage", () => { expect(finalMessage.errorMessage).toBe("Deadline exceeded"); }); }); + +describe("agentLoop streaming snapshots", () => { + it("deep-clones tool-call arguments into message_update snapshots, copying only own enumerable properties", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const config: AgentLoopConfig = { + model: createMockModel().model, + convertToLlm: identityConverter, + }; + + // Arguments carry a nested object, a nested array, primitives, and an + // INHERITED enumerable property. The snapshot must deep-clone the own + // nested structures (fresh references), pass primitives through by value, + // and carry only OWN enumerable data — the inherited key must never leak + // into the immutable view subscribers receive. + const inheritedProto = { inheritedKey: "from-prototype" }; + const innerArray = [2, 3]; + const base: Record = Object.create(inheritedProto); + const sourceArgs: Record = Object.assign(base, { + nestedObj: { a: 1, b: "two" }, + nestedArr: [1, innerArray, { c: 4 }], + num: 42, + str: "hi", + flag: true, + nul: null, + }); + const toolCall = { type: "toolCall" as const, id: "tc-clone", name: "noop", arguments: sourceArgs }; + + // Turn 0 streams the tool call; the unknown tool produces an error result + // and the loop calls the model again — turn 1 returns plain text so the + // loop terminates instead of spinning forever. + let turn = 0; + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + if (turn++ === 0) { + const partial = createAssistantMessage([toolCall], "toolUse"); + stream.push({ type: "start", partial }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "{}", partial }); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial }); + stream.push({ type: "done", reason: "toolUse", message: partial }); + } else { + const partial = createAssistantMessage([{ type: "text", text: "done" }], "stop"); + stream.push({ type: "start", partial }); + stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial }); + stream.push({ type: "done", reason: "stop", message: partial }); + } + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("call noop")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + + const toolUpdate = events.find( + (e): e is Extract => + e.type === "message_update" && + e.message.role === "assistant" && + e.message.content.some(c => c.type === "toolCall"), + ); + expect(toolUpdate).toBeDefined(); + if (toolUpdate?.message.role !== "assistant") throw new Error("missing tool-call update"); + const block = toolUpdate.message.content.find(c => c.type === "toolCall"); + if (block?.type !== "toolCall") throw new Error("missing tool-call block"); + const cloned: Record = block.arguments; + + // Fresh top-level object, not the source reference. + expect(cloned).not.toBe(sourceArgs); + // Own enumerable keys only — the inherited property must not appear. + expect("inheritedKey" in cloned).toBe(false); + expect(Object.hasOwn(cloned, "inheritedKey")).toBe(false); + // Nested object: deep-cloned (equal value, distinct reference). + expect(cloned.nestedObj).toEqual({ a: 1, b: "two" }); + expect(cloned.nestedObj).not.toBe(sourceArgs.nestedObj); + // Nested array: deep-cloned recursively (distinct references at every level). + expect(Array.isArray(cloned.nestedArr)).toBe(true); + expect(cloned.nestedArr).toEqual([1, [2, 3], { c: 4 }]); + expect(cloned.nestedArr).not.toBe(sourceArgs.nestedArr); + expect((cloned.nestedArr as unknown[])[1]).not.toBe(innerArray); + // Primitives pass through by value. + expect(cloned.num).toBe(42); + expect(cloned.str).toBe("hi"); + expect(cloned.flag).toBe(true); + expect(cloned.nul).toBeNull(); + }); + + it("shares one immutable snapshot between message and assistantMessageEvent.partial on message_update", async () => { + const context: AgentContext = { + systemPrompt: ["You are helpful."], + messages: [], + tools: [], + }; + const config: AgentLoopConfig = { + model: createMockModel().model, + convertToLlm: identityConverter, + }; + + const livePartial = createAssistantMessage([{ type: "text", text: "Hi" }], "stop"); + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + stream.push({ type: "start", partial: livePartial }); + stream.push({ type: "text_start", contentIndex: 0, partial: livePartial }); + stream.push({ type: "text_delta", contentIndex: 0, delta: "Hi", partial: livePartial }); + stream.push({ type: "text_end", contentIndex: 0, content: "Hi", partial: livePartial }); + stream.push({ type: "done", reason: "stop", message: livePartial }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("say hi")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + + const update = events.find( + (e): e is Extract => e.type === "message_update", + ); + expect(update).toBeDefined(); + if (!update) throw new Error("missing message_update"); + const ame = update.assistantMessageEvent; + expect("partial" in ame).toBe(true); + if (!("partial" in ame)) throw new Error("expected a partial-bearing assistant event"); + // Alias contract: `message` and `assistantMessageEvent.partial` are ONE + // shared snapshot... + expect(update.message).toBe(ame.partial); + // ...and that snapshot is an independent deep clone of the live streaming + // partial, never the mutable partial object itself. + expect(update.message).not.toBe(livePartial); + }); +}); diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 402b6aea8..e4825e675 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message, Tool, ToolExample } from "@oh-my-pi/pi-ai"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; // --------------------------------------------------------------------------- @@ -603,8 +604,8 @@ describe("intent injection through build()", () => { const result = mgr.build(ctx, { intentTracing: true }); const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; expect(params?.properties).toBeDefined(); - expect(params!.properties!._i).toBeDefined(); - expect(params!.required).toContain("_i"); + expect(params!.properties![INTENT_FIELD]).toBeDefined(); + expect(params!.required).toContain(INTENT_FIELD); }); it("materializes ArkType params and keeps `_i` first in authored order", () => { @@ -616,8 +617,8 @@ describe("intent injection through build()", () => { const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; // `_i` must lead; authored order (path before content) is preserved rather // than ArkType's alphabetized-by-hash order (content, path). - expect(Object.keys(params.properties ?? {})).toEqual(["_i", "path", "content"]); - expect(params.required).toContain("_i"); + expect(Object.keys(params.properties ?? {})).toEqual([INTENT_FIELD, "path", "content"]); + expect(params.required).toContain(INTENT_FIELD); }); it("omits `_i` when intentTracing is false", () => { @@ -631,8 +632,8 @@ describe("intent injection through build()", () => { const result = mgr.build(ctx, { intentTracing: false }); const params = result.tools?.[0]?.parameters as { properties?: Record; required?: string[] }; - expect(params?.properties?._i).toBeUndefined(); - expect(params?.required ?? []).not.toContain("_i"); + expect(params?.properties?.[INTENT_FIELD]).toBeUndefined(); + expect(params?.required ?? []).not.toContain(INTENT_FIELD); }); it("intentTracing flip invalidates the fingerprint cache", () => { @@ -685,7 +686,7 @@ describe("tool examples injection through build()", () => { const result = mgr.build(ctx, { intentTracing: true, exampleDialect: "anthropic" }); const desc = result.tools?.[0]?.description ?? ""; - expect(desc).toContain(' { it("strips line-range and raw selectors in every supported shape", () => { expect(stripReadSelector("src/foo.ts:50")).toBe("src/foo.ts"); @@ -64,6 +69,41 @@ describe("extractFileOpsFromMessage", () => { expect(readFiles).toEqual([]); expect(modifiedFiles).toEqual(["src/login.ts"]); }); + + it("skips internal URLs and web URLs so they never enter ", () => { + const fileOps = createFileOps(); + const message = createAssistantMessage([ + readCall("r1", "src/keep.ts"), + readCall("r2", "artifact://7"), + readCall("r3", "local://ctx.md"), + readCall("r4", "https://example.com/page"), + writeCall("w1", "conflict://1"), + writeCall("w2", "conflict://*"), + // Tolerated `:conflict://N` prefix typo form the write tool accepts. + writeCall("w3", "src/login.ts:conflict://3"), + { type: "toolCall" as const, id: "e1", name: "edit", arguments: { path: "agent://abc" } }, + ]); + extractFileOpsFromMessage(message, fileOps); + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + expect(readFiles).toEqual(["src/keep.ts"]); + expect(modifiedFiles).toEqual([]); + }); +}); + +describe("computeFileLists", () => { + it("drops scheme:// URLs rehydrated from legacy compaction details", () => { + const fileOps = createFileOps(); + // Simulate a pre-fix summary's details.readFiles/modifiedFiles fed straight + // into fileOps without going through extractFileOpsFromMessage. + fileOps.read.add("src/read-only.ts"); + fileOps.read.add("artifact://7"); + fileOps.edited.add("src/edited.ts"); + fileOps.edited.add("conflict://1"); + fileOps.written.add("local://ctx.md"); + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + expect(readFiles).toEqual(["src/read-only.ts"]); + expect(modifiedFiles).toEqual(["src/edited.ts"]); + }); }); describe("formatFileOperations", () => { @@ -83,3 +123,24 @@ describe("formatFileOperations", () => { expect(rendered).toBe(["", "c.ts (Write)", ""].join("\n")); }); }); + +describe("isUrlSchemePath", () => { + it("flags internal URIs and web URLs", () => { + expect(isUrlSchemePath("conflict://1")).toBe(true); + expect(isUrlSchemePath("conflict://*")).toBe(true); + expect(isUrlSchemePath("artifact://7")).toBe(true); + expect(isUrlSchemePath("local://ctx.md")).toBe(true); + expect(isUrlSchemePath("history://AuthLoader")).toBe(true); + expect(isUrlSchemePath("https://example.com/page")).toBe(true); + // Prefixed conflict typo form — scheme appears after a colon, not at start. + expect(isUrlSchemePath("src/login.ts:conflict://3")).toBe(true); + }); + + it("leaves real filesystem paths untouched", () => { + expect(isUrlSchemePath("src/foo.ts")).toBe(false); + expect(isUrlSchemePath("C:/Users/me/file.ts")).toBe(false); + expect(isUrlSchemePath("db.sqlite:users")).toBe(false); + expect(isUrlSchemePath("archive.zip:dir/file.ts")).toBe(false); + expect(isUrlSchemePath("docs/compaction.md:100-170:raw")).toBe(false); + }); +}); diff --git a/packages/agent/test/normalize-tools-prune.test.ts b/packages/agent/test/normalize-tools-prune.test.ts new file mode 100644 index 000000000..ed480b013 --- /dev/null +++ b/packages/agent/test/normalize-tools-prune.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { normalizeTools } from "@oh-my-pi/pi-agent-core/agent-loop"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core/types"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; +import { type } from "arktype"; + +const toolSchema = type({ + path: type("string").describe("where to read"), + nested: type({ inner: type("string").describe("inner value") }).describe("a nested object"), +}); + +function makeTool(): AgentTool { + return { + name: "demo", + label: "Demo", + description: "top-level tool description", + parameters: toolSchema, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +/** Read the `properties` map off a wire JSON schema with runtime narrowing. */ +function wireProps(parameters: unknown): Record { + if (!parameters || typeof parameters !== "object" || !("properties" in parameters)) { + throw new Error("expected a wire schema with properties"); + } + const properties = parameters.properties; + if (!properties || typeof properties !== "object") throw new Error("expected a properties object"); + // Narrowed to a non-null object above; wire property maps are plain string-keyed objects. + const map = properties as Record; + return map; +} + +function hasField(parameters: unknown, field: string): boolean { + return field in wireProps(parameters); +} + +function fieldDescription(parameters: unknown, field: string): unknown { + const value = wireProps(parameters)[field]; + if (value && typeof value === "object" && "description" in value) return value.description; + return undefined; +} + +describe("normalizeTools — pruneDescriptions", () => { + it("keeps the top-level description when pruning is off", () => { + const tools = normalizeTools([makeTool()], false); + expect(tools?.[0]?.description).toBe("top-level tool description"); + }); + + it("empties the description and strips nested schema descriptions when pruning", () => { + const tools = normalizeTools([makeTool()], false, undefined, true); + const tool = tools?.[0]; + expect(tool?.description).toBe(""); + const wire = JSON.stringify(tool?.parameters); + expect(wire).not.toContain("where to read"); + expect(wire).not.toContain("inner value"); + expect(wire).not.toContain("a nested object"); + // Structure is preserved. + expect(hasField(tool?.parameters, "path")).toBe(true); + expect(hasField(tool?.parameters, "nested")).toBe(true); + }); + + it("injects the intent field WITHOUT a description when pruning", () => { + const tools = normalizeTools([makeTool()], true, undefined, true); + const params = tools?.[0]?.parameters; + expect(hasField(params, INTENT_FIELD)).toBe(true); + expect(fieldDescription(params, INTENT_FIELD)).toBeUndefined(); + // No description text rides the wire at all. + expect(JSON.stringify(params)).not.toContain("description"); + }); + + it("keeps the intent field description when not pruning", () => { + const tools = normalizeTools([makeTool()], true); + expect(typeof fieldDescription(tools?.[0]?.parameters, INTENT_FIELD)).toBe("string"); + }); +}); diff --git a/packages/agent/test/prompt-tools-loop.test.ts b/packages/agent/test/prompt-tools-loop.test.ts index db9548c36..eb636dbbf 100644 --- a/packages/agent/test/prompt-tools-loop.test.ts +++ b/packages/agent/test/prompt-tools-loop.test.ts @@ -102,6 +102,79 @@ describe("agentLoop with owned in-band tool calls", () => { expect(wireText(internalResult!)).toBe("echoed:hello world"); }); + it("prunes native tool descriptions from the wire when pruneToolDescriptions is set", async () => { + const toolSchema = type({ msg: type("string").describe("the message to echo") }); + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo a message back", + parameters: toolSchema, + async execute(_toolCallId, params) { + return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params }; + }, + }; + const captured: Context[] = []; + const mock = createMockModel({ + responses: [ + context => { + captured.push(context); + return { content: ["done"] }; + }, + ], + }); + const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] }; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + pruneToolDescriptions: true, + }; + await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result(); + + const wireTools = captured[0]?.tools; + expect(wireTools).toHaveLength(1); + expect(wireTools?.[0].name).toBe("echo"); + // Native tool calling: spec ships with no description text (top-level or nested). + expect(wireTools?.[0].description).toBe(""); + expect(JSON.stringify(wireTools?.[0].parameters)).not.toContain("the message to echo"); + }); + + it("keeps in-band tool descriptions for owned dialects even when pruneToolDescriptions is set", async () => { + const toolSchema = type({ msg: "string" }); + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo a message back", + parameters: toolSchema, + async execute(_toolCallId, params) { + return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params }; + }, + }; + const captured: Context[] = []; + const mock = createMockModel({ + responses: [ + context => { + captured.push(context); + return { content: ["done"] }; + }, + ], + }); + const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] }; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + dialect: "glm", + pruneToolDescriptions: true, + }; + await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result(); + + // Owned dialect carries the catalog in the prompt as text and sends no native + // tools, so pruning must not strip its descriptions. + expect(captured[0]?.tools).toBeUndefined(); + const promptSection = (captured[0]?.systemPrompt ?? []).join("\n"); + expect(promptSection).toContain(""); + expect(promptSection).toContain("Echo a message back"); + }); + it("executes Hermes/Qwen JSON tool calls when that dialect is selected", async () => { const echoArgs: Array<{ msg: string }> = []; const toolSchema = type({ msg: "string" }); diff --git a/packages/agent/test/soft-tool-requirement.test.ts b/packages/agent/test/soft-tool-requirement.test.ts new file mode 100644 index 000000000..c52550918 --- /dev/null +++ b/packages/agent/test/soft-tool-requirement.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "bun:test"; +import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop"; +import type { + AgentContext, + AgentLoopConfig, + AgentMessage, + AgentTool, + SoftToolRequirement, +} from "@oh-my-pi/pi-agent-core/types"; +import type { Message, ToolChoice } from "@oh-my-pi/pi-ai"; +import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { type } from "arktype"; +import { createUserMessage } from "./helpers"; + +function identityConverter(messages: AgentMessage[]): Message[] { + return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; +} + +const emptySchema = type({}); + +/** + * Build a host that gates `resolve` as a soft requirement while a preview is + * pending. The `resolve` tool clears it, mirroring coding-agent's pending + * invoker. `getToolChoice` returns the soft requirement (a peek — never + * consuming) until the preview resolves. + */ +function makeSoftResolveHarness() { + let pendingPreview = true; + let resolveRuns = 0; + let peekRuns = 0; + const reminder = createUserMessage("Resolve the pending preview."); + + const resolveTool: AgentTool> = { + name: "resolve", + label: "Resolve", + description: "Resolve the pending action", + parameters: emptySchema, + async execute() { + resolveRuns++; + pendingPreview = false; + return { content: [{ type: "text", text: "resolved" }], details: {} }; + }, + }; + const peekTool: AgentTool> = { + name: "peek", + label: "Peek", + description: "A non-resolve detour tool", + parameters: emptySchema, + async execute() { + peekRuns++; + return { content: [{ type: "text", text: "peeked" }], details: {} }; + }, + }; + + const getToolChoice = (): SoftToolRequirement | undefined => + pendingPreview ? { soft: true, id: "preview-1", toolName: "resolve", reminder: [reminder] } : undefined; + + return { + tools: [resolveTool, peekTool], + getToolChoice, + reminder, + get resolveRuns() { + return resolveRuns; + }, + get peekRuns() { + return peekRuns; + }, + }; +} + +describe("agentLoop soft tool requirement", () => { + it("never forces tool_choice when the model complies with the reminder", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "c1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + const messages = await stream.result(); + + // Compliant: resolve ran, and NO turn ever carried a forced tool_choice. + expect(h.resolveRuns).toBe(1); + expect(mock.calls).toHaveLength(2); + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + expect(mock.calls[1]?.options?.toolChoice).toBeUndefined(); + // The reminder was injected exactly once (on the new requirement id). + expect(messages.filter(m => m === h.reminder)).toHaveLength(1); + }); + + it("skips a detour batch and force-escalates resolve on the next turn", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + // Turn 1: model ignores the reminder and calls a detour tool. + { content: [{ type: "toolCall", id: "p1", name: "peek", arguments: {} }] }, + // Turn 2: forced to resolve. + { content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + const messages = await stream.result(); + + // The detour tool was NOT executed (its batch was skipped), resolve ran once. + expect(h.peekRuns).toBe(0); + expect(h.resolveRuns).toBe(1); + // Turn 1 ran with auto (no bust); turn 2 was force-escalated to resolve. + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + // The skipped peek call still produced a paired tool result (API pairing). + const peekResult = messages.find(m => m.role === "toolResult" && m.toolCallId === "p1"); + expect(peekResult).toBeDefined(); + }); + + it("does not yield while the requirement is unmet — escalates after a bare-text turn", async () => { + const h = makeSoftResolveHarness(); + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools }; + const mock = createMockModel({ + responses: [ + // Turn 1: model yields with text, no tool call. + { content: ["I'll get to it later."] }, + // Turn 2: forced to resolve. + { content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] }, + { content: ["done"] }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: h.getToolChoice, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + + // The loop did not stop after the bare-text turn; it forced resolve. + expect(h.resolveRuns).toBe(1); + expect(mock.calls.length).toBeGreaterThanOrEqual(2); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + }); + + it("leaves tool_choice untouched when no requirement is pending", async () => { + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] }; + const mock = createMockModel({ responses: [{ content: ["hi"] }] }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: () => undefined, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + expect(mock.calls).toHaveLength(1); + expect(mock.calls[0]?.options?.toolChoice).toBeUndefined(); + }); + + it("reuses the fetched hard tool choice across a Harmony retry instead of re-consuming getToolChoice", async () => { + const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] }; + // Consuming source: yields the forced choice once, then nothing — mirrors + // coding-agent's ToolChoiceQueue.nextToolChoice advancing its generator. + const queue: ToolChoice[] = [{ type: "tool", name: "resolve" }]; + const leak = "Some prose. analysis to=functions.edit code 大发官网"; + const mock = createMockModel({ + provider: "openai-codex", + responses: [{ content: [leak] }, { content: ["clean retry"] }], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + getToolChoice: () => queue.shift(), + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream); + for await (const _ of stream) { + // drain + } + + // The leaked attempt was retried; both calls carried the SAME forced choice + // and the consuming source advanced exactly once (no double-consume). + expect(mock.calls).toHaveLength(2); + expect(mock.calls[0]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" }); + expect(queue).toHaveLength(0); + }); +}); diff --git a/packages/agent/test/supersede-prune.test.ts b/packages/agent/test/supersede-prune.test.ts index f71f63526..dcc2c2b95 100644 --- a/packages/agent/test/supersede-prune.test.ts +++ b/packages/agent/test/supersede-prune.test.ts @@ -522,3 +522,136 @@ describe("pruneToolOutputs — small-result floor", () => { expect(resultMessage(bigResult).prunedAt).toBeDefined(); }); }); + +describe("cache-stable boundary — warm prefix protection", () => { + // (a) The primary bug: in pruneToolOutputs a superseded result bypasses the + // protect window and is rewritten at any depth. With the cache guard armed it + // must be left alone when it sits in the warm, already-sent cached prefix. + test("(a) deep superseded result is rewritten WITHOUT the guard but kept WITH it", () => { + const build = (): { entries: SessionEntry[]; result1: SessionMessageEntry; result2: SessionMessageEntry } => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const big = textEntry(BIG_TEXT, T0 + 500); // pushes result1 deep into the suffix + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // tail, supersedes result1 + return { entries: [call1, result1, big, call2, result2], result1, result2 }; + }; + const base = { + protectTokens: 1_000_000, // everything inside the (age) protect window + minimumSavings: 0, + protectedTools: [], + supersedeKey: readToolSupersedeKey, + }; + + // Legacy (no cacheWarmSuffixTokens): superseded result1 bypasses the window -> pruned. + const legacy = build(); + const legacyRun = pruneToolOutputs(legacy.entries, base); + expect(legacyRun.prunedCount).toBe(1); + expect(resultText(legacy.result1)).toBe(SUPERSEDED_NOTICE); + + // Guard armed: result1's all-message suffix (BIG_TEXT + call2 + result2) far + // exceeds the window, so it is part of the warm cached prefix and is kept. + const guarded = build(); + const guardedRun = pruneToolOutputs(guarded.entries, { ...base, cacheWarmSuffixTokens: 200 }); + expect(guardedRun.prunedCount).toBe(0); + expect(resultText(guarded.result1)).toBe(FILE_CONTENT); + expect(resultMessage(guarded.result1).prunedAt).toBeUndefined(); + }); + + test("(a) deep useless result is kept when the cache guard is armed", () => { + const [call1, result1] = uselessPair("search", NO_MATCH_TEXT, T0); + const big = textEntry(BIG_TEXT, T0 + 500); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, big, call2, result2]; + + const result = pruneToolOutputs(entries, { + protectTokens: 1_000_000, + minimumSavings: 0, + protectedTools: [], + pruneUseless: true, + cacheWarmSuffixTokens: 200, + }); + + expect(result.prunedCount).toBe(0); + expect(resultText(result1)).toBe(NO_MATCH_TEXT); + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + // (b) The legit case must still fire: a superseded copy in the cheap-to-recache + // tail (suffix below the window) is still reclaimed. + test("(b) tail-case superseded result still prunes with the guard armed", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + const result = pruneToolOutputs(entries, { + protectTokens: 1_000_000, + minimumSavings: 0, + protectedTools: [], + supersedeKey: readToolSupersedeKey, + cacheWarmSuffixTokens: 100_000, // result1's suffix is far below this -> tail -> prunable + }); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(SUPERSEDED_NOTICE); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + test("(b) supersede pass still prunes the tail case with keepBoundaryId set", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + const result = pruneSupersededToolResults(entries, cfg({ keepBoundaryId: call1.id, now: T0 + 1_000 })); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(SUPERSEDED_NOTICE); + expect(resultText(result2)).toBe(FILE_CONTENT); + }); + + // (c) Entries before firstKeptEntryId are summarized away — never sent — so no + // pass may mutate them, not even the idle full-flush. + test("(c) idle flush never mutates entries before keepBoundaryId", () => { + const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary + const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2 + const [call3, result3] = readPair("src/foo.ts", FILE_CONTENT, T0 + 2_000); // idx 4,5 — latest + const big = textEntry(BIG_TEXT, T0 + 3_000); + const entries: SessionEntry[] = [call1, result1, call2, result2, call3, result3, big]; + + // Cold cache (idle > threshold) with suffixTokenLimit 0: only the idle path can fire. + const result = pruneSupersededToolResults( + entries, + cfg({ + keepBoundaryId: call2.id, + suffixTokenLimit: 0, + idleFlushMs: 30 * 60_000, + now: T0 + 3_000 + 31 * 60_000, + }), + ); + + expect(result.prunedCount).toBe(1); + expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultText(result2)).toBe(SUPERSEDED_NOTICE); // at/after boundary -> flushed + expect(resultText(result3)).toBe(FILE_CONTENT); // latest -> kept + }); + + test("(c) pruneToolOutputs never mutates entries before keepBoundaryId", () => { + const [call1, result1] = readPair("src/old.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary + const [call2, result2] = readPair("src/new.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2 + const entries: SessionEntry[] = [call1, result1, call2, result2]; + + // protectTokens 0 -> the age path would prune both; the window is wide so the + // guard does not protect either; only keepBoundaryId shields result1. + pruneToolOutputs(entries, { + protectTokens: 0, + minimumSavings: 0, + protectedTools: [], + keepBoundaryId: call2.id, + cacheWarmSuffixTokens: 1_000_000, + }); + + expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched + expect(resultMessage(result1).prunedAt).toBeUndefined(); + expect(resultMessage(result2).prunedAt).toBeDefined(); // at/after boundary, in tail -> pruned + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9f5f40a38..f656f7f1d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,54 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Added + +- Added authentication broker discovery to sync credentials between local SQLite and remote state + ### Fixed +- Added automatic fallback for unsupported OpenAI reasoning effort levels +- Improved reliability when handling invalid reasoning parameter errors across OpenAI-compatible APIs +- Fixed OpenAI-compatible Chat Completions, Responses, and Azure Responses requests to retry once with the nearest provider-supported reasoning effort when an endpoint rejects `xhigh`/`minimal`-style effort values. + +## [16.1.0] - 2026-06-19 + +### Added + +- Added utility functions to strip schema descriptions for optimized LLM context usage + +## [16.0.10] - 2026-06-18 + +### Added + +- Replaced the old legacy XML-ish `pi` owned tool-calling dialect with the new sigil-delimited format (`§` call header with inline `key=value` scalars, `«…»` verbatim body fence for the dominant string argument, `¤` reasoning, `‡‡` tool result) using single-token markers that never occur in source code. Verbatim fences escalate Markdown-style (`««…»»`) so re-rendered history never collides with payload content, and the scanner gates a bare `§` on an exact known-tool name to avoid swallowing prose. Round-trips and streams through the existing scanner contract at ~46% fewer tokens than the legacy format on typical calls; selectable via `tools.format` or `PI_DIALECT=pi`. + +### Changed + +- Updated `pi` dialect formatting to use a token-frugal, sigil-delimited format (`§`, `¤`, `‡‡`) +- Updated `pi` dialect body fences to automatically escalate when content contains fence markers +- Changed `pi` dialect tool results response format to `‡‡` blocks + +### Fixed + +- Fixed Bedrock application inference profile ARNs to route requests to the ARN's region instead of the default Bedrock runtime region. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004)) + +## [16.0.9] - 2026-06-18 + +### Fixed + +- Fixed OAuth login replacing all other active accounts for the same provider, allowing multiple OAuth accounts to coexist concurrently. +- Fixed legacy `api_key` credentials not being replaced/disabled atomically upon upgrading to OAuth login. +- Fixed a logic issue where AuthStorage lost session-to-credential stickiness upon CLI restarts, causing cold-starts for server-side prompt cache (KV cache) and wasting tokens. +- Fixed GitHub Copilot Responses requests rejecting image inputs that carry the `detail: "original"` hint with an HTTP 400 by degrading the hint to `"auto"` for hosts that do not support it; other hosts still preserve native-resolution frames (snapcompact). ([#2822](https://github.com/can1357/oh-my-pi/issues/2822)) + +## [16.0.8] - 2026-06-18 + +### Fixed + +- Improved reliability of auth-broker snapshot loading by implementing a robust manual schema check - Fixed MCP tool argument validation to drop optional empty-string parameters before schema validation, matching the existing optional null handling and avoiding pattern/type failures for omitted model-filled fields. ([#2981](https://github.com/can1357/oh-my-pi/issues/2981)) - Fixed API-key credential replacement to hard-delete superseded disabled `api_key` rows so `auth_credentials` does not grow indefinitely after key rotation. ([#2941](https://github.com/can1357/oh-my-pi/issues/2941)) - Fixed Cursor provider streaming to close text blocks before tool calls so post-tool text opens a new content block and TUI transcript cards render inline instead of grouped near the bottom. ([#2924](https://github.com/can1357/oh-my-pi/issues/2924)) diff --git a/packages/ai/package.json b/packages/ai/package.json index ca7ac9197..f16ab2169 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "16.0.7", + "version": "16.1.2", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -40,6 +40,7 @@ "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:", "arktype": "catalog:", "partial-json": "catalog:", "zod": "catalog:" diff --git a/packages/ai/src/auth-broker/discover.ts b/packages/ai/src/auth-broker/discover.ts new file mode 100644 index 000000000..857fcc904 --- /dev/null +++ b/packages/ai/src/auth-broker/discover.ts @@ -0,0 +1,217 @@ +/** + * Broker-aware auth-storage discovery used by both the coding-agent runtime and + * the catalog model generator. Keeps the precedence logic (env → config.yml → + * token file → local SQLite) in one place so build-time tooling sees the same + * credentials as the TUI. + */ +import * as path from "node:path"; +import { + getAgentDbPath, + getAgentDir, + getAuthBrokerSnapshotCachePath, + getConfigRootDir, + isEnoent, + logger, +} from "@oh-my-pi/pi-utils"; +import { YAML } from "bun"; +import { AuthStorage } from "../auth-storage"; +import { AuthBrokerClient } from "./client"; +import { RemoteAuthCredentialStore } from "./remote-store"; +import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache"; +import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "./types"; + +export interface AuthBrokerClientConfig { + url: string; + token: string; +} + +export interface ResolveAuthBrokerConfigOptions { + agentDir?: string; + configValueResolver?: (config: string) => Promise; +} + +export interface DiscoverAuthStorageOptions { + agentDir?: string; + configValueResolver?: (config: string) => Promise; + cachePath?: string; + sourceLabel?: string; +} + +/** Path to the local bearer token file. Created by `omp auth-broker token`. */ +export function getAuthBrokerTokenFilePath(): string { + return path.join(getConfigRootDir(), "auth-broker.token"); +} + +/** + * Default resolver for config values: checks `process.env` first, then treats + * the value as a literal. Does NOT execute `!command` syntax; such values are + * left unresolved so the caller can fall back to the token file. + */ +async function defaultResolveConfigValue(config: string): Promise { + if (config.startsWith("!")) return undefined; + const envValue = process.env[config]; + return envValue || config; +} + +async function readTokenFile(): Promise { + try { + const raw = await Bun.file(getAuthBrokerTokenFilePath()).text(); + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + if (isEnoent(err)) return null; + logger.warn("auth-broker token file unreadable", { error: String(err) }); + return null; + } +} + +interface ConfigSnapshot { + url?: string; + token?: string; +} + +async function readConfigYaml(agentDir: string): Promise { + const configPath = path.join(agentDir, "config.yml"); + try { + const raw = await Bun.file(configPath).text(); + const parsed = YAML.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const record = parsed as Record; + const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined; + const token = + typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined; + return { url, token }; + } catch (err) { + if (isEnoent(err)) return {}; + logger.warn("auth-broker config.yml unreadable", { error: String(err) }); + return {}; + } +} + +function resolveSnapshotTtlMs(): number { + const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; + if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS; + const value = raw.trim(); + if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS; + const ttlMs = Number(value); + if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs; + logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw }); + return DEFAULT_SNAPSHOT_CACHE_TTL_MS; +} + +/** + * Resolve broker connection configuration using the same precedence as the TUI: + * + * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars. + * 2. `auth.broker.url` / `auth.broker.token` in `/config.yml`. + * 3. `/auth-broker.token` file (paired with a URL from env/config). + * + * Returns `null` when no broker URL is configured — callers should fall back to + * the local SQLite store. Throws when a URL is configured but no token is + * available, matching the TUI behavior. + */ +export async function resolveAuthBrokerConfig( + options: ResolveAuthBrokerConfigOptions = {}, +): Promise { + const agentDir = options.agentDir ?? getAgentDir(); + const resolveConfig = options.configValueResolver ?? defaultResolveConfigValue; + + const envUrl = process.env.OMP_AUTH_BROKER_URL; + const envToken = process.env.OMP_AUTH_BROKER_TOKEN; + + let url = envUrl && envUrl.length > 0 ? envUrl : undefined; + let configToken: string | undefined; + if (!url || !envToken) { + const fromConfig = await readConfigYaml(agentDir); + if (!url && fromConfig.url) { + const resolved = await resolveConfig(fromConfig.url); + if (resolved && resolved.length > 0) url = resolved; + } + if (fromConfig.token) { + const resolved = await resolveConfig(fromConfig.token); + if (resolved && resolved.length > 0) configToken = resolved; + } + } + if (!url) return null; + + const token = + (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined; + if (!token) { + throw new Error( + `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` + + `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`, + ); + } + return { url, token }; +} + +/** + * Create an AuthStorage instance, using the broker when configured and falling + * back to the local SQLite store otherwise. This is the single source of truth + * for the TUI and the catalog generator. + */ +export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise { + const agentDir = options.agentDir ?? getAgentDir(); + const brokerConfig = await resolveAuthBrokerConfig({ + agentDir, + configValueResolver: options.configValueResolver, + }); + + if (brokerConfig) { + const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token }); + const cachePath = options.cachePath ?? getAuthBrokerSnapshotCachePath(); + const ttlMs = resolveSnapshotTtlMs(); + const persist = + ttlMs > 0 + ? (snapshot: SnapshotResponse): void => { + void writeAuthBrokerSnapshotCache({ + path: cachePath, + token: brokerConfig.token, + url: brokerConfig.url, + snapshot, + }).catch(error => { + logger.debug("auth-broker snapshot cache write failed", { error: String(error) }); + }); + } + : undefined; + + let initialSnapshot: SnapshotResponse | undefined; + if (ttlMs > 0) { + initialSnapshot = + (await readAuthBrokerSnapshotCache({ + path: cachePath, + token: brokerConfig.token, + url: brokerConfig.url, + ttlMs, + }).catch(error => { + logger.debug("auth-broker snapshot cache read failed", { error: String(error) }); + return null; + })) ?? undefined; + } + if (!initialSnapshot) { + const initialResult = await client.fetchSnapshot(); + if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot"); + initialSnapshot = initialResult.snapshot; + persist?.(initialSnapshot); + } + const store = new RemoteAuthCredentialStore({ + client, + initialSnapshot, + onSnapshot: persist, + }); + const storage = new AuthStorage(store, { + configValueResolver: options.configValueResolver, + sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}`, + }); + await storage.reload(); + return storage; + } + + const dbPath = getAgentDbPath(agentDir); + const storage = await AuthStorage.create(dbPath, { + configValueResolver: options.configValueResolver, + sourceLabel: options.sourceLabel ?? `local ${dbPath}`, + }); + await storage.reload(); + return storage; +} diff --git a/packages/ai/src/auth-broker/index.ts b/packages/ai/src/auth-broker/index.ts index 189d377c5..22497acbb 100644 --- a/packages/ai/src/auth-broker/index.ts +++ b/packages/ai/src/auth-broker/index.ts @@ -1,4 +1,5 @@ export * from "./client"; +export * from "./discover"; export * from "./refresher"; export * from "./remote-store"; export * from "./server"; diff --git a/packages/ai/src/auth-broker/snapshot-cache.ts b/packages/ai/src/auth-broker/snapshot-cache.ts index 12544ea45..84587801c 100644 --- a/packages/ai/src/auth-broker/snapshot-cache.ts +++ b/packages/ai/src/auth-broker/snapshot-cache.ts @@ -9,9 +9,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { SnapshotResponse } from "./types"; -import { snapshotResponseSchema } from "./wire-schemas"; const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS" const VERSION = 1; @@ -40,6 +38,25 @@ export interface WriteAuthBrokerSnapshotCacheOptions { snapshot: SnapshotResponse; } +/** + * Cheap structural guard for a decrypted cache payload. The bytes are already + * AES-256-GCM authenticated, so this only rejects shape/version drift (a cache + * written by a different omp build, or a buggy write) — not tampering. A + * mismatch returns null so the caller refetches a fresh snapshot. + */ +function isSnapshotResponseShape(v: unknown): v is SnapshotResponse { + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return ( + typeof o.generation === "number" && + typeof o.generatedAt === "number" && + typeof o.serverNowMs === "number" && + typeof o.refresher === "object" && + o.refresher !== null && + Array.isArray(o.credentials) + ); +} + export async function readAuthBrokerSnapshotCache( opts: ReadAuthBrokerSnapshotCacheOptions, ): Promise { @@ -56,12 +73,11 @@ export async function readAuthBrokerSnapshotCache( const plaintext = await decryptCachePayload(data, opts.token, opts.url); if (!plaintext) return null; const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext)); - const result = snapshotResponseSchema(parsed); - if (result instanceof type.errors) { + if (!isSnapshotResponseShape(parsed)) { logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path }); return null; } - const snapshot = result; + const snapshot = parsed; const now = opts.now?.() ?? Date.now(); if (now - snapshot.generatedAt > opts.ttlMs) return null; return snapshot; diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index bf638b797..87e2ced77 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -1352,6 +1352,19 @@ export class AuthStorage { const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map(); sessionMap.set(sessionId, { type, index }); this.#sessionLastCredential.set(provider, sessionMap); + + try { + const credentialId = this.#getStoredCredentials(provider)[index]?.id; + if (credentialId !== undefined) { + const cacheKey = `session:sticky:${provider}:${sessionId}`; + const cacheValue = JSON.stringify({ type, index, credentialId }); + // Expires in 30 days + const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; + this.#store.setCache(cacheKey, cacheValue, expiresAtSec); + } + } catch (err) { + logger.debug("Failed to write session sticky credential to persistent store cache", { err }); + } } /** Retrieves the last credential used by a session. */ @@ -1360,17 +1373,59 @@ export class AuthStorage { sessionId: string | undefined, ): { type: AuthCredential["type"]; index: number } | undefined { if (!sessionId) return undefined; - return this.#sessionLastCredential.get(provider)?.get(sessionId); + let sessionMap = this.#sessionLastCredential.get(provider); + if (sessionMap?.has(sessionId)) { + return sessionMap.get(sessionId); + } + try { + const cacheKey = `session:sticky:${provider}:${sessionId}`; + const raw = this.#store.getCache(cacheKey); + if (raw) { + const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number }; + + if (val.credentialId !== undefined) { + const stored = this.#getStoredCredentials(provider); + const actualIndex = stored.findIndex(entry => entry.id === val.credentialId); + if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) { + this.#store.setCache(cacheKey, "", 0); + return undefined; + } + val.index = actualIndex; + } else { + // Fallback: drop unsafe index-only cache rows to prevent wrong-account routing + this.#store.setCache(cacheKey, "", 0); + return undefined; + } + + if (!sessionMap) { + sessionMap = new Map(); + this.#sessionLastCredential.set(provider, sessionMap); + } + const sessionVal = { type: val.type, index: val.index }; + sessionMap.set(sessionId, sessionVal); + return sessionVal; + } + } catch (err) { + logger.debug("Failed to read session sticky credential from persistent store cache", { err }); + } + return undefined; } /** Clears the last credential used by a session for a provider. */ #clearSessionCredential(provider: string, sessionId: string | undefined): void { if (!sessionId) return; const sessionMap = this.#sessionLastCredential.get(provider); - if (!sessionMap) return; - sessionMap.delete(sessionId); - if (sessionMap.size === 0) { - this.#sessionLastCredential.delete(provider); + if (sessionMap) { + sessionMap.delete(sessionId); + if (sessionMap.size === 0) { + this.#sessionLastCredential.delete(provider); + } + } + try { + const cacheKey = `session:sticky:${provider}:${sessionId}`; + this.#store.setCache(cacheKey, "", 0); + } catch (err) { + logger.debug("Failed to clear session sticky credential from persistent store cache", { err }); } } @@ -1549,6 +1604,17 @@ export class AuthStorage { return rows; } + async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise { + const stored = this.#store.upsertAuthCredentialRemote + ? await this.#store.upsertAuthCredentialRemote(provider, credential) + : this.#store.upsertAuthCredentialForProvider(provider, credential); + this.#setStoredCredentials( + provider, + stored.map(entry => ({ id: entry.id, credential: entry.credential })), + ); + this.#resetProviderAssignments(provider); + } + /** * Remove credential for a provider. */ @@ -1786,10 +1852,10 @@ export class AuthStorage { return; } const newCredential: OAuthCredential = { type: "oauth", ...result }; - // Use set() instead of #upsertOAuthCredential to replace ALL existing credentials - // (including legacy api_key rows from older versions) with the new OAuth credential. - // This ensures getApiKey() doesn't match an old api_key row before the new OAuth row. - await this.set(def.storeCredentialsAs ?? provider, newCredential); + // Use #upsertOAuthCredential to upsert the new credential. + // Any legacy api_key rows from older versions will be cleaned up so they do not + // shadow the new OAuth row, while preserving other active OAuth credentials. + await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential); } /** @@ -4916,6 +4982,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { identityKey: resolveRowCredentialIdentityKey(providerName, row), })); + if (item.type === "oauth") { + for (const row of existing) { + if (row.credential && row.credential.type === "api_key") { + this.#deleteStmt.run("replaced by oauth login", row.id); + } + } + } + let targetId: number | null = null; for (const row of existing) { if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; diff --git a/packages/ai/src/dialect/owned-stream.ts b/packages/ai/src/dialect/owned-stream.ts index 3140d8eb5..b66c0d31d 100644 --- a/packages/ai/src/dialect/owned-stream.ts +++ b/packages/ai/src/dialect/owned-stream.ts @@ -19,7 +19,7 @@ const RESPONSE_OPEN_TOKENS: Record = { minimax: ["", ""], deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"], harmony: ["<|start|>functions."], - pi: [""], + pi: ["‡‡"], qwen3: [""], gemini: ["```tool_outputs"], gemma: ["<|tool_response>"], diff --git a/packages/ai/src/dialect/pi.md b/packages/ai/src/dialect/pi.md index f1a99c18e..506368713 100644 --- a/packages/ai/src/dialect/pi.md +++ b/packages/ai/src/dialect/pi.md @@ -1,57 +1,55 @@ ## Format guide -A tool call is a `…` block (or self-closing ``) written as plain assistant text; arguments are given as tag attributes, child elements, or a verbatim inline body. +A tool call begins with `§` immediately followed by the function NAME (start each call on its own line). Scalar arguments follow on the same line as `key=value` pairs; a single large or multi-line string argument goes in a verbatim body fenced by `«…»` right after the header. + +Scalar-only call (the line ends the call): ```text - +§read path=src/a.ts offset=50 limit=200 ``` -Objects and arrays use child elements, repeating an element for each array item: +Call with a verbatim body — everything between `«` and `»` is taken literally, no quoting or escaping: ```text - - -4 -alpha -beta - - -``` - -A single string argument can fill the body directly: - -```text - +§edit path=src/server/auth.ts« *** Begin Patch -... +*** Update File: src/server/auth.ts +@@ class AuthService +- login(user) { ++ async login(user, opts) { *** End Patch - +» ``` -Tool results arrive as response blocks, read in call order: +Argument values: + +- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`). +- Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`). +- Arrays and objects are inline JSON (`paths=["src","test"]`). +- The body fence holds the call's first long/multi-line string parameter; its key is implied, never written. + +Private reasoning goes in a `¤…¤` block before your calls: ```text - -verbatim tool result - -``` - -Private reasoning goes in a `…` block before your calls: - -```text - +¤ brief reasoning - +¤ +``` + +Tool results arrive in `‡‡…‡‡` blocks, read in call order: + +```text +‡‡ +verbatim tool result +‡‡ ``` ## Rules -- `NAME` must match a listed function; never wrap calls in JSON or fences. -- Use attributes only for top-level scalars; put objects, arrays, and long strings in child elements. -- Strings are verbatim (no quotes, no entity escaping); numbers, booleans, and null are JSON literals. -- An object opens a child block whose scalar subfields may also be attributes; an array repeats its element once per item. -- The inline body fills the first unset string-typed parameter and may contain any raw text except ``. -- Emit parallel calls as consecutive blocks. NEVER invent call ids; results are positional. -- Private reasoning goes in a `…` block before your calls; NEVER put calls inside it. -- Read each `` in call order. NEVER emit `` yourself. +- `NAME` MUST match a listed function; never wrap calls in JSON or fences. +- Put each scalar argument once as `key=value`; reserve the `«…»` body for the one dominant string argument (file contents, patches, commands, queries). +- Body text is verbatim — include no surrounding quotes. If the body itself contains `»`, widen BOTH guillemet fences equally (`««…»»`, `«««…»»»`). +- Emit parallel calls as consecutive `§…` blocks. NEVER invent call ids; results are positional. +- Private reasoning goes in a `¤…¤` block before your calls; NEVER put calls inside it, and keep a literal `¤` out of the reasoning text. +- Read each `‡‡…‡‡` result in call order. NEVER emit a `‡‡` block yourself. - After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT. diff --git a/packages/ai/src/dialect/pi.ts b/packages/ai/src/dialect/pi.ts index ed6535d46..acb1ca11b 100644 --- a/packages/ai/src/dialect/pi.ts +++ b/packages/ai/src/dialect/pi.ts @@ -1,19 +1,8 @@ import type { Message, ToolCall } from "../types"; import type { ToolArgShape } from "./coercion"; -import { - buildArgShapes, - coerceValue, - collectSchemaTypes, - getArrayItemSchema, - getObjectProperties, - isArraySchema, - isObjectSchema, - isStringOnlySchema, - mintToolCallId, - partialSuffixOverlapAny, -} from "./coercion"; +import { buildArgShapes, coerceValue, isStringOnlySchema, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import dialectPrompt from "./pi.md" with { type: "text" }; -import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering"; +import { renderChatMlTranscript, stringifyJson } from "./rendering"; import type { DialectDefinition, DialectRenderOptions, @@ -23,57 +12,66 @@ import type { InbandScannerOptions, } from "./types"; -const CALL_PREFIX = "` / `` (3 tokens + the repeated name); +// - scalar arguments inline as `key=value` (the key appears once) rather than +// `value` (key twice + four bracket tokens); +// - the dominant string argument fills a verbatim body fence, dropping its key +// entirely and needing no escaping for code/patches. +// +// Verbatim fences (body `«»`, result `‡‡`) escalate Markdown/raw-string style: +// when the content contains the closer, the renderer widens the fence (`««…»»`, +// `‡‡‡…‡‡‡`) so re-rendered history can never collide with payload content. + +const CALL_SIGIL = "§"; +const FENCE_OPEN = "«"; +const FENCE_CLOSE = "»"; +const THINK_SIGIL = "¤"; +const RESULT_FENCE = "‡"; +const OUTSIDE_TAGS = [CALL_SIGIL, THINK_SIGIL] as const; +const CALL_TAGS = [CALL_SIGIL] as const; const NAME_START = /[A-Za-z_]/; const NAME_CHAR = /[A-Za-z0-9_-]/; const EMPTY_STRING_ARGS: ReadonlySet = new Set(); type ScannerState = "outside" | "body" | "thinking"; -type BodyMode = "undecided" | "inline" | "members"; -type RawAttribute = { name: string; value: string | true }; - -type OpenTag = { - name: string; - rawAttrs: RawAttribute[]; - selfClosing: boolean; - end: number; -}; - -type MembersResult = { - ok: boolean; - value: Record; - next: number; -}; - -type ValueResult = { - ok: boolean; - value: unknown; - next: number; -}; +type HeaderEnd = + | { kind: "fence"; index: number } + | { kind: "newline"; index: number } + | { kind: "eof"; index: number } + | { kind: "incomplete" }; export class PiNativeInbandScanner implements InbandScanner { #buffer = ""; #state: ScannerState = "outside"; - #bodyMode: BodyMode = "undecided"; #id = ""; #name = ""; #args: Record = {}; - #inlineKey = ""; - #inlineValue = ""; - #inlineLeading = false; + #bodyKey = ""; + #bodyValue = ""; + #bodyLeading = false; + #closeMarker = ""; #rawBlock = ""; + #thinking = ""; readonly #argShapes: Map; readonly #stringArgs: (toolName: string) => ReadonlySet; - #thinking = ""; + readonly #knowsTools: boolean; readonly #parseThinking: boolean; constructor(options: InbandScannerOptions = {}) { this.#argShapes = buildArgShapes(options.tools); + this.#knowsTools = this.#argShapes.size > 0; this.#stringArgs = options.stringArgs ?? (toolName => this.#argShapes.get(toolName)?.stringArgs ?? EMPTY_STRING_ARGS); this.#parseThinking = options.parseThinking !== false; @@ -96,37 +94,19 @@ export class PiNativeInbandScanner implements InbandScanner { if (!this.#consumeOutside(events, final)) break; continue; } - if (this.#state === "thinking") { if (!this.#consumeThinking(events, final)) break; continue; } - - if (this.#bodyMode === "undecided") { - const mode = this.#classifyBody(final); - if (!mode) break; - this.#bodyMode = mode; - if (mode === "inline") { - this.#inlineKey = this.#inlineTargetKey() ?? "input"; - this.#inlineValue = ""; - this.#inlineLeading = true; - } - } - - if (this.#bodyMode === "inline") { - if (!this.#consumeInline(events, final)) break; - continue; - } - - if (!this.#consumeMembers(events, final)) break; + if (!this.#consumeBody(events, final)) break; } if (final && this.#state === "thinking") this.#endThinking(events); return events; } #consumeOutside(events: InbandScanEvent[], final: boolean): boolean { - const call = this.#buffer.indexOf(CALL_PREFIX); - const think = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1; + const call = this.#buffer.indexOf(CALL_SIGIL); + const think = this.#parseThinking ? this.#buffer.indexOf(THINK_SIGIL) : -1; let start = call; let isThink = false; if (think !== -1 && (start === -1 || think < start)) { @@ -148,49 +128,174 @@ export class PiNativeInbandScanner implements InbandScanner { } if (isThink) { - this.#buffer = this.#buffer.slice(THINK_OPEN.length); + this.#buffer = this.#buffer.slice(THINK_SIGIL.length); this.#thinking = ""; events.push({ type: "thinkingStart" }); this.#state = "thinking"; return true; } - const tagEnd = findTagEnd(this.#buffer, 0); - if (tagEnd === -1) { - if (final) this.#buffer = ""; - return false; - } + return this.#beginCall(events, final); + } - const tag = parseCallOpenTag(this.#buffer); - if (!tag) { - events.push({ type: "text", text: this.#buffer[0] ?? "" }); - this.#buffer = this.#buffer.slice(1); - return true; - } + // Buffer starts with `§`. Resolve the tool name, then the header terminator. + // Returns false to wait for more input (call still streaming in). + #beginCall(events: InbandScanEvent[], final: boolean): boolean { + const nameStart = CALL_SIGIL.length; + if (nameStart >= this.#buffer.length && !final) return false; // just `§` so far + let nameEnd = nameStart; + if (!isNameStart(this.#buffer[nameEnd])) return this.#rejectSigil(events); + nameEnd++; + while (nameEnd < this.#buffer.length && isNameChar(this.#buffer[nameEnd])) nameEnd++; + if (nameEnd >= this.#buffer.length && !final) return false; // name may continue - this.#beginCall(tag, events); - this.#buffer = this.#buffer.slice(tag.end); - if (tag.selfClosing) { - events.push({ - type: "toolEnd", - id: this.#id, - name: this.#name, - arguments: this.#args, - rawBlock: this.#rawBlock, - }); - this.#reset(); - return true; - } + const name = this.#buffer.slice(CALL_SIGIL.length, nameEnd); + // Guard against `§` in prose: only claim a known tool when schemas exist. + if (this.#knowsTools && !this.#argShapes.has(name)) return this.#rejectSigil(events); - this.#state = "body"; - this.#bodyMode = "undecided"; + const header = findHeaderEnd(this.#buffer, nameEnd); + if (header.kind === "fence") { + let runEnd = header.index; + while (runEnd < this.#buffer.length && this.#buffer[runEnd] === FENCE_OPEN) runEnd++; + if (runEnd >= this.#buffer.length && !final) return false; // fence run may grow + return this.#startCall(events, name, nameEnd, header.index, "fence", runEnd - header.index); + } + if (header.kind === "newline") { + return this.#startCall(events, name, nameEnd, header.index, "newline", 0); + } + // "eof"/"incomplete": the header may still be streaming in — only a scalar + // call with no trailing newline at true end-of-stream finalizes here. + if (!final) return false; + return this.#startCall(events, name, nameEnd, this.#buffer.length, "eof", 0); + } + + // `§` not followed by a known tool name is prose — surface it as literal text. + #rejectSigil(events: InbandScanEvent[]): boolean { + events.push({ type: "text", text: CALL_SIGIL }); + this.#buffer = this.#buffer.slice(CALL_SIGIL.length); return true; } - #consumeThinking(events: InbandScanEvent[], final: boolean): boolean { - const close = this.#buffer.indexOf(THINK_CLOSE); + #startCall( + events: InbandScanEvent[], + name: string, + argsStart: number, + headerEnd: number, + kind: "fence" | "newline" | "eof", + fenceLen: number, + ): boolean { + const shape = this.#argShapes.get(name); + this.#id = mintToolCallId(); + this.#name = name; + this.#args = parseHeaderArgs(this.#buffer.slice(argsStart, headerEnd), shape?.properties ?? {}); + events.push({ type: "toolStart", id: this.#id, name: this.#name }); + + if (kind === "fence") { + const fenceEnd = headerEnd + fenceLen; + this.#rawBlock = this.#buffer.slice(0, fenceEnd); + this.#closeMarker = FENCE_CLOSE.repeat(fenceLen); + this.#bodyKey = this.#inlineTargetKey() ?? "input"; + this.#bodyValue = ""; + this.#bodyLeading = true; + this.#buffer = this.#buffer.slice(fenceEnd); + this.#state = "body"; + return true; + } + + this.#rawBlock = this.#buffer.slice(0, headerEnd); + events.push({ + type: "toolEnd", + id: this.#id, + name: this.#name, + arguments: this.#args, + rawBlock: this.#rawBlock, + }); + let next = headerEnd; + if (kind === "newline") { + if (this.#buffer[next] === "\r") next++; + if (this.#buffer[next] === "\n") next++; + } + this.#buffer = this.#buffer.slice(next); + this.#reset(); + return true; + } + + #consumeBody(events: InbandScanEvent[], final: boolean): boolean { + this.#stripBodyLeading(final); + const close = this.#buffer.indexOf(this.#closeMarker); if (close === -1) { - const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_CLOSE]); + if (final) { + this.#reset(); + this.#buffer = ""; + return false; + } + const overlap = partialSuffixOverlapAny(this.#buffer, [this.#closeMarker]); + let hold = Math.max(this.#closeMarker.length, overlap); + if (overlap > 0) { + const beforeOverlap = this.#buffer.length - overlap - 1; + if (this.#buffer[beforeOverlap] === "\n") { + hold = Math.max(hold, overlap + 1); + if (this.#buffer[beforeOverlap - 1] === "\r") hold = Math.max(hold, overlap + 2); + } + } + const emitLength = this.#buffer.length - hold; + if (emitLength > 0) { + const delta = this.#buffer.slice(0, emitLength); + this.#rawBlock += delta; + this.#emitBodyDelta(delta, events); + this.#buffer = this.#buffer.slice(emitLength); + } + return false; + } + + const rawDelta = this.#buffer.slice(0, close); + this.#rawBlock += rawDelta + this.#closeMarker; + let delta = rawDelta; + if (delta.endsWith("\r\n")) delta = delta.slice(0, -2); + else if (delta.endsWith("\n")) delta = delta.slice(0, -1); + this.#emitBodyDelta(delta, events); + this.#args[this.#bodyKey] = this.#bodyValue; + events.push({ + type: "toolEnd", + id: this.#id, + name: this.#name, + arguments: this.#args, + rawBlock: this.#rawBlock, + }); + this.#buffer = this.#buffer.slice(close + this.#closeMarker.length); + this.#reset(); + return true; + } + + #stripBodyLeading(final: boolean): void { + if (!this.#bodyLeading) return; + if (this.#buffer.length === 0) return; + if (this.#buffer[0] === "\r") { + if (this.#buffer.length === 1 && !final) return; + if (this.#buffer[1] === "\n") { + this.#rawBlock += this.#buffer.slice(0, 2); + this.#buffer = this.#buffer.slice(2); + } + this.#bodyLeading = false; + return; + } + if (this.#buffer[0] === "\n") { + this.#rawBlock += this.#buffer[0]; + this.#buffer = this.#buffer.slice(1); + } + this.#bodyLeading = false; + } + + #emitBodyDelta(delta: string, events: InbandScanEvent[]): void { + if (delta.length === 0) return; + this.#bodyValue += delta; + events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#bodyKey, delta }); + } + + #consumeThinking(events: InbandScanEvent[], final: boolean): boolean { + const close = this.#buffer.indexOf(THINK_SIGIL); + if (close === -1) { + const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_SIGIL]); this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events); this.#buffer = this.#buffer.slice(this.#buffer.length - hold); if (final) { @@ -200,7 +305,7 @@ export class PiNativeInbandScanner implements InbandScanner { return false; } this.#emitThinking(this.#buffer.slice(0, close), events); - this.#buffer = this.#buffer.slice(close + THINK_CLOSE.length); + this.#buffer = this.#buffer.slice(close + THINK_SIGIL.length); this.#endThinking(events); this.#state = "outside"; return true; @@ -218,151 +323,8 @@ export class PiNativeInbandScanner implements InbandScanner { this.#state = "outside"; } - #beginCall(tag: OpenTag, events: InbandScanEvent[]): void { - this.#id = mintToolCallId(); - this.#name = tag.name; - this.#args = coerceAttributes(tag.rawAttrs, this.#shape()?.properties ?? {}); - this.#inlineKey = ""; - this.#inlineValue = ""; - this.#inlineLeading = false; - this.#rawBlock = this.#buffer.slice(0, tag.end); - events.push({ type: "toolStart", id: this.#id, name: this.#name }); - } - - #classifyBody(final: boolean): BodyMode | undefined { - const closeTag = this.#closeTag(); - const first = skipWhitespace(this.#buffer, 0); - const close = this.#buffer.indexOf(closeTag); - const inlineKey = this.#inlineTargetKey(); - - if (close !== -1 && first >= close) return inlineKey ? "inline" : "members"; - if (first >= this.#buffer.length) return undefined; - - const fromFirst = this.#buffer.slice(first); - if (!final && closeTag.startsWith(fromFirst)) return undefined; - - if (this.#buffer[first] !== "<") return inlineKey ? "inline" : "members"; - if (this.#buffer.startsWith(closeTag, first)) return inlineKey ? "inline" : "members"; - if (this.#buffer.startsWith(" 0) { - const beforeOverlap = this.#buffer.length - overlap - 1; - if (this.#buffer[beforeOverlap] === "\n") { - hold = Math.max(hold, overlap + 1); - if (this.#buffer[beforeOverlap - 1] === "\r") hold = Math.max(hold, overlap + 2); - } - } - const emitLength = this.#buffer.length - hold; - if (emitLength > 0) { - const delta = this.#buffer.slice(0, emitLength); - this.#rawBlock += delta; - this.#emitInlineDelta(delta, events); - this.#buffer = this.#buffer.slice(emitLength); - } - return false; - } - - const rawDelta = this.#buffer.slice(0, close); - this.#rawBlock += rawDelta + closeTag; - let delta = rawDelta; - if (delta.endsWith("\r\n")) delta = delta.slice(0, -2); - else if (delta.endsWith("\n")) delta = delta.slice(0, -1); - this.#emitInlineDelta(delta, events); - this.#args[this.#inlineKey] = this.#inlineValue; - events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: this.#args, rawBlock: this.#rawBlock }); - this.#buffer = this.#buffer.slice(close + closeTag.length); - this.#reset(); - return true; - } - - #consumeMembers(events: InbandScanEvent[], final: boolean): boolean { - const closeTag = this.#closeTag(); - let searchFrom = 0; - while (true) { - const close = this.#buffer.indexOf(closeTag, searchFrom); - if (close === -1) { - if (final) { - this.#reset(); - this.#buffer = ""; - } - return false; - } - - const body = this.#buffer.slice(0, close); - const parsed = parseMembers(body, 0, undefined, this.#shape()?.properties ?? {}); - if (!parsed.ok || skipWhitespace(body, parsed.next) !== body.length) { - searchFrom = close + closeTag.length; - continue; - } - - const bodyArgs = parsed.value; - const args = { ...this.#args, ...bodyArgs }; - this.#rawBlock += body + closeTag; - this.#emitCompletedStringDeltas(bodyArgs, events); - events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: args, rawBlock: this.#rawBlock }); - this.#buffer = this.#buffer.slice(close + closeTag.length); - this.#reset(); - return true; - } - } - - #emitCompletedStringDeltas(args: Record, events: InbandScanEvent[]): void { - for (const key in args) { - const value = args[key]; - if (typeof value === "string" && value.length > 0) { - events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key, delta: value }); - } - } - } - - #stripInlineLeadingDelimiter(final: boolean): void { - if (!this.#inlineLeading) return; - if (this.#buffer.length === 0) return; - if (this.#buffer[0] === "\r") { - if (this.#buffer.length === 1 && !final) return; - if (this.#buffer[1] === "\n") { - this.#rawBlock += this.#buffer.slice(0, 2); - this.#buffer = this.#buffer.slice(2); - } - this.#inlineLeading = false; - return; - } - if (this.#buffer[0] === "\n") { - this.#rawBlock += this.#buffer[0]; - this.#buffer = this.#buffer.slice(1); - } - this.#inlineLeading = false; - } - - #emitInlineDelta(delta: string, events: InbandScanEvent[]): void { - if (delta.length === 0) return; - this.#inlineValue += delta; - events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#inlineKey, delta }); - } - #inlineTargetKey(): string | undefined { - const shape = this.#shape(); + const shape = this.#argShapes.get(this.#name); if (shape) { for (const key of shape.parameterOrder) { if (Object.hasOwn(this.#args, key)) continue; @@ -370,258 +332,149 @@ export class PiNativeInbandScanner implements InbandScanner { } return undefined; } - for (const key of this.#stringArgs(this.#name)) { if (!Object.hasOwn(this.#args, key)) return key; } return "input"; } - #shape(): ToolArgShape | undefined { - return this.#argShapes.get(this.#name); - } - - #closeTag(): string { - return ``; - } - #reset(): void { this.#state = "outside"; - this.#bodyMode = "undecided"; this.#id = ""; this.#name = ""; this.#args = {}; - this.#inlineKey = ""; - this.#inlineValue = ""; - this.#inlineLeading = false; + this.#bodyKey = ""; + this.#bodyValue = ""; + this.#bodyLeading = false; + this.#closeMarker = ""; this.#rawBlock = ""; } } -function parseMembers( - text: string, - position: number, - endTag: string | undefined, - properties: Record, -): MembersResult { - const value: Record = {}; - let index = position; +function parseHeaderArgs(text: string, properties: Record): Record { + const args: Record = {}; + let index = skipWhitespace(text, 0); while (index < text.length) { - index = skipWhitespace(text, index); - if (endTag && text.startsWith(endTag, index)) return { ok: true, value, next: index + endTag.length }; - if (index >= text.length) break; - if (text[index] !== "<" || text.startsWith(" 0) return { ok: true, value: attrs, next: tag.end }; - return { ok: true, value: coerceValue("", schema), next: tag.end }; - } - - const bodyStart = tag.end; - const closeTag = ``; - if (shouldParseObjectBody(text, bodyStart, closeTag, schema, tag.rawAttrs.length > 0)) { - const parsed = parseMembers(text, bodyStart, closeTag, attrProperties); - if (!parsed.ok) return { ok: false, value: undefined, next: bodyStart }; - return { ok: true, value: { ...attrs, ...parsed.value }, next: parsed.next }; - } - - const close = text.indexOf(closeTag, bodyStart); - if (close === -1) return { ok: false, value: undefined, next: bodyStart }; - const raw = stripBlockDelimiters(text.slice(bodyStart, close)); - return { ok: true, value: coerceValue(raw, schema), next: close + closeTag.length }; -} - -function shouldParseObjectBody( - text: string, - bodyStart: number, - closeTag: string, - schema: unknown, - hasAttrs: boolean, -): boolean { - if (isObjectSchema(schema)) return true; - if (isTypedScalarSchema(schema)) return false; - if (hasAttrs) return true; - const first = skipWhitespace(text, bodyStart); - if (text.startsWith(closeTag, first)) return false; - return text[first] === "<" && !text.startsWith(", key: string, value: unknown, schemaArray: boolean): void { - if (schemaArray) { - const existing = target[key]; - if (Array.isArray(existing)) existing.push(value); - else target[key] = [value]; - return; - } - - if (!Object.hasOwn(target, key)) { - target[key] = value; - return; - } - - const existing = target[key]; - if (Array.isArray(existing)) existing.push(value); - else target[key] = [existing, value]; -} - -function coerceAttributes( - rawAttrs: readonly RawAttribute[], - properties: Record, -): Record { - const attrs: Record = {}; - for (const attr of rawAttrs) { - attrs[attr.name] = attr.value === true ? true : coerceValue(attr.value, properties[attr.name]); - } - return attrs; -} - -function parseCallOpenTag(text: string): OpenTag | undefined { - if (!text.startsWith(CALL_PREFIX)) return undefined; - const tagEnd = findTagEnd(text, 0); - if (tagEnd === -1) return undefined; - return parseOpenTagContent(text, CALL_PREFIX.length, tagEnd); -} - -function parseElementOpenTag(text: string, start: number): OpenTag | undefined { - if (text[start] !== "<" || text.startsWith("= cursor && isWhitespace(text[last])) last--; - if (text[last] === "/") { - selfClosing = true; - contentEnd = last; - } - - return { - name, - rawAttrs: parseRawAttributes(text.slice(cursor, contentEnd)), - selfClosing, - end: tagEnd + 1, - }; -} - -function parseRawAttributes(text: string): RawAttribute[] { - const attrs: RawAttribute[] = []; - let index = 0; - while (index < text.length) { - index = skipWhitespace(text, index); - if (index >= text.length) break; if (!isNameStart(text[index])) { index++; continue; } - const nameStart = index; index++; while (index < text.length && isNameChar(text[index])) index++; - const name = text.slice(nameStart, index); + const key = text.slice(nameStart, index); index = skipWhitespace(text, index); if (text[index] !== "=") { - attrs.push({ name, value: true }); + args[key] = true; continue; } - - index++; - index = skipWhitespace(text, index); - if (index >= text.length) { - attrs.push({ name, value: "" }); - break; - } - - const quote = text[index]; - if (quote === '"' || quote === "'") { - const valueStart = ++index; - while (index < text.length && text[index] !== quote) index++; - attrs.push({ name, value: text.slice(valueStart, index) }); - if (index < text.length) index++; - continue; - } - - const valueStart = index; - while (index < text.length && !isWhitespace(text[index])) index++; - attrs.push({ name, value: text.slice(valueStart, index) }); + index = skipWhitespace(text, index + 1); + const parsed = readInlineValue(text, index, properties[key]); + args[key] = parsed.value; + index = skipWhitespace(text, parsed.next); } - return attrs; + return args; } -function readElementNamePrefix(text: string, ltIndex: number): string | undefined { - let index = ltIndex + 1; - if (index >= text.length) return undefined; - if (!isNameStart(text[index])) return ""; - const start = index; - index++; - while (index < text.length && isNameChar(text[index])) index++; - if (index >= text.length) return undefined; - const next = text[index]; - return isWhitespace(next) || next === "/" || next === ">" ? text.slice(start, index) : ""; +type InlineValue = { value: unknown; next: number }; + +function readInlineValue(text: string, start: number, schema: unknown): InlineValue { + const ch = text[start]; + if (ch === '"') { + let index = start + 1; + while (index < text.length) { + const c = text[index]; + if (c === "\\") { + index += 2; + continue; + } + if (c === '"') { + index++; + break; + } + index++; + } + const raw = text.slice(start, index); + try { + return { value: JSON.parse(raw) as unknown, next: index }; + } catch { + return { value: raw.slice(1, raw.endsWith('"') ? -1 : undefined), next: index }; + } + } + if (ch === "[" || ch === "{") { + const end = matchBracket(text, start); + const raw = text.slice(start, end); + try { + return { value: JSON.parse(raw) as unknown, next: end }; + } catch { + return { value: raw, next: end }; + } + } + let index = start; + while (index < text.length && !isWhitespace(text[index])) index++; + return { value: coerceValue(text.slice(start, index), schema), next: index }; } -function findTagEnd(text: string, start: number): number { - let quote = ""; +function matchBracket(text: string, start: number): number { + let depth = 0; + let inString = false; for (let index = start; index < text.length; index++) { const ch = text[index]; - if (quote) { - if (ch === quote) quote = ""; + if (inString) { + if (ch === "\\") { + index++; + continue; + } + if (ch === '"') inString = false; continue; } - if (ch === '"' || ch === "'") { - quote = ch; + if (ch === '"') { + inString = true; continue; } - if (ch === ">") return index; + if (ch === "[" || ch === "{") depth++; + else if (ch === "]" || ch === "}") { + depth--; + if (depth === 0) return index + 1; + } } - return -1; + return text.length; } -function stripBlockDelimiters(raw: string): string { - let start = 0; - let end = raw.length; - if (raw.startsWith("\r\n")) start = 2; - else if (raw.startsWith("\n")) start = 1; - if (end > start) { - if (raw.endsWith("\r\n")) end -= 2; - else if (raw.endsWith("\n")) end -= 1; +// Locate where a call header ends: the first top-level body fence, the first +// literal newline (scalar-only call), end-of-input, or "incomplete" when a +// quoted/bracketed value is still mid-stream. +function findHeaderEnd(text: string, start: number): HeaderEnd { + let inString = false; + let depth = 0; + for (let index = start; index < text.length; index++) { + const ch = text[index]; + if (inString) { + if (ch === "\\") { + index++; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "[" || ch === "{") { + depth++; + continue; + } + if (ch === "]" || ch === "}") { + if (depth > 0) depth--; + continue; + } + if (depth > 0) continue; + if (ch === FENCE_OPEN) return { kind: "fence", index }; + if (ch === "\n" || ch === "\r") return { kind: "newline", index }; } - return raw.slice(start, end); + if (inString || depth > 0) return { kind: "incomplete" }; + return { kind: "eof", index: text.length }; } function skipWhitespace(text: string, index: number): number { @@ -645,26 +498,83 @@ function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): str return renderInvocation(call, buildArgShapes(options.tools).get(call.name)); } -function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { - let body = ``; - for (const key in call.arguments) { - body += `\n${renderElement(key, call.arguments[key], shape?.properties[key])}`; - } - return `${body}\n`; -} - function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string { const shapes = buildArgShapes(options.tools); return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n"); } +function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { + const properties = shape?.properties ?? {}; + const bodyKey = selectBodyKey(call.arguments, shape); + let header = `${CALL_SIGIL}${call.name}`; + for (const key in call.arguments) { + if (key === bodyKey) continue; + header += ` ${key}=${renderInlineValue(call.arguments[key], properties[key])}`; + } + if (bodyKey === undefined) return header; + const body = String(call.arguments[bodyKey]); + const fence = 1 + maxRun(body, FENCE_CLOSE); + return `${header}${FENCE_OPEN.repeat(fence)}\n${body}\n${FENCE_CLOSE.repeat(fence)}`; +} + +// The body holds a single dominant string argument: the first string-only +// parameter whose value contains a newline. Single-line strings stay inline +// (quoted when needed) so the verbatim fence is reserved for genuine blocks. +function selectBodyKey(args: Record, shape: ToolArgShape | undefined): string | undefined { + // Round-trip requires renderer and scanner to agree on the omitted body key. + // The scanner assigns the body to the first string-only parameter missing from + // the header, so a body is only safe when no earlier parameter is also absent + // (no schema → keep everything inline). + if (!shape) return undefined; + for (const key of shape.parameterOrder) { + if (!Object.hasOwn(args, key)) return undefined; + const value = args[key]; + if (typeof value === "string" && value.includes("\n") && isStringOnlySchema(shape.properties[key])) return key; + } + return undefined; +} + +function renderInlineValue(value: unknown, schema: unknown): string { + if (typeof value === "string") { + return needsQuote(value) ? JSON.stringify(value) : value; + } + if (isStringOnlySchema(schema) && value === null) return '""'; + return stringifyJson(value); +} + +function needsQuote(value: string): boolean { + if (value.length === 0) return true; + const first = value[0]; + if (first === '"' || first === "[" || first === "{") return true; + return /[\s«»]/.test(value); +} + +function maxRun(text: string, ch: string): number { + let best = 0; + let run = 0; + for (let index = 0; index < text.length; index++) { + if (text[index] === ch) { + run++; + if (run > best) best = run; + } else { + run = 0; + } + } + return best; +} + function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string { - return renderToolResponseResults(results); + return results + .map(result => { + const fence = RESULT_FENCE.repeat(Math.max(2, 1 + maxRun(result.text, RESULT_FENCE))); + return `${fence}\n${result.text}\n${fence}`; + }) + .join("\n"); } function renderThinking(text: string): string { if (!text) return ""; - return `\n${text}\n`; + return `${THINK_SIGIL}\n${text}\n${THINK_SIGIL}`; } function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string { @@ -676,29 +586,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp }); } -function renderElement(key: string, value: unknown, schema: unknown): string { - if (Array.isArray(value)) { - const itemSchema = getArrayItemSchema(schema); - return value.map(item => renderElement(key, item, itemSchema)).join("\n"); - } - if (value && typeof value === "object") { - const record = value as Record; - const properties = getObjectProperties(schema); - let body = `<${key}>`; - for (const childKey in record) { - body += `\n${renderElement(childKey, record[childKey], properties[childKey])}`; - } - return `${body}\n`; - } - return `<${key}>${renderScalar(value, schema)}`; -} - -function renderScalar(value: unknown, schema: unknown): string { - if (typeof value === "string") return value; - if (isStringOnlySchema(schema) && value === null) return ""; - return stringifyJson(value); -} - const definition: DialectDefinition = { dialect: "pi", prompt: dialectPrompt, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 0fdde330e..28e8239af 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,8 +1,8 @@ export { type Type, type } from "arktype"; export { type ZodType, z } from "zod/v4"; export * from "./api-registry"; -export * from "./auth-broker"; -export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server"; +export type * from "./auth-broker"; +export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server"; export * from "./auth-gateway/types"; export * from "./auth-retry"; export * from "./auth-storage"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 7fd2da65c..3289fb327 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -80,6 +80,13 @@ function resolveBearerToken(options: BedrockOptions): string | undefined { return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; } +function inferRegionFromBedrockArn(modelId: string): string | undefined { + const parts = modelId.split(":", 6); + if (parts[0] !== "arn" || parts[2] !== "bedrock") return undefined; + const region = parts[3]; + return region || undefined; +} + type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string; @@ -206,7 +213,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( const blocks = output.content as Block[]; let rawRequestDump: RawHttpRequestDump | undefined; - const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; + const region = + options.region || + inferRegionFromBedrockArn(model.id) || + $env.AWS_REGION || + $env.AWS_DEFAULT_REGION || + "us-east-1"; try { const cacheRetention = resolveCacheRetention(options.cacheRetention); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 14982d573..23674a3fa 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -18,9 +18,15 @@ import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout, } from "../utils/idle-iterator"; -import { postOpenAIStream } from "../utils/openai-http"; +import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http"; import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; +import { + applyOpenAIReasoningEffortFallback, + createOpenAIReasoningEffortFallbackKey, + type OpenAIReasoningEffortFallback, + resolveOpenAIReasoningEffortFallback, +} from "./openai-reasoning-fallback"; import type { ResponseCreateParamsStreaming, ResponseStreamEvent } from "./openai-responses-wire"; import { applyCommonResponsesSamplingParams, @@ -135,29 +141,52 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" url, body: params, }; - let requestTimeout: NodeJS.Timeout | undefined; - if (requestTimeoutMs !== undefined) { - requestTimeout = setTimeout(() => abortTracker.abortLocally(firstEventTimeoutAbortError), requestTimeoutMs); - } + const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey( + "azure-responses", + url, + typeof params.model === "string" ? params.model : model.id, + ); + const attemptedReasoningEffortFallbacks = new Set(); let openaiStream: AsyncIterable; - try { - const headersWithTimeout = { ...headers }; + while (true) { + let requestTimeout: NodeJS.Timeout | undefined; if (requestTimeoutMs !== undefined) { - headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString(); + requestTimeout = setTimeout( + () => abortTracker.abortLocally(firstEventTimeoutAbortError), + requestTimeoutMs, + ); + } + try { + const headersWithTimeout = { ...headers }; + if (requestTimeoutMs !== undefined) { + headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString(); + } + const handle = await postOpenAIStream({ + url, + headers: headersWithTimeout, + body: params, + signal: requestSignal, + fetch: options?.fetch, + // Watchdog armed → no retries, so they cannot silently extend the deadline. + maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined, + onSseEvent: rawSseObserver, + }); + openaiStream = handle.events; + break; + } catch (error) { + const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined; + const reasoningEffortFallback: OpenAIReasoningEffortFallback | undefined = !requestSignal.aborted + ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, params) + : undefined; + if (reasoningEffortFallback === undefined) throw error; + const retryMarker = `${reasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`; + if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error; + attemptedReasoningEffortFallbacks.add(retryMarker); + applyOpenAIReasoningEffortFallback(params, reasoningEffortFallback); + rawRequestDump.body = params; + } finally { + if (requestTimeout !== undefined) clearTimeout(requestTimeout); } - const handle = await postOpenAIStream({ - url, - headers: headersWithTimeout, - body: params, - signal: requestSignal, - fetch: options?.fetch, - // Watchdog armed → no retries, so they cannot silently extend the deadline. - maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined, - onSseEvent: rawSseObserver, - }); - openaiStream = handle.events; - } finally { - if (requestTimeout !== undefined) clearTimeout(requestTimeout); } stream.push({ type: "start", partial: output }); @@ -297,6 +326,7 @@ function buildParams( model, context, strictResponsesPairing: true, + supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal, systemRole, includeThinkingSignatures: true, developerStringContent: true, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index cfc8fdcc1..4ac3ed632 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -3253,7 +3253,15 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex } if (msg.role === "toolResult") { - appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages, + msg, + model, + false, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); } msgIndex += 1; @@ -3271,7 +3279,10 @@ function normalizeInputMessageContent( return [{ type: "input_text", text: content.toWellFormed() }]; } - return convertResponsesInputContent(content, model.input.includes("image")) ?? []; + return ( + convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ?? + [] + ); } /** @internal Exported for tests. */ diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index c7882e0f4..ee49e6086 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -56,6 +56,17 @@ import type { ChatCompletionTool, ChatCompletionToolMessageParam, } from "./openai-chat-wire"; +import { + applyOpenAIReasoningEffortFallback, + clearOpenAIReasoningEffortFallbackState, + createOpenAIReasoningEffortFallbackKey, + createOpenAIReasoningEffortFallbackState, + getOpenAIReasoningEffortFallback, + type OpenAIReasoningEffortFallback, + type OpenAIReasoningEffortFallbackState, + rememberOpenAIReasoningEffortFallback, + resolveOpenAIReasoningEffortFallback, +} from "./openai-reasoning-fallback"; import { applyChatCompletionsCompatPolicy, applyChatCompletionsToolStream, @@ -444,14 +455,19 @@ type BuiltOpenAICompletionTools = { const OPENAI_COMPLETIONS_PROVIDER_SESSION_STATE_PREFIX = "openai-completions:"; -type OpenAICompletionsProviderSessionState = ProviderSessionState & OpenAIStrictToolsState; +type OpenAICompletionsProviderSessionState = ProviderSessionState & + OpenAIStrictToolsState & + OpenAIReasoningEffortFallbackState; function createOpenAICompletionsProviderSessionState(): OpenAICompletionsProviderSessionState { const strictToolsState = createOpenAIStrictToolsState(); + const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState(); const state: OpenAICompletionsProviderSessionState = { ...strictToolsState, + ...reasoningEffortFallbackState, close: () => { clearOpenAIStrictToolsState(state); + clearOpenAIReasoningEffortFallbackState(state); }, }; return state; @@ -581,6 +597,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( ); const premiumRequestsTotal = copilotPremiumRequests; let appliedStrictTools = false; + const requestReasoningEffortFallbacks = new Map(); + const attemptedReasoningEffortFallbacks = new Set(); + let activeReasoningEffortFallbackKey: string | undefined; + let activeRequestParams: OpenAICompletionsParams | undefined; const providerSessionState = getOpenAICompletionsProviderSessionState( model, baseUrl, @@ -601,6 +621,19 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( effectiveToolStrictModeOverride, ); appliedStrictTools = strictToolsApplied; + const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey( + "chat-completions", + trimmedBaseUrl, + params.model, + ); + const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(reasoningEffortFallbackKey) + ? requestReasoningEffortFallbacks.get(reasoningEffortFallbackKey) + : getOpenAIReasoningEffortFallback(providerSessionState, reasoningEffortFallbackKey); + if (requestReasoningEffortFallback !== undefined) { + applyOpenAIReasoningEffortFallback(params, requestReasoningEffortFallback); + } + activeReasoningEffortFallbackKey = reasoningEffortFallbackKey; + activeRequestParams = params; options?.onPayload?.(params); rawRequestDump = { provider: model.provider, @@ -650,7 +683,24 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( }); } catch (error) { const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined; - if ( + const reasoningEffortFallback = + activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted + ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, { + explicitDisable: options?.disableReasoning === true && options.reasoning === undefined, + }) + : undefined; + if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) { + const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`; + if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error; + attemptedReasoningEffortFallbacks.add(retryMarker); + requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback); + openaiStream = await createCompletionsStream(); + rememberOpenAIReasoningEffortFallback( + providerSessionState, + activeReasoningEffortFallbackKey, + reasoningEffortFallback, + ); + } else if ( isOpenRouterAnthropicModel(model) && !disableStrictTools && isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse) diff --git a/packages/ai/src/providers/openai-reasoning-fallback.ts b/packages/ai/src/providers/openai-reasoning-fallback.ts new file mode 100644 index 000000000..89f8c85a5 --- /dev/null +++ b/packages/ai/src/providers/openai-reasoning-fallback.ts @@ -0,0 +1,269 @@ +import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import type { CapturedHttpErrorResponse } from "../utils/http-inspector"; + +/** @internal */ +export type OpenAIReasoningEffortFallback = string | null; + +/** @internal */ +export interface OpenAIReasoningEffortFallbackState { + reasoningEffortFallbacks: Map; +} + +const ENABLED_REASONING_VALUES = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; +const KNOWN_REASONING_VALUE: Readonly> = { + none: true, + minimal: true, + low: true, + medium: true, + high: true, + xhigh: true, + max: true, +}; +const REASONING_VALUE_RANK: Readonly> = { + minimal: 0, + low: 1, + medium: 2, + high: 3, + xhigh: 4, + max: 5, +}; + +/** @internal */ +export function createOpenAIReasoningEffortFallbackState(): OpenAIReasoningEffortFallbackState { + return { reasoningEffortFallbacks: new Map() }; +} + +/** @internal */ +export function clearOpenAIReasoningEffortFallbackState(state: OpenAIReasoningEffortFallbackState): void { + state.reasoningEffortFallbacks.clear(); +} + +/** @internal */ +export function getOpenAIReasoningEffortFallback( + state: OpenAIReasoningEffortFallbackState | undefined, + key: string, +): OpenAIReasoningEffortFallback | undefined { + return state?.reasoningEffortFallbacks.get(key); +} + +/** @internal */ +export function rememberOpenAIReasoningEffortFallback( + state: OpenAIReasoningEffortFallbackState | undefined, + key: string, + fallback: OpenAIReasoningEffortFallback, +): void { + state?.reasoningEffortFallbacks.set(key, fallback); +} + +/** @internal */ +export function createOpenAIReasoningEffortFallbackKey( + endpoint: "chat-completions" | "responses" | "azure-responses", + baseUrl: string | undefined, + wireModelId: string | undefined, +): string { + return `${endpoint}:${baseUrl ?? ""}:${wireModelId ?? ""}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @internal */ +export function readOpenAIReasoningEffort(params: unknown): string | undefined { + if (!isRecord(params)) return undefined; + if (typeof params.reasoning_effort === "string") return params.reasoning_effort; + const reasoning = params.reasoning; + return isRecord(reasoning) && typeof reasoning.effort === "string" ? reasoning.effort : undefined; +} + +function deleteReasoningEffort(reasoning: Record, parent: Record): boolean { + if (typeof reasoning.effort !== "string") return false; + delete reasoning.effort; + for (const key in reasoning) { + if (key !== "effort") return true; + } + delete parent.reasoning; + return true; +} + +/** @internal */ +export function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean { + if (!isRecord(params)) return false; + let changed = false; + if (typeof params.reasoning_effort === "string") { + if (fallback === null) { + delete params.reasoning_effort; + } else { + params.reasoning_effort = fallback; + } + changed = true; + } + const reasoning = params.reasoning; + if (isRecord(reasoning) && typeof reasoning.effort === "string") { + if (fallback === null) { + changed = deleteReasoningEffort(reasoning, params) || changed; + } else { + reasoning.effort = fallback; + changed = true; + } + } + return changed; +} + +function capturedStringField( + captured: CapturedHttpErrorResponse | undefined, + field: "code" | "message" | "param" | "type", +) { + const body = isRecord(captured?.bodyJson) ? captured.bodyJson : undefined; + const error = isRecord(body?.error) ? body.error : undefined; + if (typeof error?.[field] === "string") return error[field]; + return typeof body?.[field] === "string" ? body[field] : undefined; +} + +function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse | undefined): string { + const parts = [ + error instanceof Error ? error.message : undefined, + capturedStringField(captured, "message"), + capturedStringField(captured, "param"), + capturedStringField(captured, "code"), + capturedStringField(captured, "type"), + captured?.bodyText, + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return parts.join("\n"); +} + +const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i; + +function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean { + const param = capturedStringField(captured, "param"); + const code = capturedStringField(captured, "code"); + const type = capturedStringField(captured, "type"); + const message = collectMessageParts(error, captured); + return ( + REASONING_EFFORT_FIELD_PATTERN.test(param ?? "") || + REASONING_EFFORT_FIELD_PATTERN.test(code ?? "") || + REASONING_EFFORT_FIELD_PATTERN.test(type ?? "") || + REASONING_EFFORT_FIELD_PATTERN.test(message) + ); +} + +function isInvalidReasoningEffortError( + error: unknown, + captured: CapturedHttpErrorResponse | undefined, + currentEffort: string, +): boolean { + const status = extractHttpStatusFromError(error) ?? captured?.status; + if (status !== 400 && status !== 422) return false; + if (!mentionsReasoningEffort(error, captured)) return false; + const message = collectMessageParts(error, captured); + if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false; + if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true; + if ( + /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|must be|expected)/i.test( + message, + ) + ) { + return true; + } + if (/(?:unsupported|not supported)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) { + return true; + } + return new RegExp( + `(?:invalid|unsupported|not supported)[^\\n]*["'\`]${escapeRegExp(currentEffort)}["'\`]`, + "i", + ).test(message); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parseKnownReasoningValues(text: string): Set { + const values = new Set(); + const quotedPattern = /["'`](none|minimal|low|medium|high|xhigh|max)["'`]/gi; + let quotedMatch = quotedPattern.exec(text); + while (quotedMatch !== null) { + values.add(quotedMatch[1]!.toLowerCase()); + quotedMatch = quotedPattern.exec(text); + } + const allowedMatch = /(?:must be|one of|allowed values?|supported values?(?: are)?|expected)([^.\n]+)/i.exec(text); + if (allowedMatch) { + const allowedText = allowedMatch[1]!; + const barePattern = /\b(none|minimal|low|medium|high|xhigh|max)\b/gi; + let bareMatch = barePattern.exec(allowedText); + while (bareMatch !== null) { + values.add(bareMatch[1]!.toLowerCase()); + bareMatch = barePattern.exec(allowedText); + } + } + return values; +} + +function parseAllowedReasoningValues(message: string, currentEffort: string): Set | undefined { + const values = parseKnownReasoningValues(message); + const hasAllowedCue = /must be|one of|allowed values?|supported values?|expected/i.test(message); + values.delete(currentEffort.toLowerCase()); + if (!hasAllowedCue && values.size === 0) return undefined; + return values; +} + +function orderedEnabledAllowedValues(allowed: Set): string[] { + return ENABLED_REASONING_VALUES.filter(value => allowed.has(value)); +} + +function lowestEnabledAllowedValue(allowed: Set): string | undefined { + for (const value of ENABLED_REASONING_VALUES) { + if (allowed.has(value)) return value; + } + return undefined; +} + +function nearestEnabledReasoningFallback(currentEffort: string, allowed: Set): string | undefined { + const current = currentEffort.toLowerCase(); + const allowedEnabled = orderedEnabledAllowedValues(allowed); + if (allowedEnabled.length === 0) return undefined; + if (current === "minimal" && allowedEnabled.includes("low")) return "low"; + if (current === "xhigh" && allowedEnabled.includes("max")) return "max"; + if (current === "xhigh" && allowedEnabled.includes("high")) return "high"; + if (current === "max" && allowedEnabled.includes("xhigh")) return "xhigh"; + const currentRank = REASONING_VALUE_RANK[current]; + if (currentRank === undefined) return undefined; + let best: string | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + let bestRank = Number.NEGATIVE_INFINITY; + for (const candidate of allowedEnabled) { + if (candidate === current) continue; + const candidateRank = REASONING_VALUE_RANK[candidate]; + if (candidateRank === undefined) continue; + const distance = Math.abs(candidateRank - currentRank); + if (distance < bestDistance || (distance === bestDistance && candidateRank > bestRank)) { + best = candidate; + bestDistance = distance; + bestRank = candidateRank; + } + } + return best; +} + +/** @internal */ +export function resolveOpenAIReasoningEffortFallback( + error: unknown, + captured: CapturedHttpErrorResponse | undefined, + params: unknown, + options?: { explicitDisable?: boolean }, +): OpenAIReasoningEffortFallback | undefined { + const currentEffort = readOpenAIReasoningEffort(params); + if (!currentEffort || !KNOWN_REASONING_VALUE[currentEffort.toLowerCase()]) return undefined; + if (!isInvalidReasoningEffortError(error, captured, currentEffort)) return undefined; + const message = collectMessageParts(error, captured); + const allowed = parseAllowedReasoningValues(message, currentEffort); + const normalizedCurrent = currentEffort.toLowerCase(); + if (allowed === undefined) return null; + if (options?.explicitDisable) { + if (normalizedCurrent !== "none" && allowed.has("none")) return "none"; + const fallback = lowestEnabledAllowedValue(allowed); + return fallback && fallback !== normalizedCurrent ? fallback : null; + } + if (normalizedCurrent === "none") return null; + return nearestEnabledReasoningFallback(normalizedCurrent, allowed) ?? null; +} diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 61aef61af..7a1c37401 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -44,6 +44,17 @@ import { type OpenAIResponsesToolChoice, } from "../utils/tool-choice"; import { compactGrammarDefinition } from "./grammar"; +import { + applyOpenAIReasoningEffortFallback, + clearOpenAIReasoningEffortFallbackState, + createOpenAIReasoningEffortFallbackKey, + createOpenAIReasoningEffortFallbackState, + getOpenAIReasoningEffortFallback, + type OpenAIReasoningEffortFallback, + type OpenAIReasoningEffortFallbackState, + rememberOpenAIReasoningEffortFallback, + resolveOpenAIReasoningEffortFallback, +} from "./openai-reasoning-fallback"; import type { Tool as OpenAITool, ResponseCreateParamsStreaming, @@ -140,7 +151,10 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE = /** Consecutive stale-previous-response failures before chaining is disabled for the session. */ const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3; -interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState { +interface OpenAIResponsesProviderSessionState + extends ProviderSessionState, + OpenAIStrictToolsState, + OpenAIReasoningEffortFallbackState { nativeHistoryReplayWarmed: boolean; /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */ chains: Map; @@ -164,14 +178,17 @@ interface OpenAIResponsesChainState { function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState { const strictToolsState = createOpenAIStrictToolsState(); + const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState(); const state: OpenAIResponsesProviderSessionState = { ...strictToolsState, + ...reasoningEffortFallbackState, nativeHistoryReplayWarmed: false, chains: new Map(), close: () => { state.nativeHistoryReplayWarmed = false; state.chains.clear(); clearOpenAIStrictToolsState(state); + clearOpenAIReasoningEffortFallbackState(state); }, }; return state; @@ -385,6 +402,26 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( const { trailingScaffoldingItems } = builtParams; let activeParams = params; let activeTrailingScaffoldingItems = trailingScaffoldingItems; + const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, ""); + const requestReasoningEffortFallbacks = new Map(); + const attemptedReasoningEffortFallbacks = new Set(); + let pendingReasoningEffortFallback: { key: string; fallback: OpenAIReasoningEffortFallback } | undefined; + let activeReasoningEffortFallbackKey: string | undefined; + let activeRequestParams: OpenAIResponsesSamplingParams | undefined; + const applyReasoningEffortFallbackForRequest = (requestParams: OpenAIResponsesSamplingParams): string => { + const fallbackKey = createOpenAIReasoningEffortFallbackKey( + "responses", + resolvedBaseUrl, + typeof requestParams.model === "string" ? requestParams.model : model.id, + ); + const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(fallbackKey) + ? requestReasoningEffortFallbacks.get(fallbackKey) + : getOpenAIReasoningEffortFallback(providerSessionState, fallbackKey); + if (requestReasoningEffortFallback !== undefined) { + applyOpenAIReasoningEffortFallback(requestParams, requestReasoningEffortFallback); + } + return fallbackKey; + }; if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) { chainState = getOpenAIResponsesChainState(providerSessionState, model, baseUrl, routingSessionId); if (!chainState.disabled) { @@ -392,6 +429,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( params.store = true; } } + applyReasoningEffortFallbackForRequest(params); let chained: OpenAIResponsesChainedParams = chainState && !chainState.disabled ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState) @@ -402,12 +440,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs); const requestTimeoutMs = firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined; - const requestUrl = `${(baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "")}/responses`; + const requestUrl = `${resolvedBaseUrl}/responses`; const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => { const replacementPayload = await options?.onPayload?.(requestParams, model); - return replacementPayload !== undefined - ? (replacementPayload as OpenAIResponsesSamplingParams) - : requestParams; + const payload = + replacementPayload !== undefined ? (replacementPayload as OpenAIResponsesSamplingParams) : requestParams; + applyReasoningEffortFallbackForRequest(payload); + return payload; }; chained = { ...chained, params: await applyPayloadReplacement(chained.params) }; rawRequestDump = { @@ -418,8 +457,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( url: requestUrl, body: chained.params, }; - const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => - callWithCopilotModelRetry( + const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => { + activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey( + "responses", + resolvedBaseUrl, + typeof requestParams.model === "string" ? requestParams.model : model.id, + ); + activeRequestParams = requestParams; + return callWithCopilotModelRetry( async () => { let requestTimeout: NodeJS.Timeout | undefined; if (requestTimeoutMs !== undefined) { @@ -458,6 +503,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( }, { provider: model.provider, signal: requestSignal }, ); + }; let openaiStream: AsyncIterable; let strictRetryAvailable = true; let activeStrictToolsApplied = builtParams.strictToolsApplied; @@ -465,9 +511,37 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( while (true) { try { openaiStream = await openResponsesStream(chained.params); + if (pendingReasoningEffortFallback) { + rememberOpenAIReasoningEffortFallback( + providerSessionState, + pendingReasoningEffortFallback.key, + pendingReasoningEffortFallback.fallback, + ); + pendingReasoningEffortFallback = undefined; + } break; } catch (error) { const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined; + const reasoningEffortFallback = + activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted + ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, { + explicitDisable: options?.disableReasoning === true && options.reasoning === undefined, + }) + : undefined; + if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) { + const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`; + if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error; + attemptedReasoningEffortFallbacks.add(retryMarker); + requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback); + applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback); + applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback); + rawRequestDump.body = chained.params; + pendingReasoningEffortFallback = { + key: activeReasoningEffortFallbackKey, + fallback: reasoningEffortFallback, + }; + continue; + } const compiledGrammarTooLarge = isOpenRouterAnthropicModel(model) && isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse); @@ -687,6 +761,7 @@ export function buildParams( model, context, strictResponsesPairing, + supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal, nativeHistory: { replay: shouldReplayNativeHistory, filterReasoning: policy.reasoning.filterReasoningHistory, diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 684c4a02b..405e7bffd 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -1178,9 +1178,24 @@ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseIn return repaired; } +/** + * Some Responses backends (notably GitHub Copilot) reject the OpenAI image + * `detail: "original"` value with a 400. When the model does not advertise + * support for it, degrade `"original"` to `"auto"` so the request still goes + * through with the closest valid fidelity instead of failing outright. See #2822. + */ +function clampResponsesImageDetail( + detail: ImageContent["detail"], + supportsImageDetailOriginal: boolean, +): ResponseInputImage["detail"] { + const resolved = detail ?? "auto"; + return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved; +} + export function convertResponsesInputContent( content: string | Array, supportsImages: boolean, + supportsImageDetailOriginal: boolean, ): ResponseInputContent[] | undefined { if (typeof content === "string") { if (content.trim().length === 0) return undefined; @@ -1200,7 +1215,7 @@ export function convertResponsesInputContent( for (const item of imageBlocks) { normalizedContent.push({ type: "input_image", - detail: item.detail ?? "auto", + detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal), image_url: `data:${item.mimeType};base64,${item.data}`, } satisfies ResponseInputImage); } @@ -1217,6 +1232,7 @@ export interface BuildResponsesInputOptions { model: Model; context: Context; strictResponsesPairing: boolean; + supportsImageDetailOriginal: boolean; systemRole?: "system" | "developer"; nativeHistory?: { replay: boolean; @@ -1267,7 +1283,11 @@ export function buildResponsesInput(options: BuildResponsesInp msgIndex++; continue; } - const content = convertResponsesInputContent(msg.content, options.model.input.includes("image")); + const content = convertResponsesInputContent( + msg.content, + options.model.input.includes("image"), + options.supportsImageDetailOriginal, + ); if (!content) continue; messages.push({ role: "user", @@ -1318,6 +1338,7 @@ export function buildResponsesInput(options: BuildResponsesInp msg, options.model, options.strictResponsesPairing, + options.supportsImageDetailOriginal, knownCallIds, customCallIds, ); @@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages( toolResult: ToolResultMessage, model: Model, strictResponsesPairing: boolean, + supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet, customCallIds?: ReadonlySet, ): void { @@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages( if (block.type === "image") { contentParts.push({ type: "input_image", - detail: block.detail ?? "auto", + detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal), image_url: `data:${block.mimeType};base64,${block.data}`, } satisfies ResponseInputImage); } diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts index 5c365ab16..5f293fb01 100644 --- a/packages/ai/src/registry/oauth/index.ts +++ b/packages/ai/src/registry/oauth/index.ts @@ -19,6 +19,7 @@ const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter( id: provider.id, name: provider.name, available: provider.available ?? true, + storeCredentialsAs: provider.storeCredentialsAs, })); const customOAuthProviders = new Map(); @@ -163,6 +164,7 @@ export function getOAuthProviders(): OAuthProviderInfo[] { id: provider.id, name: provider.name, available: true, + storeCredentialsAs: provider.storeCredentialsAs, })); return [...builtInOAuthProviders, ...customProviders]; } diff --git a/packages/ai/src/registry/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts index 859d8b1c3..20bc43219 100644 --- a/packages/ai/src/registry/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -31,6 +31,12 @@ export interface OAuthProviderInfo { id: OAuthProviderId; name: string; available: boolean; + /** + * Provider id the login stores credentials under, when it differs from `id` + * (e.g. `openai-codex-device` ⇒ `openai-codex`). Lets callers map a login + * entry back to the model provider it authenticates. + */ + storeCredentialsAs?: string; } export interface OAuthController { diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index b24a9f593..ffb25fdb5 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -152,6 +152,7 @@ function arkJsonAstToWire(value: unknown): unknown { const kZodWireSchema = Symbol("pi.schema.zod.wire"); const kJsonWireSchema = Symbol("pi.schema.json.wire"); const kArkWireSchema = Symbol("pi.schema.ark.wire"); +const kStrippedSchema = Symbol("pi.schema.descriptions.stripped"); /** * Post-process Zod-emitted JSON Schema so it matches the wire shape providers @@ -634,3 +635,87 @@ export function toolWireSchema(tool: Tool): Record { return postProcessJsonSchema(upgraded); }); } + +/** + * Schema-valued keywords whose value is a single subschema (or an array of + * subschemas — the recursion dispatches on array-ness, so tuple forms like + * draft-07 `items: []` are handled too). Covers the draft 2020-12 surface plus + * the legacy `additionalItems` that may survive an incomplete upgrade. + */ +const STRIP_SCHEMA_VALUE_KEYS = [ + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "items", + "additionalItems", + "contains", + "propertyNames", + "contentSchema", + "if", + "then", + "else", + "not", + "anyOf", + "oneOf", + "allOf", + "prefixItems", +] as const; + +/** Keywords whose value is a `{ name: Schema }` map — names are NOT annotations. */ +const STRIP_SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"] as const; + +/** + * Recursively strip human-readable `description` annotations from a JSON Schema, + * descending only through schema-valued keywords so a property literally named + * `"description"` inside a `properties`/`$defs` map keeps its schema (only its own + * annotation is dropped), and data-bearing keywords (`default`/`const`/`examples`) + * are never traversed. Mutates `node` in place — callers pass a clone. + */ +function stripSchemaDescriptionsInPlace(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) stripSchemaDescriptionsInPlace(child); + return; + } + if (!isSchemaRecord(node)) return; + delete node.description; + for (const key of STRIP_SCHEMA_VALUE_KEYS) { + if (Object.hasOwn(node, key)) stripSchemaDescriptionsInPlace(node[key]); + } + for (const mapKey of STRIP_SCHEMA_MAP_KEYS) { + const map = node[mapKey]; + if (isSchemaRecord(map)) { + for (const key in map) stripSchemaDescriptionsInPlace(map[key]); + } + } +} + +/** + * Return a deep clone of `schema` with every `description` annotation removed. + * The result is memoized on the input via a non-enumerable symbol (`stamp`) so + * repeated provider requests reuse the same stripped object; the input is never + * mutated, so the stamped `toolWireSchema` cache stays intact for + * system-prompt/UI rendering. + */ +export function stripSchemaDescriptions(schema: Record): Record { + return stamp(schema, kStrippedSchema, source => { + const clone = structuredClone(source); + stripSchemaDescriptionsInPlace(clone); + return clone; + }); +} + +/** + * Strip a tool's human-readable text from its provider-bound spec: empties the + * top-level `description` and removes nested schema `description` annotations. + * Used when the full tool catalog is rendered into the system prompt instead, so + * the descriptions ride the wire once (in the prompt) rather than duplicated on + * every tool definition. Parameters are resolved to wire JSON Schema and cloned, + * leaving the original tool objects and the stamped schema cache untouched. + */ +export function stripToolDescriptions(tools: readonly Tool[]): Tool[] { + return tools.map(tool => ({ + ...tool, + description: "", + parameters: stripSchemaDescriptions(toolWireSchema(tool)), + })); +} diff --git a/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts b/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts new file mode 100644 index 000000000..9952e254b --- /dev/null +++ b/packages/ai/test/anthropic-copilot-checkpoint-thinking-signature.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test"; +import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { + AssistantMessage, + Message, + Model, + ModelSpec, + ToolResultMessage, + UserMessage, +} from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +/** + * End-to-end encoder contract for #2851. GitHub Copilot's `anthropic-messages` + * proxy forwards to signature-enforcing Anthropic, so after the fix it is a + * SIGNING endpoint (`replayUnsignedThinking: false` — see the catalog compat + * regression test). This pins the consequence: when a checkpoint/branch-return + * turn is an abandoned tool-use turn (adaptive Opus emits a tool call then ends + * on `stop`), the transform strips its end_turn-bound signature and the encoder + * must DEGRADE the block to text — never replay it as `{ signature: "" }`, which + * 400s the whole request with "Invalid signature" on every full re-send. + * + * The signing classification is asserted directly in + * `packages/catalog/test/anthropic-copilot-signing-compat.test.ts`; the explicit + * `compat` override here models that post-fix classification so the encoder + * behavior is exercised independently of cross-package module resolution. + */ +function copilotSigningModel(): Model<"anthropic-messages"> { + return buildModel({ + api: "anthropic-messages", + provider: "github-copilot", + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + baseUrl: "https://api.githubcopilot.com", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, + // Post-#2851 classification: github-copilot is a signing endpoint. + compat: { replayUnsignedThinking: false }, + } as ModelSpec<"anthropic-messages">); +} + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +type WireBlock = { type: string; signature?: string; thinking?: string; text?: string; id?: string }; +interface WireParam { + role: string; + content: string | WireBlock[]; +} + +function copilotAssistant( + content: AssistantMessage["content"], + overrides: Partial = {}, +): AssistantMessage { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "github-copilot", + model: "claude-opus-4.8", + usage: emptyUsage, + stopReason: "stop", + timestamp: 0, + ...overrides, + }; +} + +/** Every thinking block emitted in the whole request, across all params. */ +function allThinkingBlocks(params: WireParam[]): WireBlock[] { + const blocks: WireBlock[] = []; + for (const p of params) { + if (!Array.isArray(p.content)) continue; + for (const b of p.content) { + if (b.type === "thinking") blocks.push(b); + } + } + return blocks; +} + +function assistantBlocksAt(params: WireParam[], index: number): WireBlock[] { + const assistants = params.filter(p => p.role === "assistant"); + const content = assistants[index]?.content; + return Array.isArray(content) ? content : []; +} + +describe("#2851 github-copilot checkpoint/branch-return thinking signature", () => { + it("classifies the explicit signing model as non-replay (sanity)", () => { + expect(copilotSigningModel().compat.replayUnsignedThinking).toBe(false); + }); + + it("never emits an empty-signature thinking block for a historical checkpoint turn", () => { + const model = copilotSigningModel(); + // The store holds a full (~14.6k char) Anthropic signature for the checkpoint + // turn's thinking; the turn ended on `stop` (abandoned tool-use), so its + // signature is end_turn-bound and gets stripped on replay. + const checkpointSig = "real_anthropic_signature_".repeat(600); + const messages: Message[] = [ + { role: "user", content: "investigate the flaky test", timestamp: 1 } satisfies UserMessage, + copilotAssistant( + [ + { type: "thinking", thinking: "Checkpoint first, then explore.", thinkingSignature: checkpointSig }, + { type: "toolCall", id: "toolu_ckpt", name: "checkpoint", arguments: { goal: "find the flake" } }, + ], + { stopReason: "stop", timestamp: 2 }, + ), + { + role: "toolResult", + toolCallId: "toolu_ckpt", + toolName: "checkpoint", + content: [{ type: "text", text: "checkpoint started" }], + isError: false, + timestamp: 3, + } satisfies ToolResultMessage, + // branch-return summary surfaces as a user turn in the rebuilt history + { role: "user", content: "[branch summary] explored, found the race", timestamp: 4 } satisfies UserMessage, + copilotAssistant([{ type: "text", text: "Here's the fix." }], { stopReason: "stop", timestamp: 5 }), + ]; + + const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[]; + + // (a) Anthropic's all-or-none contract: no thinking block may carry an empty signature. + for (const block of allThinkingBlocks(params)) { + expect(block.signature && block.signature.length > 0).toBeTruthy(); + } + + // The stripped checkpoint thinking is preserved as text (reasoning not silently lost), + // and its tool_use stays paired with the appended tool_result. + const checkpointBlocks = assistantBlocksAt(params, 0); + expect(checkpointBlocks.some(b => b.type === "text" && b.text?.includes("Checkpoint first"))).toBe(true); + expect(checkpointBlocks.some(b => b.type === "thinking")).toBe(false); + expect(checkpointBlocks.some(b => b.type === "tool_use" && b.id === "toolu_ckpt")).toBe(true); + }); + + it("still replays a signed historical thinking block natively (no regression to the common case)", () => { + const model = copilotSigningModel(); + // A clean tool-use turn (stopReason "toolUse") keeps a replayable signature. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: 1 } satisfies UserMessage, + copilotAssistant( + [ + { type: "thinking", thinking: "I'll read README.", thinkingSignature: "sig_replayable" }, + { type: "toolCall", id: "toolu_read", name: "read", arguments: { path: "README.md" } }, + ], + { stopReason: "toolUse", timestamp: 2 }, + ), + { + role: "toolResult", + toolCallId: "toolu_read", + toolName: "read", + content: [{ type: "text", text: "file body" }], + isError: false, + timestamp: 3, + } satisfies ToolResultMessage, + { role: "user", content: "now summarize", timestamp: 4 } satisfies UserMessage, + copilotAssistant([{ type: "text", text: "Summary." }], { stopReason: "stop", timestamp: 5 }), + ]; + + const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[]; + for (const block of allThinkingBlocks(params)) { + expect(block.signature && block.signature.length > 0).toBeTruthy(); + } + const firstAssistant = assistantBlocksAt(params, 0); + expect(firstAssistant.some(b => b.type === "thinking" && b.signature === "sig_replayable")).toBe(true); + }); +}); diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index a0ac5afdf..7e539ff9c 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -659,8 +659,17 @@ describe("history replay: custom_tool_call round-trip", () => { }; const knownCallIds = new Set(["call_1"]); const customCallIds = new Set(["call_1"]); + const model = makeModel(); - appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages as never, + toolResult, + model, + true, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); expect(messages).toHaveLength(1); const item = messages[0] as { type: string; call_id: string; output: string }; @@ -681,8 +690,17 @@ describe("history replay: custom_tool_call round-trip", () => { }; const knownCallIds = new Set(["call_2"]); const customCallIds = new Set(); // call_2 not custom + const model = makeModel(); - appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages as never, + toolResult, + model, + true, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); const item = messages[0] as { type: string }; expect(item.type).toBe("function_call_output"); diff --git a/packages/ai/test/auth-broker-oauth-extra-fields.test.ts b/packages/ai/test/auth-broker-oauth-extra-fields.test.ts index 4f06b2432..df74c01da 100644 --- a/packages/ai/test/auth-broker-oauth-extra-fields.test.ts +++ b/packages/ai/test/auth-broker-oauth-extra-fields.test.ts @@ -2,16 +2,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AuthStorage, type OAuthCredential, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { AuthBrokerClient, type AuthBrokerServerHandle, - AuthStorage, - type OAuthCredential, - REMOTE_REFRESH_SENTINEL, RemoteAuthCredentialStore, - SqliteAuthCredentialStore, startAuthBroker, -} from "@oh-my-pi/pi-ai"; +} from "@oh-my-pi/pi-ai/auth-broker"; // MCP OAuth credentials extend the base OAuthCredential with refresh material // (tokenUrl/clientId/clientSecret/resource) embedded so token refresh works for diff --git a/packages/ai/test/auth-broker-refresher.test.ts b/packages/ai/test/auth-broker-refresher.test.ts index 82787da9b..732b62b4b 100644 --- a/packages/ai/test/auth-broker-refresher.test.ts +++ b/packages/ai/test/auth-broker-refresher.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +import { AuthBrokerRefresher } from "@oh-my-pi/pi-ai/auth-broker"; import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; diff --git a/packages/ai/test/auth-broker-remote-store.test.ts b/packages/ai/test/auth-broker-remote-store.test.ts index cf553eed4..03ef26558 100644 --- a/packages/ai/test/auth-broker-remote-store.test.ts +++ b/packages/ai/test/auth-broker-remote-store.test.ts @@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { AuthBrokerClient, type AuthBrokerServerHandle, - AuthStorage, - REMOTE_REFRESH_SENTINEL, RemoteAuthCredentialStore, type SnapshotResponse, - SqliteAuthCredentialStore, startAuthBroker, -} from "@oh-my-pi/pi-ai"; +} from "@oh-my-pi/pi-ai/auth-broker"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; const savedEnv: Partial> = {}; diff --git a/packages/ai/test/auth-broker-snapshot-cache.test.ts b/packages/ai/test/auth-broker-snapshot-cache.test.ts index 51532b4d8..98cc010f6 100644 --- a/packages/ai/test/auth-broker-snapshot-cache.test.ts +++ b/packages/ai/test/auth-broker-snapshot-cache.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readAuthBrokerSnapshotCache, type SnapshotResponse, writeAuthBrokerSnapshotCache } from "@oh-my-pi/pi-ai"; +import { + readAuthBrokerSnapshotCache, + type SnapshotResponse, + writeAuthBrokerSnapshotCache, +} from "@oh-my-pi/pi-ai/auth-broker"; const TOKEN = "broker-cache-token"; const URL = "http://127.0.0.1:8765"; diff --git a/packages/ai/test/auth-broker-wire.test.ts b/packages/ai/test/auth-broker-wire.test.ts index 60fb2f349..7524e9226 100644 --- a/packages/ai/test/auth-broker-wire.test.ts +++ b/packages/ai/test/auth-broker-wire.test.ts @@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { AuthBrokerClient, type AuthBrokerServerHandle, AuthBrokerStreamUnsupportedError, - AuthStorage, - REMOTE_REFRESH_SENTINEL, type SnapshotStreamEvent, - SqliteAuthCredentialStore, startAuthBroker, -} from "@oh-my-pi/pi-ai"; +} from "@oh-my-pi/pi-ai/auth-broker"; import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; diff --git a/packages/ai/test/auth-storage-email-dedupe.test.ts b/packages/ai/test/auth-storage-email-dedupe.test.ts index 143f932de..a6bc154e6 100644 --- a/packages/ai/test/auth-storage-email-dedupe.test.ts +++ b/packages/ai/test/auth-storage-email-dedupe.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { registerOAuthProvider, unregisterOAuthProviders } from "../src/registry/oauth"; const LEGACY_TIMESTAMP = 1_700_000_000; @@ -360,7 +361,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v1-anthropic-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -442,7 +443,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const futureDbPath = path.join(tempDir, "future-schema-agent.db"); const futureDb = new Database(futureDbPath); - futureDb.exec(` + futureDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -505,7 +506,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v3-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -561,7 +562,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-v1-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL @@ -612,7 +613,7 @@ describe("AuthStorage openai-codex email dedupe", () => { const legacyDbPath = path.join(tempDir, "legacy-agent.db"); const legacyDb = new Database(legacyDbPath); - legacyDb.exec(` + legacyDb.run(` CREATE TABLE auth_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, @@ -650,3 +651,214 @@ describe("AuthStorage openai-codex email dedupe", () => { } }); }); + +describe("AuthStorage OAuth login upgrade and multi-account coexistence", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-auth-login-test-")); + }); + + afterEach(async () => { + unregisterOAuthProviders("auth-storage-login-upgrade-test"); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("allows multiple OAuth accounts to coexist and replaces legacy api_key rows on OAuth login", async () => { + if (!tempDir) throw new Error("test setup failed"); + + const dbPath = path.join(tempDir, "login-upgrade.db"); + const authStorage = await AuthStorage.create(dbPath); + + try { + // 1. Setup a legacy api_key credential + await authStorage.set("unit-login-upgrade", { type: "api_key", key: "sk-legacy-key" }); + expect(await authStorage.getApiKey("unit-login-upgrade")).toBe("sk-legacy-key"); + + // 2. Register custom oauth provider + let loginReturns: (Omit & { type?: string }) | null = null; + registerOAuthProvider({ + id: "unit-login-upgrade", + name: "Unit Login Upgrade", + sourceId: "auth-storage-login-upgrade-test", + login: async () => { + if (!loginReturns) throw new Error("no credentials"); + const { type, ...rest } = loginReturns; + return rest; + }, + refreshToken: async () => { + if (!loginReturns) throw new Error("no credentials"); + const { type, ...rest } = loginReturns; + return rest; + }, + }); + + // 3. Login first OAuth account + loginReturns = { + refresh: "refresh-token-1", + access: "access-token-1", + expires: Date.now() + 60_000, + projectId: "project-1", + email: "user-1@example.com", + }; + await authStorage.login("unit-login-upgrade", { + onAuth: () => {}, + onPrompt: async () => "", + }); + + // Legacy api_key should be gone/replaced, and first oauth account is active. + // getApiKey should now return the first OAuth access token (since the api_key is gone). + expect(await authStorage.getApiKey("unit-login-upgrade")).toBe("access-token-1"); + const firstRows = readStoredIdentityRows(dbPath, "unit-login-upgrade"); + expect(firstRows).toEqual([ + { identity_key: null, disabled_cause: "replaced by oauth login" }, + { identity_key: "email:user-1@example.com", disabled_cause: null }, + ]); + + // 4. Login second OAuth account + loginReturns = { + refresh: "refresh-token-2", + access: "access-token-2", + expires: Date.now() + 60_000, + projectId: "project-2", + email: "user-2@example.com", + }; + await authStorage.login("unit-login-upgrade", { + onAuth: () => {}, + onPrompt: async () => "", + }); + + // Both OAuth accounts should coexist! No accounts should be disabled + const secondRows = readStoredIdentityRows(dbPath, "unit-login-upgrade"); + expect(secondRows).toEqual([ + { identity_key: null, disabled_cause: "replaced by oauth login" }, + { identity_key: "email:user-1@example.com", disabled_cause: null }, + { identity_key: "email:user-2@example.com", disabled_cause: null }, + ]); + } finally { + authStorage.close(); + } + }); +}); + +describe("AuthStorage persistent session stickiness", () => { + let tempDir = ""; + let dbPath = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-auth-test-")); + dbPath = path.join(tempDir, "auth.db"); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("persists session-sticky credentials across AuthStorage restarts", async () => { + // 1. Initialize AuthStorage and log in two accounts + let authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(dbPath))); + + try { + const credential1: OAuthCredential = { + type: "oauth", + refresh: "refresh-token-1", + access: "access-token-1", + expires: Date.now() + 3600_000, + projectId: "project-1", + email: "user-1@example.com", + }; + const credential2: OAuthCredential = { + type: "oauth", + refresh: "refresh-token-2", + access: "access-token-2", + expires: Date.now() + 3600_000, + projectId: "project-2", + email: "user-2@example.com", + }; + await authStorage.set("unit-session-stickiness", [credential1, credential2]); + + // 2. Resolve initial key for session-1 + const key1 = await authStorage.getApiKey("unit-session-stickiness", "session-1"); + expect(key1).toBe("access-token-2"); + + // 3. Rotate session-1's sticky credential to the sibling + await authStorage.rotateSessionCredential("unit-session-stickiness", "session-1"); + const key2 = await authStorage.getApiKey("unit-session-stickiness", "session-1"); + expect(key2).toBe("access-token-1"); + + // 4. Close AuthStorage to simulate process restart + authStorage.close(); + + // 5. Re-instantiate AuthStorage using the same DB + authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(dbPath))); + await authStorage.reload(); + + // 6. Retrieve the sticky key again for session-1 (should still be access-token-1) + const key3 = await authStorage.getApiKey("unit-session-stickiness", "session-1"); + expect(key3).toBe("access-token-1"); + + authStorage.close(); + } finally { + // No-op + } + }); + + it("re-resolves a persisted sticky to the same account by id after a lower-index credential is removed", async () => { + const provider = "unit-sticky-invalidation"; + const mk = (suffix: string): OAuthCredential => ({ + type: "oauth", + refresh: `refresh-${suffix}`, + access: `access-${suffix}`, + expires: Date.now() + 3600_000, + projectId: `project-${suffix}`, + email: `user-${suffix}@example.com`, + }); + + let authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(dbPath))); + // Four accounts so a one-slot index shift still lands on a *different* valid index. + await authStorage.set(provider, [mk("a"), mk("b"), mk("c"), mk("d")]); + let rows = authStorage.listStoredCredentials(provider); + + // Stick a session to an account whose index is >= 1 (so removing index 0 + // shifts it) and <= len-2 (so the old recorded index still maps to a + // different live account — the resurrection-prone shape the review flagged). + let session: string | undefined; + let stuckId = -1; + let stuckIndex = -1; + let stuckToken: string | undefined; + for (let i = 0; i < 256 && session === undefined; i++) { + const candidate = `sticky-probe-${i}`; + const token = await authStorage.getApiKey(provider, candidate); + const index = rows.findIndex(row => (row.credential as OAuthCredential).access === token); + if (index >= 1 && index <= rows.length - 2) { + session = candidate; + stuckIndex = index; + stuckId = rows[index].id; + stuckToken = token; + } + } + expect(session).toBeDefined(); + // The account that will slide into the OLD recorded index after the removal. + const shiftedToken = (rows[stuckIndex + 1].credential as OAuthCredential).access; + expect(shiftedToken).not.toBe(stuckToken); + + // Remove the index-0 account (a different account) -> indices shift down by one. + expect(await authStorage.removeCredential(provider, rows[0].id)).toBe(true); + authStorage.close(); + + // Restart from the same DB. + authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(dbPath))); + await authStorage.reload(); + rows = authStorage.listStoredCredentials(provider); + expect(rows.findIndex(row => row.id === stuckId)).toBe(stuckIndex - 1); + + // The persisted sticky must follow the credential id, not the stale index: + // route back to the same account, never the one now occupying the old index. + const resolved = await authStorage.getApiKey(provider, session); + authStorage.close(); + expect(resolved).toBe(stuckToken); + expect(resolved).not.toBe(shiftedToken); + }); +}); diff --git a/packages/ai/test/bedrock-inference-profile.test.ts b/packages/ai/test/bedrock-inference-profile.test.ts new file mode 100644 index 000000000..3753bedff --- /dev/null +++ b/packages/ai/test/bedrock-inference-profile.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48"; +const profileModel: Model<"bedrock-converse-stream"> = buildModel({ + id: profileArn, + name: "Bedrock inference profile", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, +}); + +function userContext(): Context { + return { + messages: [{ role: "user", content: "Say hello", timestamp: 0 }], + }; +} + +describe("Bedrock inference profile ARNs", () => { + test("routes requests to the ARN region and preserves the ARN model id", async () => { + const calls: string[] = []; + const customFetch: FetchImpl = Object.assign( + async (input: string | URL | Request, _init?: RequestInit) => { + calls.push(String(input instanceof Request ? input.url : input)); + return new Response("nope", { status: 418 }); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamBedrock(profileModel, userContext(), { + bearerToken: "test-token", + fetch: customFetch, + maxTokens: 16, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(calls).toEqual([ + `https://bedrock-runtime.us-east-2.amazonaws.com/model/${encodeURIComponent(profileArn)}/converse-stream`, + ]); + }); +}); diff --git a/packages/ai/test/dialect-thinking.test.ts b/packages/ai/test/dialect-thinking.test.ts index 9ec128887..84f2f32c3 100644 --- a/packages/ai/test/dialect-thinking.test.ts +++ b/packages/ai/test/dialect-thinking.test.ts @@ -164,26 +164,26 @@ describe("kimi think tags (…)", () => { }); }); -describe("pi native thinking channel (…)", () => { - it("routes to thinking, keeping it out of the reply and calls", () => { - const events = scan("pi", "reasoning"); +describe("pi native thinking channel (¤…¤)", () => { + it("routes ¤ to thinking, keeping it out of the reply and calls", () => { + const events = scan("pi", "¤reasoning¤§foo x=1\n"); expect(thinkingText(events)).toBe("reasoning"); expect(callNames(events)).toEqual([{ name: "foo", arguments: { x: 1 } }]); - expect(visibleText(events)).not.toContain(""); + expect(visibleText(events)).not.toContain("¤"); }); it("round-trips renderThinking through the scanner", () => { const rendered = getDialectDefinition("pi").renderThinking("reasoning"); - expect(rendered).toBe("\nreasoning\n"); + expect(rendered).toBe("¤\nreasoning\n¤"); const events = scan("pi", `${rendered}visible`); expect(thinkingText(events).trim()).toBe("reasoning"); expect(visibleText(events)).toBe("visible"); }); - it("treats as plain text when parseThinking is disabled", () => { - const events = scan("pi", "xanswer", { options: { parseThinking: false } }); + it("treats ¤ as plain text when parseThinking is disabled", () => { + const events = scan("pi", "¤x¤answer", { options: { parseThinking: false } }); expect(thinkingBoundaries(events)).toBe(0); - expect(visibleText(events)).toContain("x"); + expect(visibleText(events)).toContain("¤x¤"); }); }); @@ -223,7 +223,7 @@ describe("unterminated thinking at stream end", () => { { dialect: "gemma", input: "<|channel>thought\npartial" }, { dialect: "glm", input: "partial" }, { dialect: "kimi", input: "partial" }, - { dialect: "pi", input: "partial" }, + { dialect: "pi", input: "¤partial" }, { dialect: "qwen3", input: "partial" }, ]; diff --git a/packages/ai/test/github-copilot-long-context-wire.test.ts b/packages/ai/test/github-copilot-long-context-wire.test.ts index 86abab512..64de79a19 100644 --- a/packages/ai/test/github-copilot-long-context-wire.test.ts +++ b/packages/ai/test/github-copilot-long-context-wire.test.ts @@ -15,8 +15,12 @@ afterEach(() => { vi.restoreAllMocks(); }); +// Fixed timestamp keeps the wire-body assertions deterministic; the value is +// never read on the wire, but pinning it avoids any incidental nondeterminism. +const FIXED_TIMESTAMP = 1_700_000_000_000; + const testContext: Context = { - messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + messages: [{ role: "user", content: "hello", timestamp: FIXED_TIMESTAMP }], }; function makeLongContextVariant(spec: Partial> & { api: TApi }): Model { @@ -112,3 +116,109 @@ describe("GitHub Copilot long-context variant wire model id", () => { expect(wireModelIds[0]).toBe("gemini-3.1-pro-preview"); }); }); + +/** + * GitHub Copilot's Responses endpoint rejects the `detail: "original"` image + * hint (an oh-my-pi extension that preserves native-resolution snapcompact + * frames) with an HTTP 400. The catalog resolves `supportsImageDetailOriginal` + * to `false` for Copilot, and the Responses request builder degrades the hint + * to `"auto"` so the wire stays valid. Every other host preserves `"original"`. + */ +describe("GitHub Copilot Responses image detail clamp (#2822)", () => { + const imageContext: Context = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this frame" }, + { type: "image", mimeType: "image/png", data: "ZmFrZQ==", detail: "original" }, + ], + timestamp: FIXED_TIMESTAMP, + }, + ], + }; + + // Walk a serialized Responses request body and return the first `input_image` + // detail hint it emits (the wire nests it under `input[].content[]`). + function firstImageDetail(body: Record): string | undefined { + let found: string | undefined; + const walk = (node: unknown): void => { + if (found !== undefined || node === null || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + const obj = node as Record; + if (obj.type === "input_image" && typeof obj.detail === "string") { + found = obj.detail; + return; + } + for (const value of Object.values(obj)) walk(value); + }; + walk(body); + return found; + } + + async function detailOnWire(model: Model<"openai-responses">): Promise { + let body: Record | undefined; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + body = await getRequestBody(input, init); + return createUnauthorizedResponse(); + }); + try { + // The mocked fetch returns 401; this test only cares about what was + // serialized onto the wire, which `fetchMock` captures into `body` + // before `.result()` settles. Tolerate the result rejecting. + await streamOpenAIResponses(model, imageContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + }).result(); + } catch { + // Ignore: the 401 may surface as a rejection on some result paths. + } + return body === undefined ? undefined : firstImageDetail(body); + } + + it("degrades `original` to `auto` for GitHub Copilot, which rejects it with a 400", async () => { + const model = makeLongContextVariant({ + api: "openai-responses", + id: "gpt-5.5-1m", + requestModelId: "gpt-5.5", + name: "GPT-5.5 (1M)", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(false); + expect(await detailOnWire(model)).toBe("auto"); + }); + + it("preserves `original` for non-Copilot Responses hosts (snapcompact native frames)", async () => { + const model = makeLongContextVariant({ + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + headers: {}, + id: "gpt-5.5", + requestModelId: "gpt-5.5", + name: "GPT-5.5", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(true); + expect(await detailOnWire(model)).toBe("original"); + }); + + it("clamps `original` when only the base URL identifies Copilot (provider id differs)", async () => { + // A model pointed at the Copilot Responses host but labeled with a generic + // provider id must still degrade `original`. Detecting Copilot solely by the + // provider field would resolve `supportsImageDetailOriginal: true` here and + // reintroduce the HTTP 400 this clamp prevents; host-aware detection keeps it + // `false`. + const model = makeLongContextVariant({ + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.githubcopilot.com", + id: "gpt-5.5-1m", + requestModelId: "gpt-5.5", + name: "GPT-5.5 (1M)", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(false); + expect(await detailOnWire(model)).toBe("auto"); + }); +}); diff --git a/packages/ai/test/glm-5.2-reasoning-effort.test.ts b/packages/ai/test/glm-5.2-reasoning-effort.test.ts new file mode 100644 index 000000000..969e4291f --- /dev/null +++ b/packages/ai/test/glm-5.2-reasoning-effort.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, FetchImpl, Model, ModelSpec } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; + +// GLM-5.2 reasoning-effort dialects diverge per host (verified against live +// endpoints): a direct GLM host (Fireworks) wants the top UI tier on the wire +// as `max` while keeping its distinct lower tiers, whereas OpenRouter rejects +// `max` (HTTP 400) and treats `xhigh` as its own max tier. The catalog bakes +// the right `thinking.effortMap`; these tests pin the resulting wire value so a +// future map change can't silently 400 either host. +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: 0 }] }; + +function chatSse(): Response { + const chunk = (delta: unknown, finish: string | null) => + JSON.stringify({ + id: "x", + object: "chat.completion.chunk", + created: 0, + choices: [{ index: 0, delta, finish_reason: finish }], + }); + return new Response(`data: ${chunk({ content: "ok" }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function responsesSse(): Response { + return new Response( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 } }, + }, + })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +async function captureChatEffort(model: Model<"openai-completions">, reasoning: Effort): Promise { + let body: Record | undefined; + const fetchMock: FetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record; + return chatSse(); + }); + for await (const event of streamOpenAICompletions(model, context, { apiKey: "k", fetch: fetchMock, reasoning })) { + if (event.type === "done" || event.type === "error") break; + } + if (!body) throw new Error("Expected captured chat-completions request"); + return body.reasoning_effort; +} + +async function captureResponsesEffort(model: Model<"openrouter">, reasoning: Effort): Promise { + let body: Record | undefined; + const fetchMock: FetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record; + return responsesSse(); + }); + // OpenRouter is a pseudo-API driven through the Responses surface (stream.ts + // defaults `api: "openrouter"` to streamOpenAIResponses). + const responsesModel = model as unknown as Model<"openai-responses">; + for await (const event of streamOpenAIResponses(responsesModel, context, { + apiKey: "k", + fetch: fetchMock, + reasoning, + })) { + if (event.type === "done" || event.type === "error") break; + } + if (!body) throw new Error("Expected captured Responses request"); + const reasoningParam = body.reasoning; + return reasoningParam && typeof reasoningParam === "object" && "effort" in reasoningParam + ? reasoningParam.effort + : undefined; +} + +const fireworks = buildModel({ + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, +} as ModelSpec<"openai-completions">) as Model<"openai-completions">; + +const openRouter = buildModel({ + id: "z-ai/glm-5.2", + name: "GLM 5.2", + api: "openrouter", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 131_072, +} as ModelSpec<"openrouter">) as Model<"openrouter">; + +describe("GLM-5.2 reasoning effort wire mapping", () => { + afterEach(() => vi.restoreAllMocks()); + + it("maps the top tier to reasoning_effort:max on a direct GLM host (Fireworks), lower tiers literal", async () => { + expect(await captureChatEffort(fireworks, Effort.XHigh)).toBe("max"); + expect(await captureChatEffort(fireworks, Effort.High)).toBe("high"); + expect(await captureChatEffort(fireworks, Effort.Medium)).toBe("medium"); + // Fireworks rejects literal `minimal`; the host quirk merge keeps `minimal -> none`. + expect(await captureChatEffort(fireworks, Effort.Minimal)).toBe("none"); + }); + + it("sends the literal xhigh tier to OpenRouter (which rejects max) via the Responses surface", async () => { + expect(await captureResponsesEffort(openRouter, Effort.XHigh)).toBe("xhigh"); + }); +}); diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index be852cee0..d19970f0c 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -70,6 +70,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; @@ -77,7 +78,7 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./oauth"; -const TEMP_DIR = path.join(import.meta.dir, ".temp-images"); +const TEMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omp-temp-images-")); /** * Generate a valid PNG image of specified dimensions using ImageMagick @@ -231,8 +232,6 @@ describe("Image Limits E2E Tests", () => { beforeAll(async () => { if (!$which("magick")) return; - // Create temp directory - fs.mkdirSync(TEMP_DIR, { recursive: true }); // Generate small test image for count tests smallImage = await generateImage(100, 100, "small.png"); diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index 8598599b9..0aa9e7966 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -154,11 +154,7 @@ describe("in-band tool dialects", () => { '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', ); - expectRawBlock( - "pi", - '\nhello\n', - '\nhello\n', - ); + expectRawBlock("pi", "§write path=out.ts«\nhello\n»", "§write path=out.ts«\nhello\n»"); }); it("projects raw tool blocks onto parsed ToolCall content", () => { @@ -244,9 +240,7 @@ describe("in-band tool dialects", () => { expect(getDialectDefinition("qwen3").renderToolResults([resultBlock])).toBe( "\nFILE\n", ); - expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe( - "\nFILE\n", - ); + expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe("‡‡\nFILE\n‡‡"); expect(getDialectDefinition("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```"); expect(getDialectDefinition("gemma").renderToolResults([resultBlock])).toBe( '<|tool_response>response:read{output:<|"|>FILE<|"|>}', @@ -296,4 +290,60 @@ describe("in-band tool dialects", () => { .join(""); expect(deltas).toBe("line1\nconst x = `a`;"); }); + + it("streams the verbatim body incrementally for pi", () => { + const text = getDialectDefinition("pi").renderAssistantToolCalls( + [ + { + type: "toolCall", + id: "c1", + name: "write", + arguments: { path: "out.ts", content: "line1\nconst x = `a`;" }, + }, + ], + { tools: TOOLS }, + ); + const deltas = feedText("pi", text) + .filter( + (event): event is Extract => + event.type === "toolArgDelta" && event.key === "content", + ) + .map(event => event.delta) + .join(""); + expect(deltas).toBe("line1\nconst x = `a`;"); + }); + + it("escalates the pi body fence so payloads containing » round-trip", () => { + const content = "before »close« and »» double\nsecond line"; + const text = getDialectDefinition("pi").renderToolCall( + { type: "toolCall", id: "c1", name: "write", arguments: { path: "a.md", content } }, + { tools: TOOLS }, + ); + expect(text).toContain("«««"); // fence widened past the »» run in the body + const calls = toolEnds(feedText("pi", text)); + expect(calls).toHaveLength(1); + expect(calls[0]!.arguments).toEqual({ path: "a.md", content }); + }); + + it("ends a scalar-only pi call at the newline and keeps following prose", () => { + const events = feedText("pi", "§read path=a.ts count=5\ndone"); + const calls = toolEnds(events); + expect(calls).toHaveLength(1); + expect(calls[0]!.arguments).toEqual({ path: "a.ts", count: 5 }); + const visible = events + .filter((event): event is Extract => event.type === "text") + .map(event => event.text) + .join(""); + expect(visible).toBe("done"); + }); + + it("treats a § not naming a known tool as literal text", () => { + const events = feedText("pi", "ref §section 3 and §frobnicate end"); + expect(toolEnds(events)).toHaveLength(0); + const visible = events + .filter((event): event is Extract => event.type === "text") + .map(event => event.text) + .join(""); + expect(visible).toBe("ref §section 3 and §frobnicate end"); + }); }); diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index dd1c572dc..fc1c6ffed 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -171,6 +171,7 @@ describe("issue #967 vision guard", () => { { type: "image", mimeType: "image/png", data: "ZmFrZQ==" }, ], false, + model.compat.supportsImageDetailOriginal, ); expect(countTaggedValues(userContent, "input_image")).toBe(0); expect(userContent).toEqual([ @@ -187,6 +188,7 @@ describe("issue #967 vision guard", () => { ]), model, true, + model.compat.supportsImageDetailOriginal, new Set(["call_1"]), ); expect(countTaggedValues(payload, "input_image")).toBe(0); diff --git a/packages/ai/test/openai-reasoning-effort-fallback.test.ts b/packages/ai/test/openai-reasoning-effort-fallback.test.ts new file mode 100644 index 000000000..cdedabb21 --- /dev/null +++ b/packages/ai/test/openai-reasoning-effort-fallback.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from "bun:test"; +import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import type { Context, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; + +const testContext: Context = { + messages: [{ role: "user", content: "hello", timestamp: 0 }], +}; + +function createChatSseResponse(): Response { + const chunks = [ + { + id: "chatcmpl-reasoning-fallback", + object: "chat.completion.chunk", + created: 0, + model: "fallback-reasoner", + choices: [{ index: 0, delta: { content: "ok" } }], + }, + { + id: "chatcmpl-reasoning-fallback", + object: "chat.completion.chunk", + created: 0, + model: "fallback-reasoner", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + "[DONE]", + ]; + return new Response( + `${chunks.map(chunk => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`).join("\n\n")}\n\n`, + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ); +} + +function createResponsesSseResponse(id = "resp_reasoning_fallback"): Response { + const events = [ + { + type: "response.completed", + response: { + id, + status: "completed", + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + }, + ]; + return new Response(`${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function invalidReasoningResponse(param: "reasoning_effort" | "reasoning.effort", value: string): Response { + return new Response( + JSON.stringify({ + error: { + message: `invalid reasoning value: '${value}' (must be "high", "medium", "low", "max", or "none")`, + type: "invalid_request_error", + param, + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} +function invalidMediumReasoningResponse(): Response { + return new Response( + JSON.stringify({ + error: { + message: 'reasoning.effort: Invalid option: expected one of "high"|"low"|"minimal"|"none"', + type: "invalid_request_error", + param: "reasoning.effort", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +function pipeDelimitedReasoningEffortResponse(): Response { + return new Response( + JSON.stringify({ + error: { + message: 'reasoning.effort: Invalid option: expected one of "xhigh"|"high"|"medium"|"low"|"minimal"|"none"', + type: "invalid_request_error", + param: "reasoning.effort", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +function summaryReasoningErrorResponse(): Response { + return new Response( + JSON.stringify({ + error: { + message: "invalid reasoning.summary value: 'verbose'", + type: "invalid_request_error", + param: "reasoning.summary", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +function parseJsonBody(init: RequestInit | undefined): Record { + return JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record; +} + +function createCompletionsModel(): Model<"openai-completions"> { + return buildModel({ + id: "fallback-reasoner", + name: "Fallback Reasoner", + api: "openai-completions", + provider: "custom", + baseUrl: "https://proxy.example.test/v1", + reasoning: true, + compat: { + thinkingFormat: "openai", + supportsReasoningParams: true, + supportsReasoningEffort: true, + }, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }); +} + +function createResponsesModel(): Model<"openai-responses"> { + return buildModel({ + id: "fallback-responses-reasoner", + name: "Fallback Responses Reasoner", + api: "openai-responses", + provider: "custom-responses", + baseUrl: "https://responses.example.test/v1", + reasoning: true, + compat: { + supportsReasoningParams: true, + supportsReasoningEffort: true, + }, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }); +} +function createMappedResponsesModel(): Model<"openai-responses"> { + return buildModel({ + id: "mapped-responses-reasoner", + name: "Mapped Responses Reasoner", + api: "openai-responses", + provider: "custom-responses", + baseUrl: "https://responses.example.test/v1", + reasoning: true, + compat: { + supportsReasoningParams: true, + supportsReasoningEffort: true, + }, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + effortMap: { [Effort.XHigh]: "max" }, + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }); +} + +function createAzureResponsesModel(): Model<"azure-openai-responses"> { + return buildModel({ + id: "gpt-5.2-test", + name: "GPT 5.2 Test", + api: "azure-openai-responses", + provider: "azure", + baseUrl: "https://azure.example.test/openai/v1", + reasoning: true, + compat: { + supportsReasoningParams: true, + supportsReasoningEffort: true, + }, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }); +} + +describe("OpenAI reasoning effort fallback retry", () => { + it("retries Chat Completions xhigh as provider max", async () => { + const bodies: Record[] = []; + const fetchMock: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + const body = parseJsonBody(init); + bodies.push(body); + return bodies.length === 1 + ? invalidReasoningResponse("reasoning_effort", "xhigh") + : createChatSseResponse(); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamOpenAICompletions(createCompletionsModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + reasoning: "xhigh", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(bodies.map(body => body.reasoning_effort)).toEqual(["xhigh", "max"]); + }); + + it("retries Responses xhigh as provider max and stores the successful fallback params", async () => { + const bodies: Record[] = []; + const fetchMock: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + const body = parseJsonBody(init); + bodies.push(body); + return bodies.length === 1 + ? invalidReasoningResponse("reasoning.effort", "xhigh") + : createResponsesSseResponse(); + }, + { preconnect: fetch.preconnect }, + ); + const providerSessionState = new Map(); + + const result = await streamOpenAIResponses(createResponsesModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + reasoning: "xhigh", + statefulResponses: true, + sessionId: "reasoning-fallback-session", + providerSessionState, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(bodies.map(body => (body.reasoning as { effort?: string } | undefined)?.effort)).toEqual(["xhigh", "max"]); + const state = [...providerSessionState.values()][0] as unknown as { + chains: Map; + }; + const chain = [...state.chains.values()][0]!; + expect(chain.lastParams?.reasoning?.effort).toBe("max"); + }); + + it("retries pipe-delimited reasoning.effort errors with the nearest supported tier", async () => { + const bodies: Record[] = []; + const fetchMock: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + const body = parseJsonBody(init); + bodies.push(body); + return bodies.length === 1 ? pipeDelimitedReasoningEffortResponse() : createResponsesSseResponse(); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamOpenAIResponses(createMappedResponsesModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + reasoning: "xhigh", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(bodies.map(body => (body.reasoning as { effort?: string } | undefined)?.effort)).toEqual(["max", "xhigh"]); + }); + + it("retries medium as high when medium is missing and high is the closest upper tier", async () => { + const bodies: Record[] = []; + const fetchMock: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + const body = parseJsonBody(init); + bodies.push(body); + return bodies.length === 1 ? invalidMediumReasoningResponse() : createResponsesSseResponse(); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamOpenAIResponses(createResponsesModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + reasoning: "medium", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(bodies.map(body => (body.reasoning as { effort?: string } | undefined)?.effort)).toEqual([ + "medium", + "high", + ]); + }); + + it("retries Azure Responses xhigh as provider max", async () => { + const bodies: Record[] = []; + const fetchMock: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + const body = parseJsonBody(init); + bodies.push(body); + return bodies.length === 1 + ? invalidReasoningResponse("reasoning.effort", "xhigh") + : createResponsesSseResponse(); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamAzureOpenAIResponses(createAzureResponsesModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + azureBaseUrl: "https://azure.example.test/openai/v1", + azureApiVersion: "v1", + reasoning: "xhigh", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(bodies.map(body => (body.reasoning as { effort?: string } | undefined)?.effort)).toEqual(["xhigh", "max"]); + }); + + it("does not retry unrelated reasoning parameter errors", async () => { + let attempts = 0; + const fetchMock: FetchImpl = Object.assign( + async (): Promise => { + attempts += 1; + return summaryReasoningErrorResponse(); + }, + { preconnect: fetch.preconnect }, + ); + + const result = await streamOpenAIResponses(createResponsesModel(), testContext, { + apiKey: "test-key", + fetch: fetchMock, + reasoning: "xhigh", + reasoningSummary: "auto", + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorStatus).toBe(400); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index e1a6e2be6..3147f2ce5 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -2,15 +2,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { AuthBrokerClient, type AuthBrokerServerHandle, - AuthStorage, - REMOTE_REFRESH_SENTINEL, RemoteAuthCredentialStore, - SqliteAuthCredentialStore, startAuthBroker, -} from "@oh-my-pi/pi-ai"; +} from "@oh-my-pi/pi-ai/auth-broker"; import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index 46ce7a076..56506b07d 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -8,6 +8,8 @@ import { normalizeEmptySchemas, normalizeSchemaForCCA, normalizeSchemaForGoogle, + stripSchemaDescriptions, + stripToolDescriptions, toolWireSchema, zodToWireSchema, } from "@oh-my-pi/pi-ai/utils/schema"; @@ -405,3 +407,114 @@ describe("arkToWireSchema — authored property order", () => { expect(Object.keys(wire.properties as Record)).toEqual(["pattern", "i", "paths", "skip"]); }); }); + +describe("stripSchemaDescriptions", () => { + it("removes annotations through nested schema keywords while preserving structure", () => { + const schema = { + type: "object", + description: "object annotation", + properties: { + path: { type: "string", description: "the path" }, + choice: { + anyOf: [ + { type: "string", description: "string variant" }, + { type: "number", description: "number variant" }, + ], + }, + }, + dependentSchemas: { + path: { type: "object", description: "dependent annotation" }, + }, + required: ["path"], + }; + const stripped = stripSchemaDescriptions(schema); + expect(JSON.stringify(stripped)).not.toContain("annotation"); + expect(JSON.stringify(stripped)).not.toContain("variant"); + expect(JSON.stringify(stripped)).not.toContain("the path"); + // Structure survives: types, property names, required, union arity. + const props = stripped.properties as Record; + expect(props.path.type).toBe("string"); + expect(props.choice.anyOf).toHaveLength(2); + expect(stripped.required).toEqual(["path"]); + }); + + it("keeps a property literally named `description` (only its own annotation is dropped)", () => { + const schema = { + type: "object", + properties: { + description: { type: "string", description: "a field that is named description" }, + }, + }; + const stripped = stripSchemaDescriptions(schema); + const prop = (stripped.properties as Record).description; + expect(prop).toBeDefined(); + expect(prop.type).toBe("string"); + expect(prop.description).toBeUndefined(); + }); + + it("never descends into data-bearing keywords (default/const/examples)", () => { + const schema = { + type: "object", + properties: { + mode: { + type: "string", + description: "the mode", + default: { description: "data, keep me" }, + examples: [{ description: "example data" }], + }, + }, + }; + const stripped = stripSchemaDescriptions(schema); + const mode = (stripped.properties as Record>).mode; + expect(mode.description).toBeUndefined(); + expect(mode.default).toEqual({ description: "data, keep me" }); + expect(mode.examples).toEqual([{ description: "example data" }]); + }); + + it("does not mutate the input schema", () => { + const schema = { + type: "object", + description: "keep", + properties: { a: { type: "string", description: "keep a" } }, + }; + stripSchemaDescriptions(schema); + expect(schema.description).toBe("keep"); + expect(schema.properties.a.description).toBe("keep a"); + }); + + it("memoizes the result on the input via a hidden stamp", () => { + const schema = { type: "object", properties: { a: { type: "string", description: "x" } } }; + const first = stripSchemaDescriptions(schema); + const second = stripSchemaDescriptions(schema); + expect(second).toBe(first); + }); +}); + +describe("stripToolDescriptions", () => { + const tool: Tool = { + name: "demo", + description: "top-level tool description", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "where to read" }, + }, + required: ["path"], + }, + }; + + it("empties the top-level description and strips nested schema descriptions", () => { + const [stripped] = stripToolDescriptions([tool]); + expect(stripped.description).toBe(""); + expect(JSON.stringify(stripped.parameters)).not.toContain("where to read"); + expect((stripped.parameters as { properties: Record }).properties.path.type).toBe( + "string", + ); + }); + + it("leaves the original tool and the cached wire schema intact", () => { + stripToolDescriptions([tool]); + expect(tool.description).toBe("top-level tool description"); + expect(JSON.stringify(toolWireSchema(tool))).toContain("where to read"); + }); +}); diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index 32fdcc551..f5d386b9e 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -5,6 +5,7 @@ import type { Context, FetchImpl, Model, ThinkingContent, Tool, ToolCall } from import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; interface SseToolCallDelta { index: number; @@ -83,7 +84,7 @@ const bashTool: Tool = { parameters: { type: "object", properties: { - _i: { type: "string" }, + [INTENT_FIELD]: { type: "string" }, command: { type: "string" }, timeout: { type: "number" }, }, @@ -163,7 +164,7 @@ describe("StreamMarkupHealing DSML envelope pattern", () => { expect(call.id).toMatch(/^call_[0-9a-f]+$/); const args = JSON.parse(call.arguments) as Record; - expect(args._i).toBe("Check Fedora 42 available packages"); + expect(args[INTENT_FIELD]).toBe("Check Fedora 42 available packages"); expect(args.timeout).toBe(15); expect(String(args.command)).toContain("2>/dev/null"); expect(String(args.command)).toContain('grep -E "^python3|^git-|^sed-|^bash-"'); @@ -736,7 +737,7 @@ describe("OpenAI completions provider DSML envelope healing", () => { expect(toolCalls).toHaveLength(1); expect(toolCalls[0].name).toBe("bash"); expect(toolCalls[0].arguments).toMatchObject({ - _i: "Check Fedora 42 available packages", + [INTENT_FIELD]: "Check Fedora 42 available packages", timeout: 15, }); expect(result.stopReason).toBe("toolUse"); @@ -784,7 +785,7 @@ describe("OpenAI completions provider DSML envelope healing", () => { expect(toolCalls).toHaveLength(1); expect(toolCalls[0].name).toBe("bash"); expect(toolCalls[0].arguments).toMatchObject({ - _i: "Check Fedora 42 available packages", + [INTENT_FIELD]: "Check Fedora 42 available packages", timeout: 15, }); expect(result.stopReason).toBe("toolUse"); diff --git a/packages/ai/test/tool-examples.test.ts b/packages/ai/test/tool-examples.test.ts index d1ae732e6..a781b7dae 100644 --- a/packages/ai/test/tool-examples.test.ts +++ b/packages/ai/test/tool-examples.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { renderToolExamples } from "../src/dialect/examples"; import type { InbandTool } from "../src/dialect/types"; @@ -50,9 +51,7 @@ describe("renderToolExamples", () => { }; const rendered = renderToolExamples(tool, "pi"); - expect(rendered).toContain(""); - expect(rendered).toContain(""); - expect(rendered).toContain("src/**/*.ts"); + expect(rendered).toContain('§find paths=["src/**/*.ts"]'); }); it("renders call example in hermes format", () => { @@ -155,10 +154,10 @@ describe("renderToolExamples", () => { parameters: { type: "object", properties: { - _i: { type: "string" }, + [INTENT_FIELD]: { type: "string" }, paths: { type: "array", items: { type: "string" } }, }, - required: ["_i", "paths"], + required: [INTENT_FIELD, "paths"], }, examples: [ { @@ -168,11 +167,11 @@ describe("renderToolExamples", () => { ], }; - const rendered = renderToolExamples(tool, "anthropic", "_i"); - expect(rendered).toContain(' { @@ -187,6 +186,6 @@ describe("renderToolExamples", () => { examples: [{ caption: "Find files", call: { paths: ["src/**/*.ts"] } }], }; - expect(renderToolExamples(tool, "anthropic")).not.toContain("_i"); + expect(renderToolExamples(tool, "anthropic")).not.toContain(INTENT_FIELD); }); }); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 60572b582..c79db88b6 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,37 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Added + +- Added support for Gemini 2.5 Flash-Lite, 3.1 Flash-Lite, and 3.5 Flash models +- Added support for Moonshot V1 model family + +### Changed + +- Updated context window and token limits for various Claude, Gemini, and GPT-OSS models +- Refined thinking mode behaviors and routing for supported LLM families + +### Fixed + +- Fixed GLM-5.2 `reasoning_effort` so the top thinking tier reaches each host's genuine maximum instead of 400ing, mapping the internal `xhigh` tier per host dialect (verified against live endpoints): Z.ai/Zhipu collapse onto the model's `none`/`high`/`max` scale (`xhigh → max`); Fireworks, resellers, and Ollama Cloud keep their distinct lower tiers and remap only the top `xhigh → max` (merged over host quirks such as Fireworks' `minimal → none`); and OpenRouter — whose API rejects `max` and treats `xhigh` as its own max tier — now exposes the `xhigh` tier and forwards it verbatim. Dialect detection keys off resolved `compat.thinkingFormat`, so custom OpenRouter/Z.ai-format providers are covered too. +- Maintained thinking effort routing when discovery only returns the base model ID +- Improved credential retrieval logic for Antigravity and Codex providers via auth discovery + +## [16.0.9] - 2026-06-18 + +### Fixed + +- Fixed GitHub Copilot's `anthropic-messages` proxy being misclassified as a non-signing reasoning endpoint (`replayUnsignedThinking: true`). It forwards to signature-enforcing Anthropic, so replaying a stripped/unsigned historical `thinking` block as `signature: ""` — most visibly an end_turn-bound checkpoint/branch-return turn whose signature the transform must strip — caused a `400 Invalid signature` that corrupted the session and re-tripped on every full history re-send (e.g. after toggling MCP servers). Copilot now degrades such blocks to text like the official API. ([#2851](https://github.com/can1357/oh-my-pi/issues/2851)) +- Added a `supportsImageDetailOriginal` compat flag that resolves to `false` for GitHub Copilot, whose Responses endpoint rejects the `detail: "original"` image hint with a 400, and `true` for every other host. ([#2822](https://github.com/can1357/oh-my-pi/issues/2822)) + +## [16.0.8] - 2026-06-18 + +### Changed + +- Refactored model family ID predicates and capability checkers to use a shared, uniform process-lifetime `memo` utility to eliminate caching boilerplate. + ### Fixed - Fixed LM Studio dynamic discovery to use native `/api/v0/models` metadata so VLM models advertise image input. ([#2945](https://github.com/can1357/oh-my-pi/issues/2945)) diff --git a/packages/catalog/package.json b/packages/catalog/package.json index fc746a964..3122a8ec0 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "16.0.7", + "version": "16.1.2", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index cba4765d9..5f85acc02 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -10,7 +10,8 @@ const COPILOT_PREMIUM_MULTIPLIERS: Record = { }; import * as path from "node:path"; -import { AuthStorage, type OAuthAccess, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { discoverAuthStorage } from "@oh-my-pi/pi-ai/auth-broker/discover"; +import type { OAuthAccess } from "@oh-my-pi/pi-ai/auth-storage"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { getGitLabDuoModels } from "@oh-my-pi/pi-ai/providers/gitlab-duo"; import { $env } from "@oh-my-pi/pi-utils"; @@ -69,10 +70,8 @@ async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscove } try { - const store = await SqliteAuthCredentialStore.open(); - const authStorage = new AuthStorage(store); + const authStorage = await discoverAuthStorage(); try { - await authStorage.reload(); const storedApiKey = await authStorage.getApiKey(providerId); if (storedApiKey) { return storedApiKey; @@ -88,10 +87,13 @@ async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscove } } } finally { - store.close(); + authStorage.close(); } - } catch { - // Ignore missing/unreadable auth storage. + } catch (err) { + console.warn( + `Warning: Failed to retrieve credentials for ${providerId}:`, + err instanceof Error ? err.message : String(err), + ); } return undefined; @@ -336,19 +338,25 @@ const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_PRIMARY_ENDPOINT; async function getOAuthAccessFromStorage(provider: OAuthProvider): Promise { try { - const store = await SqliteAuthCredentialStore.open(); - const authStorage = new AuthStorage(store); + const authStorage = await discoverAuthStorage(); try { - await authStorage.reload(); // `getOAuthAccess` runs the full AuthStorage refresh pipeline so an // expired-but-refreshable credential gets rotated before discovery, // and identity metadata (accountId/projectId/email) flows through // for Codex/Antigravity downstream calls. - return (await authStorage.getOAuthAccess(provider)) ?? null; + let access = await authStorage.getOAuthAccess(provider); + if (!access && provider === "google-antigravity") { + access = await authStorage.getOAuthAccess("google-gemini-cli"); + } + return access ?? null; } finally { - store.close(); + authStorage.close(); } - } catch { + } catch (err) { + console.warn( + `Warning: Failed to retrieve credentials for ${provider}:`, + err instanceof Error ? err.message : String(err), + ); return null; } } @@ -360,7 +368,8 @@ async function getOAuthAccessFromStorage(provider: OAuthProvider): Promise[]> { const access = await getOAuthAccessFromStorage("google-antigravity"); if (!access) { - console.log("No Antigravity credentials found, will use previous models"); + console.log("No Antigravity or Gemini CLI credentials found, will use previous models."); + console.log("Tip: If you are logged in under a specific profile, run with OMP_PROFILE=."); return []; } try { @@ -404,6 +413,8 @@ function extractCodexAccountId(accessToken: string): string | null { async function fetchCodexDiscoveryModels(): Promise[]> { const access = await getOAuthAccessFromStorage("openai-codex"); if (!access) { + console.log("No Codex credentials found, will use previous models."); + console.log("Tip: If you are logged in under a specific profile, run with OMP_PROFILE=."); return []; } try { diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index b7816f876..93390b0e3 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -66,7 +66,16 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // loses the reasoning chain and can destabilize the next tool-call // arguments (#2005). Known non-signing hosts (Z.AI, DeepSeek) are also // preserved for compatibility. - replayUnsignedThinking: isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official), + // + // GitHub Copilot's `anthropic-messages` proxy is excluded: it forwards to + // signature-enforcing Anthropic and returns full thinking signatures, so it + // is a SIGNING endpoint. Replaying a stripped/unsigned thinking block as + // `signature: ""` there 400s the whole request ("Invalid signature") — most + // visibly when a checkpoint/branch-return turn's end_turn-bound signature is + // stripped on replay (issue #2851). Treating it like official Anthropic + // degrades such blocks to text instead, which the API accepts. + replayUnsignedThinking: + !isCopilot && (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)), escapeBuiltinToolNames: modelMatchesHost(spec, "umans"), }; applyCompatOverrides(compat, spec.compat); diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index 0decb1364..8510c4f08 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -473,6 +473,12 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol // Azure OpenAI and GitHub Copilot Responses paths require tool results // to strictly match prior tool calls when building Responses inputs. strictResponsesPairing: isAzure || spec.provider === "github-copilot", + // GitHub Copilot's Responses endpoint rejects the `detail: "original"` + // image hint with a 400; every other host preserves native-resolution + // frames (snapcompact relies on `original`). Detect Copilot by provider id + // or base-URL host (mirroring the Anthropic compat builder) so a model + // pointed at the Copilot host under a different provider id still clamps. + supportsImageDetailOriginal: !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), requiresJuiceZeroHack: spec.name.toLowerCase().startsWith("gpt-5"), reasoningEffortMap: {}, supportsReasoningParams: true, @@ -528,6 +534,7 @@ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnly return { supportsLongPromptCacheRetention: compat.supportsLongPromptCacheRetention, strictResponsesPairing: compat.strictResponsesPairing, + supportsImageDetailOriginal: compat.supportsImageDetailOriginal, requiresJuiceZeroHack: compat.requiresJuiceZeroHack, supportsObfuscationOptOut: compat.supportsObfuscationOptOut, } satisfies ResponsesOnlyCompat; diff --git a/packages/catalog/src/hosts.ts b/packages/catalog/src/hosts.ts index 5dab516e6..7afc2a48b 100644 --- a/packages/catalog/src/hosts.ts +++ b/packages/catalog/src/hosts.ts @@ -16,7 +16,7 @@ interface HostClassSpec { readonly providers?: readonly string[]; /** Provider-id prefixes that imply this host class (e.g. `xiaomi-token-plan-`). */ readonly providerPrefixes?: readonly string[]; - /** Case-insensitive substrings matched against the base URL. */ + /** Lowercase ASCII substrings matched case-insensitively against the base URL. */ readonly urlMarkers: readonly string[]; // Strict hostname matching is intentionally not modeled here: the one // auth-sensitive consumer (Anthropic official-endpoint) parses the URL @@ -68,9 +68,8 @@ export type KnownHost = keyof typeof KNOWN_HOSTS; export function hostMatchesUrl(baseUrl: string | undefined, host: KnownHost): boolean { if (!baseUrl) return false; const spec: HostClassSpec = KNOWN_HOSTS[host]; - const normalized = baseUrl.toLowerCase(); for (const marker of spec.urlMarkers) { - if (normalized.includes(marker)) return true; + if (includesAsciiCaseInsensitive(baseUrl, marker)) return true; } return false; } @@ -91,6 +90,19 @@ export function modelMatchesHost(model: { provider: string; baseUrl: string }, h return hostMatchesUrl(model.baseUrl, host); } +function includesAsciiCaseInsensitive(value: string, lowerNeedle: string): boolean { + const needleLength = lowerNeedle.length; + const end = value.length - needleLength; + for (let start = 0; start <= end; start++) { + let offset = 0; + for (; offset < needleLength; offset++) { + if ((value.charCodeAt(start + offset) | 0x20) !== lowerNeedle.charCodeAt(offset)) break; + } + if (offset === needleLength) return true; + } + return false; +} + // --- Endpoint-shape predicates (URL path/verb shapes, not vendor hosts) --- /** Vertex AI express-mode OpenAI-compatible endpoint (`…/endpoints/openapi`). */ diff --git a/packages/catalog/src/identity/classify.ts b/packages/catalog/src/identity/classify.ts index da742d78d..624b071c7 100644 --- a/packages/catalog/src/identity/classify.ts +++ b/packages/catalog/src/identity/classify.ts @@ -51,9 +51,15 @@ export interface UnknownModel { export type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel; /** Strip a provider namespace prefix (`openai/gpt-5.4` → `gpt-5.4`). */ +// Cache keyed by model id (a bounded set of bundled/aggregator ids), so no eviction is needed. +const bareModelIdCache = new Map(); export function bareModelId(modelId: string): string { + const cached = bareModelIdCache.get(modelId); + if (cached !== undefined) return cached; const p = modelId.lastIndexOf("/"); - return p !== -1 ? modelId.slice(p + 1) : modelId; + const result = p !== -1 ? modelId.slice(p + 1) : modelId; + bareModelIdCache.set(modelId, result); + return result; } export function parseKnownModel(modelId: string): ParsedModel { diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index bcf3ba3c0..c48d91f35 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -16,45 +16,58 @@ import { semverGte, } from "./classify"; -/** Kimi family ids in any namespace form (`moonshotai/kimi-*`, `kimi-k2.6`, `vendor/kimi.x`). */ -export function isKimiModelId(modelId: string): boolean { - return modelId.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(modelId); +/** Bounded process-lifetime cache memo helper. */ +function memo(fn: (modelId: string) => T): (modelId: string) => T { + const cache = new Map(); + return (modelId: string) => { + if (cache.has(modelId)) { + return cache.get(modelId) as T; + } + const result = fn(modelId); + cache.set(modelId, result); + return result; + }; } +/** Kimi family ids in any namespace form (`moonshotai/kimi-*`, `kimi-k2.6`, `vendor/kimi.x`). */ +export const isKimiModelId = memo((modelId: string): boolean => { + return modelId.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(modelId); +}); + /** Kimi K2.6 specifically, including router ids that spell the version `k2p6`. */ -export function isKimiK26ModelId(modelId: string): boolean { +export const isKimiK26ModelId = memo((modelId: string): boolean => { return /(^|\/)kimi-k2(?:\.6|p6)(?:[-:]|$)/i.test(modelId); -} +}); /** Claude ids in any namespace form (`claude-*`, `vendor/claude.x`). */ -export function isClaudeModelId(modelId: string): boolean { +export const isClaudeModelId = memo((modelId: string): boolean => { return /(^|\/)claude[-.]/i.test(modelId); -} +}); /** `anthropic/`-namespaced ids (aggregator catalogs like OpenRouter). */ -export function isAnthropicNamespacedModelId(modelId: string): boolean { +export const isAnthropicNamespacedModelId = memo((modelId: string): boolean => { return /(^|\/)anthropic\//i.test(modelId); -} +}); /** Qwen family ids (substring match — Qwen SKUs have no stable prefix shape). */ -export function isQwenModelId(modelId: string): boolean { +export const isQwenModelId = memo((modelId: string): boolean => { return modelId.toLowerCase().includes("qwen"); -} +}); /** Gemma open-weights family (`gemma-3-27b-it`, `google/gemma-4-E2B-it`, `gemma2-9b`). */ -export function isGemmaModelId(modelId: string): boolean { +export const isGemmaModelId = memo((modelId: string): boolean => { return /(^|\/)gemma[-.]?\d/i.test(modelId); -} +}); /** DeepSeek family by id or display name (proxies often rename the id but keep the name). */ -export function isDeepseekModelIdOrName(value: string): boolean { +export const isDeepseekModelIdOrName = memo((value: string): boolean => { return value.toLowerCase().includes("deepseek"); -} +}); /** Xiaomi MiMo family by id or display name. */ -export function isMimoModelIdOrName(value: string): boolean { +export const isMimoModelIdOrName = memo((value: string): boolean => { return value.toLowerCase().includes("mimo"); -} +}); const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3"] as const; @@ -63,11 +76,11 @@ const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "g * (e.g. `grok-build`, `grok-4.20-0309-reasoning`) think natively but reject the * param, so callers must omit reasoning effort for them. */ -export function isGrokReasoningEffortCapable(modelId: string): boolean { +export const isGrokReasoningEffortCapable = memo((modelId: string): boolean => { const bare = bareModelId(modelId).trim().toLowerCase(); if (!bare) return false; return GROK_EFFORT_CAPABLE_PREFIXES.some(prefix => bare.startsWith(prefix)); -} +}); /** * MiniMax M2-generation family (M2, M2.1, M2.5, M2.7, including `-highspeed`/ @@ -78,20 +91,20 @@ export function isGrokReasoningEffortCapable(modelId: string): boolean { * `minimal` to `none` (Fireworks) or expects the full 5-tier scale must * clamp instead. Excludes M1, M3, MiniMax-Text-01, music, hailuo, voice ids. */ -export function isMinimaxM2FamilyModelId(modelId: string): boolean { +export const isMinimaxM2FamilyModelId = memo((modelId: string): boolean => { const lower = modelId.toLowerCase(); if (!lower.includes("minimax")) return false; // Boundary-delimited `m2` token followed by zero or more digits (dotless // variants like `m21`/`m25`/`m27`) and an optional dotted minor version. return /(?:^|[/.-])m2\d*(?:[.-]\d+)?(?:[-.:_]|$)/i.test(lower); -} +}); /** MiniMax M3 family ids in bundled/default and aggregator namespace forms. */ -export function isMinimaxM3FamilyModelId(modelId: string): boolean { +export const isMinimaxM3FamilyModelId = memo((modelId: string): boolean => { const lower = modelId.toLowerCase(); if (!lower.includes("minimax")) return false; return /(?:^|[/._-])(?:minimax[/._-])?m3(?:[-.:_]|$)/i.test(lower); -} +}); /** * OpenAI gpt-oss family (`gpt-oss-20b`, `gpt-oss-120b`, `gpt-oss:120b`, @@ -99,14 +112,14 @@ export function isMinimaxM3FamilyModelId(modelId: string): boolean { * `low|medium|high` for `reasoning_effort` and rejects `minimal`, `xhigh`, * and `none`. */ -export function isOpenAIGptOssModelId(modelId: string): boolean { +export const isOpenAIGptOssModelId = memo((modelId: string): boolean => { return /(^|\/)gpt-oss[-:]/i.test(modelId); -} +}); /** OpenAI model ids (gpt-*, o1-*, o3-*, o4-*, or prefixed with openai/). */ -export function isOpenAIModelId(modelId: string): boolean { +export const isOpenAIModelId = memo((modelId: string): boolean => { return /(^|\/)(gpt|o1|o3|o4)[-.]/i.test(modelId) || modelId.toLowerCase().includes("openai/"); -} +}); /** * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` / @@ -115,7 +128,7 @@ export function isOpenAIModelId(modelId: string): boolean { * keeps newly-bumped integers (`glm-5.3`, `glm-6`, …) covered without a per-id * allowlist. */ -export function isReasoningGlmModelId(modelId: string): boolean { +export const isReasoningGlmModelId = memo((modelId: string): boolean => { const glm = parseGlmModel(bareModelId(modelId)); if (!glm || glm.vision) { return false; @@ -124,9 +137,10 @@ export function isReasoningGlmModelId(modelId: string): boolean { return false; } return semverGte(glm.version, "4.5"); -} +}); + /** GLM-5.2+ coding SKUs accept `reasoning_effort` in addition to binary thinking. */ -export function isGlm52ReasoningEffortModelId(modelId: string): boolean { +export const isGlm52ReasoningEffortModelId = memo((modelId: string): boolean => { const glm = parseGlmModel(bareModelId(modelId)); if (!glm || glm.vision) { return false; @@ -135,12 +149,13 @@ export function isGlm52ReasoningEffortModelId(modelId: string): boolean { return false; } return semverGte(glm.version, "5.2"); -} +}); /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */ -export function isGlmVisionModelId(modelId: string): boolean { +export const isGlmVisionModelId = memo((modelId: string): boolean => { return parseGlmModel(bareModelId(modelId))?.vision === true; -} +}); + /** * Coarse vendor-lineage token for "are two models the same family?" checks * (e.g. picking a cross-family reviewer). All Claude point releases share a token, @@ -152,7 +167,7 @@ export function isGlmVisionModelId(modelId: string): boolean { * Vendor-only by design: a model's kind/variant (opus vs sonnet, codex vs base) is * collapsed onto the single vendor token; use {@link parseKnownModel} for finer breakdowns. */ -export function modelFamilyToken(modelId: string): string { +export const modelFamilyToken = memo((modelId: string): string => { const parsed = parseKnownModel(modelId); if (parsed.family !== "unknown") return parsed.family; if (isClaudeModelId(modelId) || isAnthropicNamespacedModelId(modelId)) return "anthropic"; @@ -166,7 +181,7 @@ export function modelFamilyToken(modelId: string): string { if (isGemmaModelId(modelId)) return "gemma"; if (parseGlmModel(bareModelId(modelId))) return "glm"; return ""; -} +}); /** * Adaptive thinking `display` is supported starting with Claude Opus 4.7 and @@ -175,23 +190,23 @@ export function modelFamilyToken(modelId: string): string { * dashed version forms both match while bare dated ids * (`claude-opus-4-20250514` = Opus 4.0) stay excluded. */ -export function supportsAdaptiveThinkingDisplay(modelId: string): boolean { +export const supportsAdaptiveThinkingDisplay = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return false; if (isFableOrMythos(parsed.kind)) return semverGte(parsed.version, "5"); return parsed.kind === "opus" && semverGte(parsed.version, "4.7"); -} +}); /** * Returns true for Anthropic models with Opus 4.7+/Fable/Mythos API restrictions: * - Sampling parameters (temperature/top_p/top_k) return 400 error * - Thinking content is omitted by default (needs display: "summarized") */ -export function hasOpus47ApiRestrictions(modelId: string): boolean { +export const hasOpus47ApiRestrictions = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return false; return (parsed.kind === "opus" && semverGte(parsed.version, "4.7")) || isFableOrMythos(parsed.kind); -} +}); /** * Mid-conversation `role: "system"` messages (system instructions appended at @@ -200,16 +215,16 @@ export function hasOpus47ApiRestrictions(modelId: string): boolean { * models reject the role. * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages */ -export function supportsMidConversationSystemMessages(modelId: string): boolean { +export const supportsMidConversationSystemMessages = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return false; return (parsed.kind === "opus" && semverGte(parsed.version, "4.8")) || isFableOrMythos(parsed.kind); -} +}); -export function isAnthropicFableOrMythosModel(modelId: string): boolean { +export const isAnthropicFableOrMythosModel = memo((modelId: string): boolean => { const parsed = parseAnthropicModel(bareModelId(modelId)); return parsed !== null && isFableOrMythos(parsed.kind); -} +}); /** Thinking-variant token location inside a model id. */ export interface ThinkingVariantToken { @@ -245,9 +260,9 @@ export function findThinkingVariantToken(modelId: string): ThinkingVariantToken * token exists or nothing would remain. Callers MUST verify the result names * a live model. */ -export function stripThinkingVariantToken(modelId: string): string | undefined { +export const stripThinkingVariantToken = memo((modelId: string): string | undefined => { const token = findThinkingVariantToken(modelId); if (!token) return undefined; const stripped = modelId.slice(0, token.index) + modelId.slice(token.index + token.length); return stripped.length > 0 ? stripped : undefined; -} +}); diff --git a/packages/catalog/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts index 2a79113f8..f90291bf3 100644 --- a/packages/catalog/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -87,7 +87,7 @@ const ZAI_GLM_52_REASONING_EFFORT_MAP: Readonly = { [Effort.High]: "high", [Effort.XHigh]: "max", }; -const OLLAMA_CLOUD_GLM_52_REASONING_EFFORT_MAP: Readonly = { +const GLM_52_XHIGH_MAX_EFFORT_MAP: Readonly = { [Effort.XHigh]: "max", }; const MIMO_REASONING_EFFORT_MAP: Readonly = { @@ -290,11 +290,16 @@ function getModelDefinedEfforts( spec: ModelSpec, compat: CompatOf, ): readonly Effort[] | undefined { - if (isOpenAICompatReasoningApi(spec.api) && isZaiGlm52ReasoningEffortModel(spec)) { - return DEFAULT_REASONING_EFFORTS_WITH_XHIGH; - } - if (isOllamaCloudGlm52ReasoningEffortModel(spec)) { - return GLM_52_HIGH_MAX_REASONING_EFFORTS; + if (isGlm52ReasoningEffortModelId(spec.id)) { + // Z.ai/Zhipu and OpenRouter both surface GLM-5.2's full effort ladder, + // including the top `xhigh` (= "max") tier; Ollama Cloud exposes only + // high/xhigh. + if (isZaiThinkingFormat(compat) || isOpenRouterThinkingFormat(compat)) { + return DEFAULT_REASONING_EFFORTS_WITH_XHIGH; + } + if (isOllamaCloudGlm52ReasoningEffortModel(spec)) { + return GLM_52_HIGH_MAX_REASONING_EFFORTS; + } } return isOpenAICompatReasoningApi(spec.api) && (isMinimaxM2FamilyModelId(spec.id) || @@ -304,11 +309,6 @@ function getModelDefinedEfforts( : undefined; } -function isZaiGlm52ReasoningEffortModel(spec: ModelSpec): boolean { - if (!isGlm52ReasoningEffortModelId(spec.id)) return false; - return modelMatchesHost(spec, "zai") || modelMatchesHost(spec, "zhipu"); -} - function isOllamaCloudGlm52ReasoningEffortModel(spec: ModelSpec): boolean { return spec.api === "ollama-chat" && spec.provider === "ollama-cloud" && isGlm52ReasoningEffortModelId(spec.id); } @@ -338,6 +338,14 @@ function readCompatEffortMap(compat: CompatOf): EffortMap | undefined { return map && Object.keys(map).length > 0 ? map : undefined; } +function isOpenRouterThinkingFormat(compat: CompatOf): boolean { + return compat !== undefined && "thinkingFormat" in compat && compat.thinkingFormat === "openrouter"; +} + +function isZaiThinkingFormat(compat: CompatOf): boolean { + return compat !== undefined && "thinkingFormat" in compat && compat.thinkingFormat === "zai"; +} + function inferDetectedEffortMap( spec: ModelSpec, compat: CompatOf, @@ -352,32 +360,44 @@ function inferDetectedEffortMap( ? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER; } + // GLM-5.2 coding SKUs accept `reasoning_effort`, but the effort dialect is + // host-specific (verified against live endpoints): + // - Z.ai/Zhipu ("zai" dialect): the model exposes only none/high/max, so + // `xhigh` 400s — collapse minimal->none, low/medium/high->high, xhigh->max. + // - OpenRouter: `max` 400s and `xhigh` IS its max tier, so it passes `xhigh` + // through literally (no map; the tier is exposed via getModelDefinedEfforts). + // - Other openai-compat hosts (Fireworks, resellers) and Ollama Cloud keep + // their distinct lower tiers and host quirks (e.g. Fireworks rejects + // `minimal`, so `minimal->none` stays) and only remap the top `xhigh` UI + // tier onto the genuine `max` budget. Filtered to supported efforts later. + const isGlm52 = isGlm52ReasoningEffortModelId(spec.id); + if (isGlm52 && isZaiThinkingFormat(compat)) { + return ZAI_GLM_52_REASONING_EFFORT_MAP; + } if (isOllamaCloudGlm52ReasoningEffortModel(spec)) { - return OLLAMA_CLOUD_GLM_52_REASONING_EFFORT_MAP; + return GLM_52_XHIGH_MAX_EFFORT_MAP; } if (!isOpenAICompatReasoningApi(spec.api)) { return undefined; } + let map: EffortMap | undefined; if (spec.provider === "groq" && spec.id === "qwen/qwen3-32b") { - return GROQ_QWEN3_32B_REASONING_EFFORT_MAP; + map = GROQ_QWEN3_32B_REASONING_EFFORT_MAP; + } else if (isDeepseekReasoningModel(spec)) { + map = DEEPSEEK_REASONING_EFFORT_MAP; + } else if (isOpenAICompatMimoReasoningEffortModel(spec, compat)) { + map = MIMO_REASONING_EFFORT_MAP; + } else if (modelMatchesHost(spec, "openrouter")) { + map = getOpenRouterAnthropicReasoningEffortMap(spec.id); + } else if (modelMatchesHost(spec, "fireworks")) { + map = FIREWORKS_REASONING_EFFORT_MAP; } - if (isZaiGlm52ReasoningEffortModel(spec)) { - return ZAI_GLM_52_REASONING_EFFORT_MAP; + // Overlay GLM-5.2's top-tier `xhigh -> max` on the host base map, except on + // OpenRouter (xhigh IS its max tier; `max` 400s there). + if (isGlm52 && !isOpenRouterThinkingFormat(compat)) { + map = { ...map, ...GLM_52_XHIGH_MAX_EFFORT_MAP }; } - if (isDeepseekReasoningModel(spec)) { - return DEEPSEEK_REASONING_EFFORT_MAP; - } - if (isOpenAICompatMimoReasoningEffortModel(spec, compat)) { - return MIMO_REASONING_EFFORT_MAP; - } - if (modelMatchesHost(spec, "openrouter")) { - const openRouterAnthropicMap = getOpenRouterAnthropicReasoningEffortMap(spec.id); - if (openRouterAnthropicMap !== undefined) return openRouterAnthropicMap; - } - if (modelMatchesHost(spec, "fireworks")) { - return FIREWORKS_REASONING_EFFORT_MAP; - } - return undefined; + return map; } function isDeepseekReasoningModel(spec: ModelSpec): boolean { diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 7aa0e4626..dad4fae84 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -14823,7 +14823,8 @@ "xhigh" ], "effortMap": { - "minimal": "none" + "minimal": "none", + "xhigh": "max" } } }, @@ -17781,7 +17782,6 @@ "high" ], "effortRouting": { - "off": "claude-opus-4-6", "minimal": "claude-opus-4-6-thinking", "low": "claude-opus-4-6-thinking", "medium": "claude-opus-4-6-thinking", @@ -17845,6 +17845,7 @@ }, "contextWindow": 250000, "maxTokens": 64000, + "requestModelId": "claude-sonnet-4-6", "thinking": { "mode": "budget", "efforts": [ @@ -33294,6 +33295,142 @@ "high" ] } + }, + "moonshot-v1-128k": { + "id": "moonshot-v1-128k", + "name": "moonshot-v1-128k", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": null + }, + "moonshot-v1-128k-vision-preview": { + "id": "moonshot-v1-128k-vision-preview", + "name": "moonshot-v1-128k-vision-preview", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": null + }, + "moonshot-v1-32k": { + "id": "moonshot-v1-32k", + "name": "moonshot-v1-32k", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 32768, + "maxTokens": null + }, + "moonshot-v1-32k-vision-preview": { + "id": "moonshot-v1-32k-vision-preview", + "name": "moonshot-v1-32k-vision-preview", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 32768, + "maxTokens": null + }, + "moonshot-v1-8k": { + "id": "moonshot-v1-8k", + "name": "moonshot-v1-8k", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 8192, + "maxTokens": null + }, + "moonshot-v1-8k-vision-preview": { + "id": "moonshot-v1-8k-vision-preview", + "name": "moonshot-v1-8k-vision-preview", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 8192, + "maxTokens": null + }, + "moonshot-v1-auto": { + "id": "moonshot-v1-auto", + "name": "moonshot-v1-auto", + "api": "openai-completions", + "provider": "moonshot", + "baseUrl": "https://api.moonshot.ai/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": null } }, "nanogpt": { @@ -35696,6 +35833,25 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "cohere/north-mini-code": { + "id": "cohere/north-mini-code", + "name": "cohere/north-mini-code", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": null, + "maxTokens": null + }, "command-a-plus-05-2026": { "id": "command-a-plus-05-2026", "name": "command-a-plus-05-2026", @@ -49932,7 +50088,10 @@ "medium", "high", "xhigh" - ] + ], + "effortMap": { + "xhigh": "max" + } } }, "zai-org/glm-latest": { @@ -56468,7 +56627,10 @@ "medium", "high", "xhigh" - ] + ], + "effortMap": { + "xhigh": "max" + } } }, "kimi-k2.5": { @@ -58958,13 +59120,13 @@ "image" ], "cost": { - "input": 0.6799999999999999, - "output": 3.41, - "cacheRead": 0.33999999999999997, + "input": 0.67, + "output": 3.5, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262142, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -62624,13 +62786,13 @@ "image" ], "cost": { - "input": 0.6799999999999999, - "output": 3.41, - "cacheRead": 0.33999999999999997, + "input": 0.67, + "output": 3.5, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262142, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -64389,7 +64551,7 @@ "cost": { "input": 0.075, "output": 0.3, - "cacheRead": 0.037, + "cacheRead": 0.0375, "cacheWrite": 0 }, "contextWindow": 131072, @@ -67618,19 +67780,20 @@ ], "cost": { "input": 1.2, - "output": 4.199999999999999, + "output": 4.1, "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 16384, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high" + "high", + "xhigh" ] } }, @@ -67735,484 +67898,9 @@ } }, "synthetic": { - "hf:deepseek-ai/DeepSeek-R1": { - "id": "hf:deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:deepseek-ai/DeepSeek-R1-0528": { - "id": "hf:deepseek-ai/DeepSeek-R1-0528", - "name": "DeepSeek R1 (0528)", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 3, - "output": 8, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:deepseek-ai/DeepSeek-V3": { - "id": "hf:deepseek-ai/DeepSeek-V3", - "name": "DeepSeek V3", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 1.25, - "output": 1.25, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:deepseek-ai/DeepSeek-V3-0324": { - "id": "hf:deepseek-ai/DeepSeek-V3-0324", - "name": "DeepSeek V3 (0324)", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text" - ], - "cost": { - "input": 1.2, - "output": 1.2, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000 - }, - "hf:deepseek-ai/DeepSeek-V3.1": { - "id": "hf:deepseek-ai/DeepSeek-V3.1", - "name": "DeepSeek V3.1", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.56, - "output": 1.68, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:deepseek-ai/DeepSeek-V3.1-Terminus": { - "id": "hf:deepseek-ai/DeepSeek-V3.1-Terminus", - "name": "DeepSeek V3.1 Terminus", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 1.2, - "output": 1.2, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 128000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:deepseek-ai/DeepSeek-V3.2": { - "id": "hf:deepseek-ai/DeepSeek-V3.2", - "name": "DeepSeek V3.2", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.27, - "output": 0.4, - "cacheRead": 0.27, - "cacheWrite": 0 - }, - "contextWindow": 162816, - "maxTokens": 8000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "effortMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max" - } - } - }, - "hf:meta-llama/Llama-3.1-405B-Instruct": { - "id": "hf:meta-llama/Llama-3.1-405B-Instruct", - "name": "Llama-3.1-405B-Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 3, - "output": 3, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 32768, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - "hf:meta-llama/Llama-3.1-70B-Instruct": { - "id": "hf:meta-llama/Llama-3.1-70B-Instruct", - "name": "Llama-3.1-70B-Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.9, - "output": 0.9, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 32768, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - "hf:meta-llama/Llama-3.1-8B-Instruct": { - "id": "hf:meta-llama/Llama-3.1-8B-Instruct", - "name": "Llama-3.1-8B-Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.2, - "output": 0.2, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 32768, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - "hf:meta-llama/Llama-3.3-70B-Instruct": { - "id": "hf:meta-llama/Llama-3.3-70B-Instruct", - "name": "Llama-3.3-70B-Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.9, - "output": 0.9, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 128000, - "maxTokens": 32768, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - "hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - "id": "hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - "name": "Llama-4-Maverick-17B-128E-Instruct-FP8", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.22, - "output": 0.88, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 524000, - "maxTokens": 4096 - }, - "hf:meta-llama/Llama-4-Scout-17B-16E-Instruct": { - "id": "hf:meta-llama/Llama-4-Scout-17B-16E-Instruct", - "name": "Llama-4-Scout-17B-16E-Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.15, - "output": 0.6, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 328000, - "maxTokens": 4096 - }, - "hf:MiniMaxAI/MiniMax-M2": { - "id": "hf:MiniMaxAI/MiniMax-M2", - "name": "MiniMax-M2", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 196608, - "maxTokens": 131000, - "thinking": { - "mode": "effort", - "efforts": [ - "low", - "medium", - "high" - ], - "requiresEffort": true - } - }, - "hf:MiniMaxAI/MiniMax-M2.1": { - "id": "hf:MiniMaxAI/MiniMax-M2.1", - "name": "MiniMax-M2.1", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 204800, - "maxTokens": 131072, - "thinking": { - "mode": "effort", - "efforts": [ - "low", - "medium", - "high" - ], - "requiresEffort": true - } - }, - "hf:MiniMaxAI/MiniMax-M2.5": { - "id": "hf:MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax-M2.5", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.6, - "output": 3, - "cacheRead": 0.6, - "cacheWrite": 0 - }, - "contextWindow": 191488, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "low", - "medium", - "high" - ], - "requiresEffort": true - } - }, "hf:MiniMaxAI/MiniMax-M3": { "id": "hf:MiniMaxAI/MiniMax-M3", - "name": "MiniMax-M3", + "name": "MiniMaxAI/MiniMax-M3", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68240,88 +67928,9 @@ ] } }, - "hf:moonshotai/Kimi-K2-Instruct-0905": { - "id": "hf:moonshotai/Kimi-K2-Instruct-0905", - "name": "Kimi K2 0905", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text" - ], - "cost": { - "input": 1.2, - "output": 1.2, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 32768 - }, - "hf:moonshotai/Kimi-K2-Thinking": { - "id": "hf:moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 262144, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "requiresEffort": true - } - }, - "hf:moonshotai/Kimi-K2.5": { - "id": "hf:moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, "hf:moonshotai/Kimi-K2.6": { "id": "hf:moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", + "name": "moonshotai/Kimi-K2.6", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68349,39 +67958,9 @@ ] } }, - "hf:nvidia/Kimi-K2.5-NVFP4": { - "id": "hf:nvidia/Kimi-K2.5-NVFP4", - "name": "Kimi K2.5 (NVFP4)", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4": { "id": "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", - "name": "Nemotron 3 Super 120B", + "name": "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68410,7 +67989,7 @@ }, "hf:openai/gpt-oss-120b": { "id": "hf:openai/gpt-oss-120b", - "name": "GPT OSS 120B", + "name": "openai/gpt-oss-120b", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68424,7 +68003,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 128000, + "contextWindow": 131072, "maxTokens": 32768, "thinking": { "mode": "effort", @@ -68435,76 +68014,9 @@ ] } }, - "hf:Qwen/Qwen3-235B-A22B-Instruct-2507": { - "id": "hf:Qwen/Qwen3-235B-A22B-Instruct-2507", - "name": "Qwen 3 235B Instruct", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text" - ], - "cost": { - "input": 0.2, - "output": 0.6, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 256000, - "maxTokens": 32000 - }, - "hf:Qwen/Qwen3-235B-A22B-Thinking-2507": { - "id": "hf:Qwen/Qwen3-235B-A22B-Thinking-2507", - "name": "Qwen3 235B A22B Thinking 2507", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.65, - "output": 3, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 256000, - "maxTokens": 32000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "requiresEffort": true - } - }, - "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct": { - "id": "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct", - "name": "Qwen 3 Coder 480B", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": false, - "input": [ - "text" - ], - "cost": { - "input": 2, - "output": 2, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 256000, - "maxTokens": 32000 - }, "hf:Qwen/Qwen3.5-397B-A17B": { "id": "hf:Qwen/Qwen3.5-397B-A17B", - "name": "Qwen3.5-97B-A17B", + "name": "Qwen/Qwen3.5-397B-A17B", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68550,38 +68062,9 @@ "contextWindow": 262144, "maxTokens": 8192 }, - "hf:zai-org/GLM-4.6": { - "id": "hf:zai-org/GLM-4.6", - "name": "GLM 4.6", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.55, - "output": 2.19, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 200000, - "maxTokens": 64000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, "hf:zai-org/GLM-4.7": { "id": "hf:zai-org/GLM-4.7", - "name": "GLM 4.7", + "name": "zai-org/GLM-4.7", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68595,7 +68078,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 200000, + "contextWindow": 202752, "maxTokens": 64000, "thinking": { "mode": "effort", @@ -68610,7 +68093,7 @@ }, "hf:zai-org/GLM-4.7-Flash": { "id": "hf:zai-org/GLM-4.7-Flash", - "name": "GLM-4.7-Flash", + "name": "zai-org/GLM-4.7-Flash", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -68637,38 +68120,9 @@ ] } }, - "hf:zai-org/GLM-5": { - "id": "hf:zai-org/GLM-5", - "name": "GLM-5", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 1, - "output": 3, - "cacheRead": 1, - "cacheWrite": 0 - }, - "contextWindow": 196608, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, "hf:zai-org/GLM-5.1": { "id": "hf:zai-org/GLM-5.1", - "name": "GLM 5.1", + "name": "zai-org/GLM-5.1", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -69753,7 +69207,8 @@ "medium", "high", "xhigh" - ] + ], + "requiresEffort": true }, "compat": { "escapeBuiltinToolNames": true @@ -79021,14 +78476,6 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "omitReasoningEffort": false - }, "thinking": { "mode": "effort", "efforts": [ @@ -79041,6 +78488,14 @@ "effortMap": { "minimal": "low" } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "omitReasoningEffort": false } }, "grok-4.3": { @@ -79062,14 +78517,6 @@ }, "contextWindow": 1000000, "maxTokens": 1000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "omitReasoningEffort": false - }, "thinking": { "mode": "effort", "efforts": [ @@ -79082,6 +78529,14 @@ "effortMap": { "minimal": "low" } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "omitReasoningEffort": false } }, "grok-build": { @@ -79092,7 +78547,8 @@ "baseUrl": "https://api.x.ai/v1", "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -79112,6 +78568,35 @@ "supportsReasoningEffort": false } }, + "grok-build-0.1": { + "id": "grok-build-0.1", + "name": "Grok Build 0.1", + "api": "openai-responses", + "provider": "xai-oauth", + "baseUrl": "https://api.x.ai/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 256000, + "compat": { + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "omitReasoningEffort": true, + "reasoningEffortMap": { + "minimal": "low" + }, + "supportsReasoningEffort": false + } + }, "grok-composer-2.5-fast": { "id": "grok-composer-2.5-fast", "name": "Grok Composer 2.5 Fast", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 658d22492..a8ea3cbcb 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -854,8 +854,20 @@ interface XAICuratedModel { // omit/include/history replay defaults live in catalog compat so every // OpenAI-family endpoint consumes the same constraint. export const XAI_OAUTH_CURATED_MODELS: readonly XAICuratedModel[] = [ - // grok-build is text-only per the bundled catalog; omit `input` for the default. - { id: "grok-build", contextWindow: 512_000, name: "Grok Build", supportsReasoningEffort: false }, + { + id: "grok-build", + contextWindow: 512_000, + name: "Grok Build", + supportsReasoningEffort: false, + input: ["text", "image"], + }, + { + id: "grok-build-0.1", + contextWindow: 256_000, + name: "Grok Build 0.1", + supportsReasoningEffort: false, + input: ["text", "image"], + }, { id: "grok-4.3", contextWindow: 1_000_000, name: "Grok 4.3", input: ["text", "image"] }, // grok-4.20-multi-agent-0309 is text-only per the bundled catalog; omit `input` for the default. { id: "grok-4.20-multi-agent-0309", contextWindow: 2_000_000, name: "Grok 4.20 (Multi-Agent)" }, diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 5abe71911..2d5d0f574 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -283,6 +283,8 @@ export interface OpenAICompat { alwaysSendMaxTokens?: boolean; /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */ strictResponsesPairing?: boolean; + /** Whether the Responses API accepts the `detail: "original"` image hint. Default: auto-detected (false for GitHub Copilot, which rejects it with a 400). */ + supportsImageDetailOriginal?: boolean; /** * Append a trailing `# Juice: 0 !important` developer item when the caller * did not request reasoning, suppressing default reasoning on models that @@ -504,6 +506,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" + | "supportsImageDetailOriginal" | "requiresJuiceZeroHack" | "enableGeminiThinkingLoopGuard" | "whenThinking" @@ -527,6 +530,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompat { supportsLongPromptCacheRetention: boolean; strictResponsesPairing: boolean; + supportsImageDetailOriginal: boolean; requiresJuiceZeroHack: boolean; supportsObfuscationOptOut: boolean; } diff --git a/packages/catalog/src/variant-collapse.ts b/packages/catalog/src/variant-collapse.ts index 0000e8027..70c207ea7 100644 --- a/packages/catalog/src/variant-collapse.ts +++ b/packages/catalog/src/variant-collapse.ts @@ -72,15 +72,17 @@ export interface EffortVariantFamily { * efforts fall back to `requestModelId ?? id`. */ routing: Readonly>>; - /** - * Preserve curated route targets even when discovery only returned one member. - * Use only for provider contracts where the hidden twin remains requestable. - */ - preserveAbsentRoutes?: boolean; /** Explicit capability surface for the collapsed spec — no inference. */ thinking: Readonly>; /** Thinking-off requests must explicitly suppress thinking on the wire. */ suppressWhenOff?: boolean; + /** + * Preserve non-off effort routes even when discovery omits the backing member. + * Used for Cloud Code Assist `X`/`X-thinking` pairs where upstream accepts + * the `-thinking` wire id but the model-list endpoint may advertise only the + * bare id. + */ + preserveAbsentEffortRoutes?: boolean; /** Retired/recycled selector ids that alias to this family without being members. */ extraAliases?: readonly string[]; } @@ -105,7 +107,7 @@ function thinkingPair(baseId: string, name: string): EffortVariantFamily { // Thinking-off routes to the non-thinking backing id, where omitting // thinkingConfig is already correct — no suppressWhenOff. thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, - preserveAbsentRoutes: true, + preserveAbsentEffortRoutes: true, }; } @@ -523,12 +525,18 @@ export function collapseEffortVariants( const routing: Partial> = {}; let hasRouting = false; let hasEffortRoute = false; + let usedAbsentEffortRoute = false; for (const effortKey in family.routing) { const target = family.routing[effortKey as Effort | "off"]; - if (target !== undefined && (presentSet.has(target) || family.preserveAbsentRoutes) && !retired?.has(target)) { - routing[effortKey as Effort | "off"] = target; + const effort = effortKey as Effort | "off"; + const targetPresent = target !== undefined && presentSet.has(target); + const preserveAbsentEffort = + target !== undefined && effort !== "off" && family.preserveAbsentEffortRoutes === true; + if (target !== undefined && (targetPresent || preserveAbsentEffort) && !retired?.has(target)) { + routing[effort] = target; hasRouting = true; if (effortKey !== "off") hasEffortRoute = true; + if (!targetPresent && effort !== "off") usedAbsentEffortRoute = true; } } @@ -557,7 +565,11 @@ export function collapseEffortVariants( // falls back. Retired members never become the default. const defaultWireId = rawPresent.find(id => !retired?.has(id)) ?? rawPresent[0]; if (defaultWireId === family.id) { - delete collapsed.requestModelId; + if (usedAbsentEffortRoute) { + collapsed.requestModelId = defaultWireId as string; + } else { + delete collapsed.requestModelId; + } } else { collapsed.requestModelId = defaultWireId as string; } diff --git a/packages/catalog/test/anthropic-copilot-signing-compat.test.ts b/packages/catalog/test/anthropic-copilot-signing-compat.test.ts new file mode 100644 index 000000000..9b47327c2 --- /dev/null +++ b/packages/catalog/test/anthropic-copilot-signing-compat.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { buildAnthropicCompat } from "../src/compat/anthropic"; +import type { ModelSpec } from "../src/types"; + +/** + * Regression for #2851. GitHub Copilot's `anthropic-messages` proxy forwards to + * signature-enforcing Anthropic and returns full thinking signatures, so it is a + * SIGNING endpoint. It must NOT be classified `replayUnsignedThinking` — otherwise + * a stripped/unsigned historical thinking block (e.g. an end_turn-bound checkpoint + * turn) is replayed as `signature: ""` and 400s the whole request. + */ +function spec(overrides: Partial>): ModelSpec<"anthropic-messages"> { + return { + api: "anthropic-messages", + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + provider: "custom", + baseUrl: "https://llm.example.com/anthropic", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, + ...overrides, + } as ModelSpec<"anthropic-messages">; +} + +describe("#2851 anthropic compat: github-copilot is a signing endpoint", () => { + it("does NOT replay unsigned thinking for the github-copilot anthropic proxy", () => { + const compat = buildAnthropicCompat( + spec({ provider: "github-copilot", baseUrl: "https://api.githubcopilot.com" }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(false); + }); + + it("also excludes github-copilot enterprise (copilot-api.*) hosts", () => { + const compat = buildAnthropicCompat( + spec({ provider: "github-copilot", baseUrl: "https://copilot-api.ghe.example.com" }), + ); + expect(compat.replayUnsignedThinking).toBe(false); + }); + + it("still replays unsigned thinking for generic non-official reasoning endpoints (#2005, no regression)", () => { + const compat = buildAnthropicCompat(spec({ provider: "custom", baseUrl: "https://llm.example.com/anthropic" })); + expect(compat.replayUnsignedThinking).toBe(true); + }); + + it("still degrades unsigned thinking to text for official Anthropic", () => { + const compat = buildAnthropicCompat(spec({ provider: "anthropic", baseUrl: "https://api.anthropic.com" })); + expect(compat.replayUnsignedThinking).toBe(false); + expect(compat.officialEndpoint).toBe(true); + }); +}); diff --git a/packages/catalog/test/model-thinking.test.ts b/packages/catalog/test/model-thinking.test.ts index 3970e772c..1e9f3bb49 100644 --- a/packages/catalog/test/model-thinking.test.ts +++ b/packages/catalog/test/model-thinking.test.ts @@ -228,6 +228,45 @@ describe("model thinking derivation", () => { }); }); + it("maps GLM-5.2 reasoning effort per host dialect", () => { + const zai = createModel({ + id: "glm-5.2", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/paas/v4", + }); + const fireworks = createModel({ + id: "glm-5.2", + api: "openai-completions", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference/v1", + }); + const openRouter = createModel({ + id: "z-ai/glm-5.2", + api: "openrouter", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + }); + + // Z.ai dialect: the model only does none/high/max, so the lower tiers + // collapse and the top `xhigh` tier reaches `max`. + expect(zai.thinking?.effortMap).toEqual({ + minimal: "none", + low: "high", + medium: "high", + high: "high", + xhigh: "max", + }); + // Fireworks keeps its distinct lower tiers and the `minimal -> none` quirk; + // only the top `xhigh` UI tier remaps onto the genuine `max` budget. + expect(getSupportedEfforts(fireworks)).toContain(Effort.XHigh); + expect(fireworks.thinking?.effortMap).toEqual({ minimal: "none", xhigh: "max" }); + // OpenRouter rejects `max` and treats `xhigh` as its max tier: expose the + // `xhigh` tier and pass it through unmapped. + expect(getSupportedEfforts(openRouter)).toContain(Effort.XHigh); + expect(openRouter.thinking?.effortMap).toBeUndefined(); + }); + it("encodes the Gemini 3 Pro effort gap and mandatory reasoning in metadata", () => { const model = createModel({ id: "gemini-3-pro-preview", diff --git a/packages/catalog/test/variant-collapse.test.ts b/packages/catalog/test/variant-collapse.test.ts index d80bbaf14..3d0dcfd89 100644 --- a/packages/catalog/test/variant-collapse.test.ts +++ b/packages/catalog/test/variant-collapse.test.ts @@ -151,13 +151,31 @@ describe("collapseEffortVariants", () => { }); }); - it("preserves off routing for a -thinking-only claude family", () => { + it("keeps claude -thinking routing when discovery only returns the bare id", () => { + const out = collapseEffortVariants( + [memberSpec("claude-sonnet-4-6", { maxTokens: 64_000 })], + ANTIGRAVITY_VARIANT_COLLAPSE_TABLE, + ); + + expect(out).toHaveLength(1); + expect(out[0]?.id).toBe("claude-sonnet-4-6"); + expect(out[0]?.requestModelId).toBe("claude-sonnet-4-6"); + expect(out[0]?.thinking?.effortRouting).toEqual({ + off: "claude-sonnet-4-6", + minimal: "claude-sonnet-4-6-thinking", + low: "claude-sonnet-4-6-thinking", + medium: "claude-sonnet-4-6-thinking", + high: "claude-sonnet-4-6-thinking", + }); + }); + + it("keeps the thinking backing id for a -thinking-only claude family", () => { const out = collapseEffortVariants([memberSpec("claude-opus-4-6-thinking")], ANTIGRAVITY_VARIANT_COLLAPSE_TABLE); const opus = buildModel(out[0] as ModelSpec<"google-gemini-cli">); expect(out[0]?.id).toBe("claude-opus-4-6"); expect(out[0]?.requestModelId).toBe("claude-opus-4-6-thinking"); - expect(resolveWireModelId(opus, undefined)).toBe("claude-opus-4-6"); + expect(resolveWireModelId(opus, undefined)).toBe("claude-opus-4-6-thinking"); expect(resolveWireModelId(opus, Effort.High)).toBe("claude-opus-4-6-thinking"); }); @@ -166,11 +184,10 @@ describe("collapseEffortVariants", () => { const sonnet = buildModel(out[0] as ModelSpec<"google-gemini-cli">); expect(out[0]?.id).toBe("claude-sonnet-4-6"); - expect(out[0]?.requestModelId).toBeUndefined(); + expect(out[0]?.requestModelId).toBe("claude-sonnet-4-6"); expect(resolveWireModelId(sonnet, undefined)).toBe("claude-sonnet-4-6"); expect(resolveWireModelId(sonnet, Effort.High)).toBe("claude-sonnet-4-6-thinking"); }); - it("renames single-member families through requestModelId with no routing", () => { const out = collapseEffortVariants( [memberSpec("gpt-oss-120b-medium", { input: ["text"] })], diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7025ca189..f863a0741 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,158 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Added + +- Added a welcome-screen tip for the `/advisor` runtime. Tips ending in a `[NEW]` marker now render a bold rainbow `NEW!` tag (it shimmers across the welcome intro's animation frames, then settles into a still rainbow) and are weighted to surface more often in the random tip rotation. + +### Changed + +- Refined system prompt and tool documentation to improve conciseness and clarity +- Simplified tool input descriptions for browser, eval, find, and memory-edit operations +- Refactored authentication storage discovery to share logic with other pi-ai tools + ### Fixed +- Resuming a session whose project directory no longer exists (deleted or renamed worktree) no longer crashes with an unhandled `ENOENT … chdir` rejection. The resume now keeps the current working directory instead of trying to `chdir` into the missing path, across the in-session selector, the `--resume` startup picker, and `SessionManager.open`/`continueRecent`. +- Fixed streaming reflowing Markdown — a fenced mermaid diagram or a GFM table — stranding stale fragments in native scrollback once the reply scrolled past the viewport (cleared only by a full repaint / Ctrl+L). While streaming, the assistant block defaulted to commit-stable, so the transcript advertised its scrolled-off rows as durable snapshot content and the renderer committed an intermediate layout to immutable terminal history; the later re-layout (a diagram reshaping, a table re-aligning its columns) then froze that superseded fragment in scrollback. A still-streaming reply whose Markdown carries a mermaid fence or a table — detected outside fenced code blocks so ordinary code snippets are unaffected — is now commit-unstable, so it stays wholly in the repaintable live region and commits once, at its final layout, when the turn finalizes. +- Fixed `SYSTEM.md` prompt customization going through the raw system prompt override path, which dropped sections rendered by `custom-system-prompt.md` such as skills and rules ([#3014](https://github.com/can1357/oh-my-pi/issues/3014)). + +## [16.1.1] - 2026-06-19 + +### Changed + +- Migrated legacy macOS power settings to a single sleep prevention enum +- Defaulted `display.cacheMissMarker` setting to `false` to suppress the cache-miss marker by default. +- Replaced the four separate `power.preventIdleSleep`, `power.preventSystemSleep`, `power.declareUserActive`, and `power.preventDisplaySleep` boolean settings with a single cumulative `power.sleepPrevention` enum (`off` → `idle` → `display` → `system`). Each level adds the caffeinate flags of all lower levels. Existing configs are migrated automatically. + +### Fixed + +- Cache-miss marker no longer fires on providers with implicit, best-effort prompt caching (Google/Gemini, OpenAI, Fireworks). Those report `cacheWrite: 0` and drop `cacheRead` to zero intermittently as routine propagation noise that self-heals the next turn. Only explicit, prefix-controlled caches (Anthropic/Bedrock `cache_control`), which re-create the prefix on a cold turn as `cacheWrite > 0`, now surface a marker — where a zero `cacheRead` genuinely means the prefix broke. +- Fixed advisor dependent settings staying visible while `advisor.enabled` is off ([#3027](https://github.com/can1357/oh-my-pi/issues/3027)). +- Fixed LSP servers that dynamically register capabilities, including Expert, hanging semantic requests after `client/registerCapability` was rejected ([#3029](https://github.com/can1357/oh-my-pi/issues/3029)). + +## [16.1.0] - 2026-06-19 + +### Added + +- Added a prompt-cache miss marker: a slim `⊘ cache miss · tokens` divider above an assistant turn whose request lost the provider prompt cache. Detected from per-turn usage — the previous turn cached a meaningful prefix but this request read none of it back and reprocessed the prompt (e.g. after a thinking-level / service-tier / model change, tool or system-prompt change, or a history rewrite). Reports the reprocessed token count. Toggle with the `display.cacheMissMarker` setting (Appearance → Display, on by default). + +### Changed + +- Optimized preview workflows by removing forced tool choices, reducing prompt cache invalidations +- Unified injected message styling with consistent rounded outlines and icon-tagged headers +- Refreshed branch summary messages with a uniform banner style to match compaction points +- Updated skill invocation UI with a compact header, home-shortened paths, and dynamic line counts +- Refined session context to utilize history blocks instead of raw images for snapcompact summaries +- Refreshed the skill-prompt transcript message to match the rest of the TUI: an icon-tagged `✦ skill ` header (with invocation args trailing), a single meta line with a home-shortened, click-to-open path in the accent color and the prompt size in muted, and a rounded outline around the card. Replaces the old `[skill]` label with the flat `Skill:`/`Path:`/`Prompt:` key/value dump (which also leaked the absolute home directory). +- Refreshed the default custom/hook transcript message frame to a rounded, icon-tagged card: the `[customType]` bracket label is replaced by an ` ` header (📦 for extension messages, 🪝 for legacy hook-role messages) with a subtle outline, matching the skill card. Covers every extension/hook custom type without a bespoke renderer. +- Refreshed the branch-summary transcript message to render as the same slim divider banner as `/compact`, handoff, and snapcompact (`⑂ branch · ctrl+o`, summary revealed on expand) instead of a `[branch]` box. +- Optimized network traffic by stripping tool descriptions from provider tool schemas +- Renamed the prompt setting from `repeatToolDescriptions` / "Repeat Tool Descriptions" to `inlineToolDescriptors` / "Inline Tool Descriptors" and enabled inline descriptors by default. +- Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. +- Fixed `/dump` output repeating the tool inventory twice when `inlineToolDescriptors` is enabled. +- Unified TUI border corners on the rounded style: tool-result frames, overlays, code fences, debug frames, and the interactive bash box now draw rounded corners (`╭╮╰╯`) to match the editor and message cards, instead of mixing rounded boxes with sharp (`┌┐└┘`) ones. `boxRound` now carries the sharp tee/cross junction glyphs (no rounded variant exists), so dividers still honor `boxSharp.tee*`/`cross` theme overrides. Markdown tables intentionally keep the fully sharp `boxSharp` set; its corner tokens now affect tables only. + +## [16.0.11] - 2026-06-19 + +### Added + +- Added `__advisor.jsonl` transcript persistence for advisor model usage attribution and visibility in the Agent Hub +- Added defensive reservation against naming a task `__advisor` to prevent filesystem collisions with internal transcripts +- Added `mode` property to the `CompactOptions` interface for extension-based compaction control +- Updated `/compact` slash command to support `soft`, `remote`, and `snapcompact` subcommands for per-run strategy overrides +- Added `/compact` mode subcommands so a manual compaction can override the configured `compaction.strategy`/`remoteEnabled` for that one run: `/compact soft` (summarize locally, skip remote endpoints), `/compact remote` (summarize via the remote endpoint / provider-native compaction; warns and falls back to a local summary when no remote path is available), and `/compact snapcompact` (archive history onto dense bitmap images, no LLM call). Bare `/compact` and `/compact ` keep their existing behavior; `soft`/`remote` still accept trailing focus instructions, `snapcompact` rejects them. The subcommands are advertised to ACP clients and surface in TUI autocomplete. Extensions can pass the same selection via `compact(instructions, { mode })`. +- The advisor now persists its own turns to a subagent-style transcript (`/__advisor.jsonl`) so the advisor model's usage is attributed in `omp stats` and its transcript is observable in the Agent Hub (read-only). The transcript follows session switches (`/new`, resume, branch) and is drained/released before a `/drop` deletes the old artifacts dir. The advisor remains a non-peer: it is hidden from agent-facing rosters (`irc` list, `history://`, subagent peer prompt, broadcast targets), is not messageable (`irc send` / collab chat), and is not revivable/killable from the Hub or collab. + +### Changed + +- Standardized file elision markers to a compact `[…Nln elided…]` format +- Updated transcript viewer to clear cached content if the session file is deleted while opened +- Refactored agent hub to use a fullscreen overlay for agent transcripts instead of inline chat +- Improved transcript rendering stability by using a non-incremental builder for viewer replays +- Advisor transcripts are now excluded from agent-facing surfaces like `irc`, `history://`, and peer rosters +- Advisor transcripts are read-only and cannot be messaged, revived, or killed via the Agent Hub or IRC +- Refined `/compact` argument parsing to reject focus instructions for modes that do not support them (e.g., `snapcompact`) +- Protocol hosts (RPC/`rpc-ui`/ACP) now host-default the full advisor settings group — `advisor.syncBacklog` and `advisor.immuneTurns` in addition to `advisor.enabled`/`advisor.subagents` — so a host that opts the advisor in gets the default tuning instead of inheriting the user's local advisor preferences. + +### Fixed + +- Fixed memory-leaking stale transcripts in the agent viewer when underlying files are deleted +- Fixed the Agent Hub transcript viewer rendering the transcript body one column right of the "Agent Hub" title (and the title appearing to shift when scrolled to the top): the fullscreen viewer added its own outer gutter on top of the transcript rows, which already carry a 1-column left pad, so the header and body no longer shared a gutter. The viewer now renders the scroll body at full width without the extra gutter, and the file-mention row carries the same 1-column pad as every other row. +- Fixed the bash tool failing with `pi-natives:command: syntax error at end of input` on a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The minimizer's segmented-chain runner rebuilds each chain segment from the brush AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator and re-run as broken shell. Every stage is now required to be a Display-safe simple command, and — as a general guard against the recurring class of brush `Display` round-trip divergences (previously: quoted here-doc close tags, multi-byte char/byte offsets) — each reconstructed segment is now re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole, unsegmented, instead of corrupting it. +- Fixed `Ctrl+T` (toggle thinking blocks) and the `/settings` "Hide Thinking Blocks" toggle only collapsing/expanding thinking in the live region: blocks that had scrolled into committed native scrollback on ED3-risk terminals kept their pre-toggle snapshot, so scrolling up showed the old thinking state. Both paths now `resetDisplay()` after flipping each block's flag, forcing a full clear + replay of the whole transcript (matching the tool-output expansion toggle) so every block above the fold re-renders at its new height. +- Fixed ACP mobile voice settings being unable to call `speech.models.list` by exposing the local STT/TTS model and voice catalog without triggering setup or downloads ([#3011](https://github.com/can1357/oh-my-pi/issues/3011)). +- Fixed the collapsed inline arg preview used by tools without a custom renderer (e.g. `advise`, MCP tools) truncating every value at a fixed 24 columns, so a long note was cut to `note="Your “stric…"` even on a wide card with empty space to spare. Each value now grows into the width the card actually has, reserving only a small slice for the keys that still follow so a long leading value can't hide them. + +### Removed + +- Removed `display.tabWidth` setting and configurable tab width support + +## [16.0.10] - 2026-06-18 + +### Added + +- Updated the `pi` option of the `tools.format` setting to use the new compact sigil-delimited owned tool-calling dialect (`§`/`«…»`/`¤`/`‡‡`) that uses ~46% fewer tokens than the legacy format on typical calls. +- Integrated terminal QR codes directly into `/collab` and `/collab view` to display both deep links and scannable codes, and added a `collab.webUrl` setting for separately hosted collab web clients. + +### Changed + +- Updated description of Pi dialect in tool-calling settings to clarify compact sigil format + +### Fixed + +- Fixed legacy `settings.json`, `models.json`, and `keybindings.json` migration/loading to accept comments in JSON config files. +- Support quoted file paths with spaces (such as default macOS screenshots) in file mentions and `@`-prefixed CLI arguments ([#2909](https://github.com/can1357/oh-my-pi/pull/2909)). +- Fixed the first steering/follow-up message typed as auto-compaction begins being dropped instead of queued; the compaction abort controller (which backs `isCompacting`) is now installed before the `auto_compaction_start` event fires, so input routed during that window lands in the compaction queue rather than the core agent queue. +- Fixed queued steering/follow-up messages being silently cleared by the agent reset during a handoff; they are now captured and restored across the new-session reset. +- Fixed the `/login` provider list still offering providers listed in `disabledProviders` (and their credential-alias logins, e.g. `openai-codex-device` → `openai-codex`) as login targets ([#2906](https://github.com/can1357/oh-my-pi/pull/2906)). +- Fixed an owned MCP manager not being disconnected on `AgentSession.dispose()`, orphaning stdio MCP subprocesses across session teardown (`/exit`, print mode, subagent teardown); the dispose-time disconnect is now bounded so a slow HTTP/SSE transport close cannot stall exit ([#2839](https://github.com/can1357/oh-my-pi/pull/2839)). +- Fixed `inspect_image` resolving pasted image labels such as `Image #1`, `[Image #1, WxH]`, and `attachment://1` against current chat attachments instead of falling back to `/Image #1` ([#2787](https://github.com/can1357/oh-my-pi/issues/2787)). +- Accepted `max` as an alias for the top thinking level (`xhigh`) in selectors and `--thinking`, so DeepSeek V4 Pro can be selected with its provider-facing maximum effort ([#2727](https://github.com/can1357/oh-my-pi/issues/2727)). +- Fixed `--provider=amazon-bedrock --model ` being rejected as an unknown model before the Bedrock provider could send the ARN to Converse Stream. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004)) + +## [16.0.9] - 2026-06-18 + +### Added + +- Added a `--print-thoughts` flag for single-shot print mode that includes sanitized thinking blocks in the text output; default print output stays the final answer only. When thinking is hidden by the `hideThinkingBlock` setting, `--print-thoughts` un-hides it for the print run so the flag is not a silent no-op (an explicit `--hide-thinking` still wins). + +### Fixed + +- Fixed active `/goal` mode being paused by internal compaction and session-switch lifecycle aborts, and made those switches persist wall-clock goal usage without charging time spent in another session to a preserved goal. + +## [16.0.8] - 2026-06-18 + +### Changed + +- Optimized app startup time by lazily loading site-specific scraper modules +- Refactored internal archive handling into a unified `src/utils/zip.ts` module +- Centralized all `fflate` (ZIP) and `Bun.Archive` (tar/tar.gz) operations into `zip.ts` +- Optimized archive reading by using lazy, ranged central-directory access for ZIP files +- Updated internal image processing to no longer include metadata text for fetched images +- Optimized `omp://` documentation indexing by compressing doc bodies into a lazily-inflated blob +- Changed Mermaid fenced-block ASCII rendering to use the first-party vendored renderer in `@oh-my-pi/pi-utils` (`src/vendor/mermaid-ascii`), dropping the `beautiful-mermaid` npm package, its transitive `elkjs` (~3.13MB), and the `beautiful-mermaid` `bun patch`; CJK/emoji width handling and the layout-direction override are preserved. +- Changed `omp://` documentation embedding to a gzipped base64 index (`docs-index.generated.txt`, populated at build time and reset afterward) inflated on first read, instead of a ~1.6MB raw TypeScript map. The compiled binary / npm bundle drops ~0.9MB; the dev tree and source checkouts read `docs/` from disk. +- Replaced the `markit-ai` package with a vendored in-house document engine (`src/markit`) for the document formats it converts: PDF (via `mupdf`), DOCX (via `mammoth`), and PPTX/XLSX/EPUB (via `fast-xml-parser`). Conversion output is preserved, including the PDF column/table-detection pipeline and HTML-table normalization; `markit-ai`'s unused converters and their dependency tail are gone. Legacy `.doc`/`.ppt`/`.xls`/`.rtf` remain unsupported (a conversion error), as before. +- Centralized ZIP handling behind a single `src/utils/zip.ts` (`fflate`): the new document converters, the `write` tool's in-place archive editing, and the `read` tool's ranged archive reader now share one ZIP implementation instead of mixing `jszip` and `fflate`. + +### Fixed + +- Fixed settings overlay crash when scrolling past the last row in list views +- Improved tool result formatting by correctly wrapping `` blocks in dim-ink toggles +- Fixed auto-retry after transient model-stream socket closes to replay text/thinking-only partial assistant output, including turns where incomplete tool-call arguments were dropped; completed tool calls still block retry to avoid duplicating tool execution. - Fixed `omp update` reporting `EPERM: operation not permitted, unlink '.bak'` on Windows when self-replacing a standalone binary, even though the new binary had already been installed. The backed-up old executable is still the running process image and cannot be unlinked until the process exits, so the post-verify backup cleanup is now best-effort, backups use a unique per-attempt name, and stale backups are swept on the next update ([#845](https://github.com/can1357/oh-my-pi/issues/845)). - Fixed plan-mode `Refine plan` so the internal approval abort is hidden and the editor is ready for a follow-up prompt instead of showing `Operation aborted` ([#2971](https://github.com/can1357/oh-my-pi/issues/2971)). - Fixed TUI prompts beginning with shell-style variables such as `$HOME` being misrouted to Python eval; Python shortcuts now require `$ ` or `$$ `. ([#2944](https://github.com/can1357/oh-my-pi/issues/2944)) - Fixed LM Studio runtime discovery to use native `/api/v0/models` metadata so `inspect_image` can select VLM models. ([#2945](https://github.com/can1357/oh-my-pi/issues/2945)) - Fixed `omp` crashing at startup with `Cannot find module './browser-data/browser-data.js'` (e.g. on Termux/proot Linux arm64). `tools/browser/attach.ts` value-imported the `TargetType` enum from `puppeteer-core`, which dragged the puppeteer-core barrel (and its `@puppeteer/browsers` Chromium downloader) back onto the eager startup import graph — defeating the lazy-load deferral and turning any bundling quirk in that subtree into a fatal boot crash instead of a recoverable browser-tool error. The import is now `import type` and the target check compares against the `"page"` literal, so puppeteer only loads on first browser use. +- Fixed the standalone compiled binary aborting at startup with `error: Cannot find package 'mupdf'`. The vendored document engine kept `mupdf` external, but a single-file `bun --compile` binary has no `node_modules` to resolve it from, and the bundler resolves the otherwise-lazy import eagerly at boot. `mupdf` (and its WASM module, embedded at build time via `scripts/embed-mupdf-wasm.ts`) is now bundled into the binary, so startup and PDF/document conversion both work in the standalone binary; npm and source installs still load `mupdf` from `node_modules`. `mupdf` is also imported lazily inside the PDF converter now, which fixes a compiled-bundle init-order hazard — `mupdf`'s top-level await made the bundled markit chunk's module init async, exposing the converters before their extension tables initialized — and keeps the ~10MB WASM off non-PDF document conversions. + +### Removed + +- Removed the `markit-ai`, `exifr`, `music-metadata`, and direct `jszip` dependencies. As a side effect, fetching an image or audio URL no longer appends EXIF/audio metadata text; image inlining and resizing are unchanged, and document conversion (PDF/DOCX/PPTX/XLSX/EPUB) is unaffected. ## [16.0.7] - 2026-06-18 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e70754362..277fb3feb 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "16.0.7", + "version": "16.1.2", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -35,12 +35,13 @@ "check": "biome check . && bun run check:types", "check:types": "tsgo -p tsconfig.json --noEmit", "lint": "biome lint .", - "test": "bun test --parallel=4", - "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index", + "test": "bun test --parallel=4 test src", + "fix": "biome check --write --unsafe . && bun run format-prompts", "fmt": "biome format --write . && bun run format-prompts", "format-prompts": "bun scripts/format-prompts.ts", - "generate-docs-index": "bun scripts/generate-docs-index.ts", - "prepack": "bun scripts/generate-docs-index.ts && bun --cwd=../collab-web run build:tool-views && bun scripts/bundle-dist.ts", + "generate-docs-index": "bun scripts/generate-docs-index.ts --generate", + "prepack": "bun scripts/generate-docs-index.ts --generate && bun --cwd=../collab-web run build:tool-views && bun scripts/bundle-dist.ts || ( bun scripts/generate-docs-index.ts --reset; exit 1 )", + "postpack": "bun scripts/generate-docs-index.ts --reset", "bench:guard": "bun scripts/bench-guard.ts" }, "dependencies": { @@ -70,11 +71,12 @@ "arktype": "catalog:", "chalk": "catalog:", "diff": "catalog:", - "fflate": "catalog:", + "fast-xml-parser": "catalog:", "handlebars": "catalog:", "linkedom": "catalog:", "lru-cache": "catalog:", - "markit-ai": "catalog:", + "mammoth": "catalog:", + "mupdf": "catalog:", "puppeteer-core": "catalog:", "turndown": "catalog:", "turndown-plugin-gfm": "catalog:", diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index 17e0f0cd2..7a5779b28 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -38,9 +38,13 @@ async function runCommand( } async function main(): Promise { - await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]); + // Generate inside the try so the finally always restores the empty checked-in + // placeholders (stats client archive, docs index) even on failure. try { + await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]); + await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]); await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]); + await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]); try { const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env; await runCommand( @@ -58,8 +62,6 @@ async function main(): Promise { "--define", `process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`, "--external", - "mupdf", - "--external", "fastembed", "--external", "onnxruntime-node", @@ -94,10 +96,12 @@ async function main(): Promise { await runCommand(["codesign", "--force", "--sign", "-", outputPath]); } } finally { + await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--reset"]); await runCommand(["bun", "--cwd=../natives", "run", "embed:native", "--reset"]); } } finally { await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--reset"]); + await runCommand(["bun", "scripts/generate-docs-index.ts", "--reset"]); } } diff --git a/packages/coding-agent/scripts/bundle-dist.ts b/packages/coding-agent/scripts/bundle-dist.ts index 19973a180..322121802 100755 --- a/packages/coding-agent/scripts/bundle-dist.ts +++ b/packages/coding-agent/scripts/bundle-dist.ts @@ -19,8 +19,8 @@ const ALWAYS_EXTERNAL = ["mupdf", "@oh-my-pi/pi-natives", "@huggingface/transfor // redundant copy that bloats dist/cli.js. NEVER add a patched dependency here — the // bundle is where a root `patchedDependencies` patch is baked in, so an externalized // import would load the unpatched npm package in users' installs (currently -// beautiful-mermaid and @ark/schema are patched, so they — and arktype, which pulls -// @ark/schema — stay bundled). +// @ark/schema is patched, so it — and arktype, which pulls @ark/schema — stay +// bundled). const RUNTIME_EXTERNAL = [ "puppeteer-core", "@puppeteer/browsers", @@ -30,7 +30,6 @@ const RUNTIME_EXTERNAL = [ "turndown-plugin-gfm", "@mozilla/readability", "linkedom", - "markit-ai", "@agentclientprotocol/sdk", ]; diff --git a/packages/coding-agent/scripts/embed-mupdf-wasm.ts b/packages/coding-agent/scripts/embed-mupdf-wasm.ts new file mode 100755 index 000000000..cb6f6c4ed --- /dev/null +++ b/packages/coding-agent/scripts/embed-mupdf-wasm.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun + +// Embeds mupdf's `mupdf-wasm.wasm` into the compiled single-file binary. +// +// mupdf loads its wasm by reading the `mupdf-wasm.wasm` sibling of its own +// module via `new URL(..., import.meta.url)` + `readFileSync`. A `bun --compile` +// binary has no node_modules, so that read fails (`ENOENT .../mupdf-wasm.wasm`), +// and marking mupdf `--external` instead makes `bun --compile` eagerly fail to +// resolve the package at startup (the static `import * as mupdf` lives in a lazy +// chunk but is hoisted). So the binary build bundles mupdf and embeds the wasm +// bytes here, handing them to the WASM module as `$libmupdf_wasm_Module.wasmBinary` +// (see src/utils/markit.ts). +// +// `--generate` copies the wasm next to src/utils/mupdf-wasm-embed.ts and rewrites +// that module to import it via `with { type: "file" }`; `--reset` restores the +// checked-in placeholder and removes the copy. The npm `dist/cli.js` bundle never +// runs this — it keeps mupdf external and loads the wasm from node_modules. + +import * as fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import * as path from "node:path"; + +const utilsDir = path.join(import.meta.dir, "..", "src", "utils"); +const helperPath = path.join(utilsDir, "mupdf-wasm-embed.ts"); +const wasmCopyPath = path.join(utilsDir, "mupdf-wasm.wasm"); + +const placeholder = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand. +// +// Compiled single-file binaries cannot let mupdf resolve its \`mupdf-wasm.wasm\` +// sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts +// and scripts/ci-release-build-binaries.ts) regenerates this module to embed the +// wasm bytes via \`with { type: "file" }\` and copies the wasm next to it. Source +// checkouts, \`bun test\`, and the npm \`dist/cli.js\` bundle keep mupdf external and +// load the wasm from node_modules, so this placeholder returns undefined and the +// build resets back to it afterward. +export function loadEmbeddedMupdfWasm(): Uint8Array | undefined { +\treturn undefined; +} +`; + +const generated = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit or commit. +import { readFileSync } from "node:fs"; +import wasmPath from "./mupdf-wasm.wasm" with { type: "file" }; + +export function loadEmbeddedMupdfWasm(): Uint8Array | undefined { +\treturn readFileSync(wasmPath); +} +`; + +if (process.argv.includes("--reset")) { + await Bun.write(helperPath, placeholder); + try { + await fs.unlink(wasmCopyPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + process.exit(0); +} + +const wasmSource = path.join(path.dirname(createRequire(import.meta.url).resolve("mupdf")), "mupdf-wasm.wasm"); +const wasmFile = Bun.file(wasmSource); +if (!(await wasmFile.exists())) { + throw new Error(`mupdf wasm not found at ${wasmSource}; run \`bun install\` first.`); +} +await Bun.write(wasmCopyPath, wasmFile); +await Bun.write(helperPath, generated); +console.log(`Embedded mupdf wasm (${wasmFile.size} bytes) into ${path.relative(process.cwd(), wasmCopyPath)}`); diff --git a/packages/coding-agent/scripts/generate-docs-index.ts b/packages/coding-agent/scripts/generate-docs-index.ts old mode 100644 new mode 100755 index cd5e8ba60..840f2d013 --- a/packages/coding-agent/scripts/generate-docs-index.ts +++ b/packages/coding-agent/scripts/generate-docs-index.ts @@ -1,40 +1,56 @@ #!/usr/bin/env bun +/** + * Populate (or reset) the embedded harness documentation index for `omp://`. + * + * `--generate` writes `src/internal-urls/docs-index.generated.txt` as two lines: + * a plain JSON array of the sorted `docs/**\/*.md` file names, then a base64 + * gzip blob of the index-aligned doc bodies (`string[]`). Keeping the filename + * list out of the blob lets the loader list docs without inflating it. + * Compiled binaries and the prepacked npm bundle inline this (~0.5MB) instead of + * the ~1.6MB raw map; `--reset` restores the checked-in empty placeholder so the + * dev tree reads `docs/` from disk. Mirrors the stats / model-catalog embeds. + */ + import * as path from "node:path"; +import { gzipSync } from "node:zlib"; import { Glob } from "bun"; const docsDir = path.resolve(import.meta.dir, "../../../docs"); -const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.ts"); +const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.txt"); +const GENERATE_FLAG = "--generate"; +const RESET_FLAG = "--reset"; -const glob = new Glob("**/*.md"); -const entries: string[] = []; -for await (const relativePath of glob.scan(docsDir)) { - entries.push(relativePath.split(path.sep).join("/")); +async function main(): Promise { + const rel = path.relative(process.cwd(), outputPath); + + if (process.argv.includes(RESET_FLAG)) { + await Bun.write(outputPath, ""); + console.log(`Reset ${rel}`); + return; + } + + if (!process.argv.includes(GENERATE_FLAG)) { + console.log(`Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)`); + return; + } + + const glob = new Glob("**/*.md"); + const files: string[] = []; + for await (const relativePath of glob.scan(docsDir)) { + files.push(relativePath.split(path.sep).join("/")); + } + files.sort(); + + // Index-aligned bodies (Promise.all preserves order), kept separate from the + // filename list so the loader can list docs without inflating the blob. + const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text())); + + const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64"); + // Two lines: plain filename array, then the base64 gzip blob. + const payload = `${JSON.stringify(files)}\n${bodiesB64}`; + await Bun.write(outputPath, payload); + console.log(`Generated ${rel} (${files.length} docs, ${payload.length} bytes)`); } -entries.sort(); -const docsWithContent = await Promise.all( - entries.map(async relativePath => ({ - relativePath, - content: await Bun.file(path.join(docsDir, relativePath)).text(), - })), -); - -const filenamesLiteral = JSON.stringify(entries); - -const mapEntries = docsWithContent - .map(({ relativePath, content }) => `\t${JSON.stringify(relativePath)}: ${JSON.stringify(content)},`) - .join("\n"); -const output = [ - "// Auto-generated by scripts/generate-docs-index.ts - DO NOT EDIT", - "", - `export const EMBEDDED_DOC_FILENAMES: readonly string[] = ${filenamesLiteral};`, - "", - `export const EMBEDDED_DOCS: Readonly> = {`, - `${mapEntries}`, - `};`, - "", -].join("\n"); - -await Bun.write(outputPath, output); -console.log(`Generated ${path.relative(process.cwd(), outputPath)} (${entries.length} docs)`); +await main(); diff --git a/packages/coding-agent/scripts/measure-prompt-tokens.ts b/packages/coding-agent/scripts/measure-prompt-tokens.ts new file mode 100644 index 000000000..f99cfb3cf --- /dev/null +++ b/packages/coding-agent/scripts/measure-prompt-tokens.ts @@ -0,0 +1,63 @@ +import { countTokens } from "@oh-my-pi/pi-agent-core"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; +import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt"; +import { createTools, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; + +function bytes(s: string): number { + return Buffer.byteLength(s, "utf-8"); +} +function est(s: string): number { + return (bytes(s) + 3) >> 2; +} + +await Settings.init({ inMemory: true, cwd: process.cwd() }); +const settings = Settings.isolated({}); + +const session: ToolSession = { + cwd: process.cwd(), + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings, +} as ToolSession; + +const tools = await createTools(session); +const toolsMap = new Map(tools.map(t => [t.name, t])); + +console.log(`active tools (${tools.length}): ${tools.map(t => t.name).join(", ")}\n`); + +const rows: Array<{ name: string; descBytes: number; tok: number; schemaTok: number }> = []; +for (const t of tools) { + const tok = estimateToolSchemaTokens([t as never]); + const descBytes = bytes(t.description ?? ""); + const descTok = est(t.description ?? ""); + rows.push({ name: t.name, descBytes, tok, schemaTok: tok - descTok }); +} +rows.sort((a, b) => b.tok - a.tok); + +const totalTok = estimateToolSchemaTokens(tools as never); +console.log("per-tool tokens (sorted): name | total tok | desc bytes | ~schema tok"); +for (const r of rows) { + console.log( + ` ${r.name.padEnd(20)} ${String(r.tok).padStart(6)} ${String(r.descBytes).padStart(7)} ${String(r.schemaTok).padStart(6)}`, + ); +} +console.log(`\nTOOLS TOTAL tokens: ${totalTok}\n`); + +const built = await buildSystemPrompt({ + tools: toolsMap as never, + toolNames: tools.map(t => t.name), + inlineToolDescriptors: false, + nativeTools: true, + cwd: process.cwd(), + skills: [], + contextFiles: [], + workspaceTree: { rootPath: process.cwd(), rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }, +}); +const parts = built.systemPrompt; +const part0 = parts[0] ?? ""; +const rest = parts.slice(1).join("\n"); +console.log(`system prompt parts: ${parts.length}`); +console.log(`SYSTEM PROMPT tokens (part0, no skills): ${countTokens(part0)} (bytes=${bytes(part0)})`); +console.log(`SYSTEM CONTEXT tokens (parts[1..]): ${countTokens(rest)} (bytes=${bytes(rest)})`); diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 774073128..7478577a8 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -106,6 +106,32 @@ describe("advisor", () => { yq.enqueue("normal", { note: "b" }); expect(scheduled).toBe(1); }); + + it("clear(kind) drops only that kind's queued entries", () => { + const yq = new YieldQueue({ + isStreaming: () => false, + injectIdle: async () => {}, + scheduleIdleFlush: () => {}, + }); + yq.register<{ note: string }>("advisor", { + build: entries => (entries.length === 0 ? null : ({ role: "custom", content: "x" } as AgentMessage)), + skipIdleFlush: true, + }); + yq.register<{ note: string }>("normal", { + build: entries => (entries.length === 0 ? null : ({ role: "custom", content: "y" } as AgentMessage)), + }); + + yq.enqueue("advisor", { note: "stale advice" }); + yq.enqueue("normal", { note: "keep me" }); + expect(yq.has("advisor")).toBe(true); + expect(yq.has("normal")).toBe(true); + + // Conversation-boundary cleanup must drop advisor deliveries without + // touching other kinds (IRC asides, async-job/diagnostic deliveries). + yq.clear("advisor"); + expect(yq.has("advisor")).toBe(false); + expect(yq.has("normal")).toBe(true); + }); }); describe("AdviseTool", () => { @@ -659,6 +685,63 @@ describe("advisor", () => { expect(promptInputs).toHaveLength(3); expect(runtime.backlog).toBe(0); }); + + it("drops the in-flight batch when a reset aborts the advisor prompt", async () => { + const promptInputs: string[] = []; + const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers(); + let rejectInFlight: ((err: unknown) => void) | undefined; + let promptCalls = 0; + const agent: AdvisorAgent = { + prompt: input => { + promptInputs.push(input); + promptCalls++; + if (promptCalls === 1) { + const { promise, reject } = Promise.withResolvers(); + rejectInFlight = reject; + startFirstPrompt(); + return promise; + } + return Promise.resolve(); + }, + // AdvisorRuntime.reset() calls agent.reset() then agent.abort(); the real + // Agent.abort rejects the awaited prompt, so model that rejection here. + abort: () => rejectInFlight?.(new Error("advisor reset")), + reset: () => {}, + state: { messages: [] }, + }; + const messages: AgentMessage[] = [{ role: "user", content: "old-conversation", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + }; + const runtime = new AdvisorRuntime(agent, host, 0); + + runtime.onTurnEnd(messages); + await firstPromptStarted; + expect(promptInputs).toHaveLength(1); + expect(promptInputs[0]).toContain("old-conversation"); + + // Conversation boundary (/new): transcript replaced and the runtime reset + // while the advisor prompt is still in flight. The abort that rejects the + // prompt is the reset itself — it must NOT be treated as a transient + // failure that requeues and re-sends the stale pre-reset batch. + messages.length = 0; + messages.push({ role: "user", content: "new-conversation", timestamp: 2 } as AgentMessage); + runtime.reset(); + await Bun.sleep(0); + await Bun.sleep(0); + + expect(promptInputs).toHaveLength(1); + expect(runtime.backlog).toBe(0); + + // The runtime still works afterward: the next turn replays the new + // transcript only, never the dropped pre-reset content. + runtime.onTurnEnd(messages); + await Bun.sleep(0); + expect(promptInputs).toHaveLength(2); + expect(promptInputs[1]).toContain("new-conversation"); + expect(promptInputs[1]).not.toContain("old-conversation"); + }); }); describe("read-only tool allowlist", () => { diff --git a/packages/coding-agent/src/advisor/index.ts b/packages/coding-agent/src/advisor/index.ts index 30dd105e5..3a7362afb 100644 --- a/packages/coding-agent/src/advisor/index.ts +++ b/packages/coding-agent/src/advisor/index.ts @@ -1,3 +1,4 @@ export * from "./advise-tool"; export * from "./runtime"; +export * from "./transcript-recorder"; export * from "./watchdog"; diff --git a/packages/coding-agent/src/advisor/runtime.ts b/packages/coding-agent/src/advisor/runtime.ts index 470348b7b..7768c0150 100644 --- a/packages/coding-agent/src/advisor/runtime.ts +++ b/packages/coding-agent/src/advisor/runtime.ts @@ -48,6 +48,11 @@ export class AdvisorRuntime { #consecutiveFailures = 0; #latestMessages?: AgentMessage[]; #waiters: CatchupWaiter[] = []; + /** Bumped by every external {@link reset}/{@link dispose}. A drain iteration + * captures it before its awaits; a mismatch on resume means a reset aborted + * the in-flight advisor prompt, so the stale batch is dropped instead of + * being retried/requeued into the post-reset conversation. */ + #epoch = 0; disposed = false; constructor( @@ -95,6 +100,7 @@ export class AdvisorRuntime { dispose(): void { this.disposed = true; + this.#epoch++; this.#pending = []; this.#backlog = 0; this.#consecutiveFailures = 0; @@ -130,6 +136,7 @@ export class AdvisorRuntime { * leaving it blind to everything before the rewrite. */ reset(): void { + this.#epoch++; this.#resetAdvisorContext(true, true); } @@ -187,6 +194,7 @@ export class AdvisorRuntime { try { while (!this.disposed && this.#pending.length) { const popped = this.#pending.splice(0); + const epoch = this.#epoch; // Each delta already opens with a `### Session update` heading, so // join with a blank line rather than a `---` rule. const candidateBatch = popped.map(b => b.text).join("\n\n"); @@ -205,6 +213,8 @@ export class AdvisorRuntime { logger.debug("advisor context maintenance failed", { err: String(err) }); } } + // A reset/dispose during context maintenance invalidates this batch. + if (this.#epoch !== epoch) continue; let batch: string | null; let finalTurns: number; @@ -231,6 +241,11 @@ export class AdvisorRuntime { success = true; this.#consecutiveFailures = 0; } catch (err) { + // reset()/dispose() aborts the in-flight prompt; the rejection is the + // reset itself, not a transient advisor failure. Drop the stale batch + // (reset already cleared #pending and rewound the cursor) instead of + // requeuing it into the post-reset conversation. + if (this.#epoch !== epoch) continue; logger.debug("advisor turn failed", { err: String(err) }); this.#consecutiveFailures++; if (this.#consecutiveFailures >= 3) { @@ -243,7 +258,7 @@ export class AdvisorRuntime { } } - if (success) { + if (success && this.#epoch === epoch) { this.#backlog = Math.max(0, this.#backlog - finalTurns); this.#notifyWaiters(); } diff --git a/packages/coding-agent/src/advisor/transcript-recorder.ts b/packages/coding-agent/src/advisor/transcript-recorder.ts new file mode 100644 index 000000000..26b03d78e --- /dev/null +++ b/packages/coding-agent/src/advisor/transcript-recorder.ts @@ -0,0 +1,136 @@ +import * as path from "node:path"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { Message, UserMessage } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; +import { SessionManager } from "../session/session-manager"; + +/** + * Reserved transcript stem for advisor session files. Chosen so it cannot + * collide with a task subagent's `.jsonl` (task ids are reserved against + * this exact stem in {@link AgentOutputManager}). + */ +export const ADVISOR_TRANSCRIPT_STEM = "__advisor"; +export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`; + +const JSONL_SUFFIX = ".jsonl"; + +/** + * Append-only persister for an advisor agent's transcript. + * + * The advisor is a passive reviewer with its own model usage, so — like a task + * subagent — its turns are written to a JSONL inside the owning session's + * artifacts dir (`/__advisor.jsonl`, `//__advisor.jsonl` + * for subagent advisors). That single file gives the advisor model proper usage + * attribution in `omp stats` (the stats parser scans the session dir + * recursively) and a read-only transcript in the Agent Hub, without making the + * advisor a registered, messageable peer. + * + * The target is derived from the *session file* (`getSessionFile()`), never + * `getArtifactsDir()` — subagents adopt the parent's artifact manager, so the + * artifacts dir points at the parent root and every subagent advisor would + * collide. The file path is resolved synchronously when a message finalizes and + * captured for the queued write, so a `/new`, resume, or session switch in + * flight can never misattribute an old advisor turn into the new session's file. + * On such a switch the previous writer is closed and the new file opened on the + * next recorded turn. The recorder never truncates: the advisor's in-memory + * context resets/compacts independently, but every billed turn is appended here. + */ +export class AdvisorTranscriptRecorder { + #manager: SessionManager | undefined; + #file: string | undefined; + /** Serializes the async open/close against synchronous appends so records land in order. */ + #queue: Promise; + + /** + * @param after Optional barrier the queue starts behind — used on the advisor + * on→off→on toggle so a fresh recorder's first `open` waits for the prior + * recorder's `close` and the two never hold the same `__advisor.jsonl` at once. + */ + constructor( + private readonly resolveSessionFile: () => string | undefined, + private readonly resolveCwd: () => string, + after?: Promise, + ) { + this.#queue = after + ? after.then( + () => {}, + () => {}, + ) + : Promise.resolve(); + } + + /** + * Persist one finalized advisor message. Assistant turns carry the usage the + * stats parser reads; tool results round out the Hub transcript; user deltas + * (the advisor's "session update" prompts) are persisted but flagged + * `synthetic`/agent-attributed so they never inflate user-message metrics. + * Non-conversational message kinds are skipped. + */ + record(message: AgentMessage): void { + let persisted: Message; + switch (message.role) { + case "assistant": + case "toolResult": + persisted = message; + break; + case "user": + // Clone so the live advisor message stays untouched; mark synthetic so + // stats' user-message metrics skip these agent-internal review prompts. + persisted = { ...(message as UserMessage), synthetic: true, attribution: "agent" }; + break; + default: + return; + } + const sessionFile = this.resolveSessionFile(); + if (!sessionFile?.endsWith(JSONL_SUFFIX)) return; + const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME); + const cwd = this.resolveCwd(); + this.#enqueue(async () => { + if (file !== this.#file) { + await this.#closeManager(); + this.#manager = await SessionManager.open(file, undefined, undefined, { + initialCwd: cwd, + suppressBreadcrumb: true, + }); + this.#file = file; + } + this.#manager?.appendMessage(persisted); + }); + } + + /** Flush pending writes (best-effort). */ + flush(): Promise { + return this.#enqueueResult(async () => { + if (this.#manager) await this.#manager.flush(); + }); + } + + /** Flush and close the writer, releasing the session file. */ + close(): Promise { + return this.#enqueueResult(() => this.#closeManager()); + } + + async #closeManager(): Promise { + const manager = this.#manager; + this.#manager = undefined; + this.#file = undefined; + if (!manager) return; + try { + await manager.close(); + } catch (err) { + logger.debug("advisor transcript close failed", { err: String(err) }); + } + } + + #enqueue(work: () => Promise): void { + this.#queue = this.#queue.then(work, work).catch(err => { + logger.debug("advisor transcript record failed", { err: String(err) }); + }); + } + + #enqueueResult(work: () => Promise): Promise { + const next = this.#queue.then(work, work); + this.#queue = next.catch(() => {}); + return next; + } +} diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index a5a7c0e74..3e000658a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -56,6 +56,7 @@ export interface Args { noExtensions?: boolean; pluginDirs?: string[]; print?: boolean; + printThoughts?: boolean; export?: string; noSkills?: boolean; skills?: string[]; @@ -200,6 +201,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map 1) { + filePath = filePath.slice(1, -1); + } else if (filePath.startsWith("'") && filePath.endsWith("'") && filePath.length > 1) { + filePath = filePath.slice(1, -1); + } + result.fileArgs.push(filePath); } else if (!arg.startsWith("-") || arg === "-") { // Plain positional or lone `-` (stdin marker) — pass through as a // message rather than flagging it. diff --git a/packages/coding-agent/src/cli/auth-broker-cli.ts b/packages/coding-agent/src/cli/auth-broker-cli.ts index 5a58bcb5c..ff9c1a0d3 100644 --- a/packages/coding-agent/src/cli/auth-broker-cli.ts +++ b/packages/coding-agent/src/cli/auth-broker-cli.ts @@ -19,11 +19,9 @@ import * as os from "node:os"; import * as path from "node:path"; import * as readline from "node:readline"; import { - AuthBrokerClient, type AuthCredential, AuthStorage, type CredentialDisabledEvent, - DEFAULT_AUTH_BROKER_BIND, getEnvApiKey, getOAuthProviders, listProvidersWithEnvKey, @@ -32,8 +30,8 @@ import { type OAuthProviderInfo, PROVIDER_REGISTRY, SqliteAuthCredentialStore, - startAuthBroker, } from "@oh-my-pi/pi-ai"; +import { AuthBrokerClient, DEFAULT_AUTH_BROKER_BIND, startAuthBroker } from "@oh-my-pi/pi-ai/auth-broker"; import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils"; import { setTransports as setLoggerTransports } from "@oh-my-pi/pi-utils/logger"; import { $ } from "bun"; diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index 956ad8fce..d21a85409 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -17,18 +17,15 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type Api, - AuthBrokerClient, AuthStorage, type CompletionProbe, type CompletionProbeInput, type CredentialCompletionResult, completeSimple, - DEFAULT_AUTH_GATEWAY_BIND, type Model, - RemoteAuthCredentialStore, - type SnapshotResponse, - startAuthGateway, } from "@oh-my-pi/pi-ai"; +import { AuthBrokerClient, RemoteAuthCredentialStore, type SnapshotResponse } from "@oh-my-pi/pi-ai/auth-broker"; +import { DEFAULT_AUTH_GATEWAY_BIND, startAuthGateway } from "@oh-my-pi/pi-ai/auth-gateway"; import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models"; import { getConfigRootDir, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; diff --git a/packages/coding-agent/src/cli/flag-tables.ts b/packages/coding-agent/src/cli/flag-tables.ts index 52c61e415..d4c29fc8b 100644 --- a/packages/coding-agent/src/cli/flag-tables.ts +++ b/packages/coding-agent/src/cli/flag-tables.ts @@ -270,6 +270,7 @@ export const VALUELESS_FLAGS: ReadonlySet = new Set([ "--hide-thinking", "--advisor", "--print", + "--print-thoughts", "--no-extensions", "--no-skills", "--no-rules", diff --git a/packages/coding-agent/src/cli/startup-cwd.ts b/packages/coding-agent/src/cli/startup-cwd.ts index b3c2c890a..08516bc9f 100644 --- a/packages/coding-agent/src/cli/startup-cwd.ts +++ b/packages/coding-agent/src/cli/startup-cwd.ts @@ -1,7 +1,6 @@ -import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils"; +import { directoryExists, getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils"; import type { Args } from "./args"; async function maybeAutoChdir(parsed: Args): Promise { @@ -22,19 +21,10 @@ async function maybeAutoChdir(parsed: Args): Promise { return; } - const isDirectory = async (p: string) => { - try { - const s = await fs.stat(p); - return s.isDirectory(); - } catch { - return false; - } - }; - const candidates = [path.join(home, "tmp"), "/tmp", "/var/tmp"]; for (const candidate of candidates) { try { - if (!(await isDirectory(candidate))) { + if (!(await directoryExists(candidate))) { continue; } setProjectDir(candidate); @@ -46,7 +36,7 @@ async function maybeAutoChdir(parsed: Args): Promise { try { const fallback = os.tmpdir(); - if (fallback && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) { + if (fallback && normalizePath(fallback) !== cwd && (await directoryExists(fallback))) { setProjectDir(fallback); } } catch { diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index cd3e4705f..31177ab71 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -4,6 +4,7 @@ * Handles `omp stats` subcommand for viewing AI usage statistics. */ +import { truncateToWidth } from "@oh-my-pi/pi-tui/utils"; import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { openPath } from "../utils/open"; @@ -32,7 +33,7 @@ function createSyncProgressReporter(): { const counter = chalk.cyan(`[${event.current}/${event.total}]`); const line = `${counter} ${pct}% ${label}`; const columns = stream.columns ?? 120; - const trimmed = truncateToColumns(line, columns - 1); + const trimmed = truncateToWidth(line, columns - 1); stream.write(`\r${trimmed.padEnd(lastWidth)}`); lastWidth = trimmed.length; }, @@ -50,16 +51,6 @@ function shortenSessionFile(p: string): string { return idx >= 0 ? p.slice(idx + marker.length) : p; } -function truncateToColumns(s: string, max: number): string { - if (max <= 0) return ""; - const width = Bun.stringWidth(s, { countAnsiEscapeCodes: false }); - if (width <= max) return s; - // Cheap right-trim with an ellipsis - we don't need ANSI-aware slicing - // because the colored prefix is short and the truncated tail is the - // dim filename, where dropping bytes is fine. - return `${s.slice(0, Math.max(0, max - 1))}\u2026`; -} - // ============================================================================= // Types // ============================================================================= diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts index 55850839a..5f6aac84e 100644 --- a/packages/coding-agent/src/collab/host.ts +++ b/packages/coding-agent/src/collab/host.ts @@ -17,7 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils"; import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire"; import type { InteractiveModeContext } from "../modes/types"; import { AgentLifecycleManager } from "../registry/agent-lifecycle"; -import { AgentRegistry } from "../registry/agent-registry"; +import { type AgentRef, AgentRegistry } from "../registry/agent-registry"; import type { AgentSessionEvent } from "../session/agent-session"; import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages"; import type { SessionEntry as StoredSessionEntry } from "../session/session-entries"; @@ -133,7 +133,7 @@ export class CollabHost { return this.#link; } - /** Browser deep link (`https:///#`) — the relay serves the web client at `/`. */ + /** Browser deep link for the configured collab web UI. */ get webLink(): string { return this.#webLink; } @@ -156,15 +156,15 @@ export class CollabHost { return list; } - async start(relayUrl: string): Promise { + async start(relayUrl: string, webUrl = ""): Promise { const rawKey = generateRoomKey(); const writeToken = generateWriteToken(); const roomId = generateRoomId(); this.#writeToken = writeToken; this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken); - this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken); + this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl); this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey); - this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey); + this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl); const parsed = parseCollabLink(this.#link); if ("error" in parsed) throw new Error(parsed.error); const key = await importRoomKey(rawKey); @@ -445,18 +445,24 @@ export class CollabHost { } #snapshotAgents(): AgentSnapshot[] { - return AgentRegistry.global() - .list() - .map(ref => ({ - id: ref.id, - displayName: ref.displayName, - kind: ref.kind, - parentId: ref.parentId, - status: ref.status, - hasSessionFile: !!ref.sessionFile, - createdAt: ref.createdAt, - lastActivity: ref.lastActivity, - })); + return ( + AgentRegistry.global() + .list() + // Advisor transcripts are local observability only; never mirror them to + // guests (the wire AgentSnapshot kind has no `advisor`, and guests must not + // be able to chat/kill/revive them). + .filter((ref): ref is AgentRef & { kind: "main" | "sub" } => ref.kind !== "advisor") + .map(ref => ({ + id: ref.id, + displayName: ref.displayName, + kind: ref.kind, + parentId: ref.parentId, + status: ref.status, + hasSessionFile: !!ref.sessionFile, + createdAt: ref.createdAt, + lastActivity: ref.lastActivity, + })) + ); } #scheduleAgentsBroadcast(): void { @@ -472,6 +478,12 @@ export class CollabHost { this.#rejectReadOnly("agent control", fromPeer); return; } + // Advisor refs are excluded from snapshots, but reject control by id defensively: + // a stale/malicious client must never chat/kill/revive a read-only advisor transcript. + if (AgentRegistry.global().get(agentId)?.kind === "advisor") { + this.#socket?.send({ t: "error", message: `agent ${agentId}: advisor transcripts are read-only` }, fromPeer); + return; + } const fail = (err: unknown) => { logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) }); this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer); diff --git a/packages/coding-agent/src/collab/protocol.ts b/packages/coding-agent/src/collab/protocol.ts index 7b6513511..26cace5a2 100644 --- a/packages/coding-agent/src/collab/protocol.ts +++ b/packages/coding-agent/src/collab/protocol.ts @@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/; const B64URL_RE = /^[A-Za-z0-9_-]+$/; const LOCAL_HOSTNAMES: Record = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true }; +function isLocalHostname(hostname: string): boolean { + return LOCAL_HOSTNAMES[hostname] === true; +} + export function generateRoomId(): string { const bytes = new Uint8Array(ROOM_ID_BYTES); crypto.getRandomValues(bytes); @@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s default: return { error: `Unsupported relay URL scheme: ${url.protocol}` }; } - if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) { + if (scheme === "ws:" && !isLocalHostname(url.hostname)) { return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" }; } const port = url.port ? `:${url.port}` : ""; @@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr return `${compact}/r/${roomId}.${keyText}`; } +function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string { + const explicitWebUrl = webUrl?.trim(); + if (!explicitWebUrl) { + const normalized = normalizeRelayOrigin(relayUrl); + if ("error" in normalized) throw new Error(normalized.error); + return normalized.origin.startsWith("wss://") + ? `https://${normalized.origin.slice("wss://".length)}` + : `http://${normalized.origin.slice("ws://".length)}`; + } + + let url: URL; + try { + url = new URL(explicitWebUrl); + } catch { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol === "http:" && !isLocalHostname(url.hostname)) { + throw new Error("collab.webUrl must use https:// unless it targets localhost"); + } + if (url.search || url.hash) { + throw new Error("collab.webUrl must not include a query string or fragment"); + } + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; +} + /** - * Render the browser deep link: `http(s):///#`. The - * relay serves the web client at `/`, and the whole collab link (including the - * room key) rides in the fragment, so it never appears in any HTTP request. - * Terminals auto-link the https form, making it click-to-join. + * Render the browser deep link. The browser UI may be hosted separately from + * the relay; the fragment always carries the relay-specific collab link, so + * room secrets stay out of HTTP path and query bytes. */ export function formatCollabWebLink( relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array, + webUrl?: string, ): string { - const normalized = normalizeRelayOrigin(relayUrl); - if ("error" in normalized) throw new Error(normalized.error); - const httpOrigin = normalized.origin.startsWith("wss://") - ? `https://${normalized.origin.slice("wss://".length)}` - : `http://${normalized.origin.slice("ws://".length)}`; - return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; + return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; } export function parseCollabLink(link: string): ParsedCollabLink | { error: string } { @@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1]!; diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 3b2aba03a..d0a623bf1 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -136,6 +136,9 @@ export default class Index extends Command { "no-title": Flags.boolean({ description: "Disable title auto-generation", }), + "print-thoughts": Flags.boolean({ + description: "Include thinking blocks in print mode text output", + }), "max-time": Flags.string({ description: "Stop the session after this many seconds", }), diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index ca76ca3ca..98b4c8867 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -1,7 +1,8 @@ -import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { Markdown } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import chalk from "chalk"; import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" }; import type { ModelRegistry } from "../../config/model-registry"; diff --git a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts index f523a4fa3..9333da3c9 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts @@ -87,7 +87,7 @@ function truncateDiffContent(diff: string): { content: string; truncated: boolea const truncatedCount = lines.length - KEEP_HEAD_LINES - KEEP_TAIL_LINES; return { - content: [...head, `\n... (truncated ${truncatedCount} lines) ...\n`, ...tail].join("\n"), + content: [...head, `\n[…${truncatedCount}ln elided…]\n`, ...tail].join("\n"), truncated: true, }; } @@ -117,7 +117,7 @@ function processDiffs(files: string[], diffs: Map): { result: st } content = truncated; if (content.length > remaining) { - content = `${content.slice(0, remaining)}\n... (diff truncated due to size) ...`; + content = `${content.slice(0, remaining)}\n[…${content.length - remaining}ch elided…]`; if (!truncatedFiles.includes(file)) { truncatedFiles.push(file); } diff --git a/packages/coding-agent/src/commit/changelog/index.ts b/packages/coding-agent/src/commit/changelog/index.ts index 5260db181..5374ab79e 100644 --- a/packages/coding-agent/src/commit/changelog/index.ts +++ b/packages/coding-agent/src/commit/changelog/index.ts @@ -138,7 +138,7 @@ export async function applyChangelogProposals({ function truncateDiff(diff: string, maxChars: number): string { if (diff.length <= maxChars) return diff; - return `${diff.slice(0, maxChars)}\n... (truncated)`; + return `${diff.slice(0, maxChars)}\n[…${diff.length - maxChars}ch elided…]`; } function formatExistingEntries(entries: Record): string { diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts index a263bfead..7c268baf3 100644 --- a/packages/coding-agent/src/commit/map-reduce/map-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -126,7 +126,7 @@ function generateContextHeader(files: FileDiff[], currentFile: string): string { } if (toShow.length < sorted.length) { - lines.push(`... and ${sorted.length - toShow.length} more files`); + lines.push(`[…${sorted.length - toShow.length} files elided…]`); } return lines.join("\n"); diff --git a/packages/coding-agent/src/commit/map-reduce/utils.ts b/packages/coding-agent/src/commit/map-reduce/utils.ts index c12860323..7948df9c7 100644 --- a/packages/coding-agent/src/commit/map-reduce/utils.ts +++ b/packages/coding-agent/src/commit/map-reduce/utils.ts @@ -5,5 +5,5 @@ export function estimateTokens(text: string): number { export function truncateToTokenLimit(text: string, maxTokens: number): string { const maxChars = maxTokens * 4; if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}\n... (truncated)`; + return `${text.slice(0, maxChars)}\n[…${text.length - maxChars}ch elided…]`; } diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index 7769350f6..018d25c8f 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -40,7 +40,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) { } const content = fs.readFileSync(jsonPath, "utf-8"); - const parsed = JSON.parse(content); + const parsed = JSONC.parse(content); if (!parsed) { logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath }); migratedPaths.add(key); diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index fe339862a..5ad80c670 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -10,7 +10,7 @@ import { KeybindingsManager as TuiKeybindingsManager, } from "@oh-my-pi/pi-tui"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; +import { JSONC, YAML } from "bun"; /** * Application-level keybindings (coding agent specific). @@ -381,7 +381,7 @@ function loadRawConfig(filePath: string): unknown { try { const content = fs.readFileSync(filePath, "utf-8"); if (filePath.endsWith(".json")) { - return JSON.parse(content); + return JSONC.parse(content); } if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) { return YAML.parse(content); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index f720b11ff..f12884945 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -563,10 +563,16 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil } as ModelSpec); } -function normalizeSuppressedSelector(selector: string): string { +function normalizeSuppressedSelector( + selector: string, + hasLiveModel?: (provider: string, id: string) => boolean, +): string { const trimmed = selector.trim(); if (!trimmed) return trimmed; - const parsed = parseModelString(trimmed); + const parsed = parseModelString(trimmed, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true, + }); if (!parsed) return trimmed; // Retired effort-tier variant ids normalize to their collapsed logical id // so persisted suppressions keyed by raw member ids still bind. @@ -2155,14 +2161,20 @@ export class ModelRegistry { * Suppress a specific model selector (e.g., "provider/id") until a specific timestamp. */ suppressSelector(selector: string, untilMs: number): void { - this.#suppressedSelectors.set(normalizeSuppressedSelector(selector), untilMs); + this.#suppressedSelectors.set( + normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined), + untilMs, + ); } /** * Check if a model selector is currently suppressed due to rate limits. */ isSelectorSuppressed(selector: string): boolean { - const normalizedSelector = normalizeSuppressedSelector(selector); + const normalizedSelector = normalizeSuppressedSelector( + selector, + (provider, id) => this.find(provider, id) !== undefined, + ); const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector); if (!suppressedUntil) return false; if (suppressedUntil <= Date.now()) { diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 134d80bcd..74048d715 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -72,6 +72,21 @@ export interface ScopedModel { explicitThinkingLevel: boolean; } +interface ThinkingSuffixOptions { + allowMaxAlias?: boolean; +} + +interface ModelStringParseOptions extends ThinkingSuffixOptions { + isLiteralModelId?: (provider: string, id: string) => boolean; +} +const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true }; + +function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ThinkingLevel | undefined { + const level = parseThinkingLevel(value); + if (level !== undefined) return level; + return options?.allowMaxAlias === true && value === "max" ? ThinkingLevel.XHigh : undefined; +} + /** * Split a trailing `:` thinking selector off a model pattern. * @@ -81,27 +96,89 @@ export interface ScopedModel { * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least * as long as the `pi/` prefix. */ -function splitThinkingSuffix(pattern: string, minColonIndex = -1): { base: string; level?: ThinkingLevel } { +function splitThinkingSuffix( + pattern: string, + minColonIndex = -1, + options?: ThinkingSuffixOptions, +): { base: string; level?: ThinkingLevel } { const colonIdx = pattern.lastIndexOf(":"); if (colonIdx <= minColonIndex) return { base: pattern }; - const level = parseThinkingLevel(pattern.slice(colonIdx + 1)); + const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options); return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern }; } +function hasExactModelPattern(pattern: string, availableModels: readonly Model[]): boolean { + const normalized = pattern.toLowerCase(); + return availableModels.some( + model => model.id.toLowerCase() === normalized || `${model.provider}/${model.id}`.toLowerCase() === normalized, + ); +} + +function matchingGlobModels(pattern: string, availableModels: readonly Model[]): Model[] { + const glob = new Bun.Glob(pattern.toLowerCase()); + return availableModels.filter(model => { + const fullId = `${model.provider}/${model.id}`; + return glob.match(fullId.toLowerCase()) || glob.match(model.id.toLowerCase()); + }); +} + +function resolveGlobScopePattern( + pattern: string, + availableModels: readonly Model[], +): { models: Model[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } { + const strictSuffix = splitThinkingSuffix(pattern); + if (strictSuffix.level !== undefined) { + return { + models: matchingGlobModels(strictSuffix.base, availableModels), + thinkingLevel: strictSuffix.level, + explicitThinkingLevel: true, + }; + } + + const maxSuffix = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); + if (maxSuffix.level !== undefined) { + const literalMatches = matchingGlobModels(pattern, availableModels); + if (literalMatches.length > 0) { + return { models: literalMatches, thinkingLevel: undefined, explicitThinkingLevel: false }; + } + return { + models: matchingGlobModels(maxSuffix.base, availableModels), + thinkingLevel: maxSuffix.level, + explicitThinkingLevel: true, + }; + } + + return { + models: matchingGlobModels(pattern, availableModels), + thinkingLevel: undefined, + explicitThinkingLevel: false, + }; +} + /** * Parse a model string in "provider/modelId" format. * Returns undefined if the format is invalid. */ export function parseModelString( modelStr: string, + options?: ModelStringParseOptions, ): { provider: string; id: string; thinkingLevel?: ThinkingLevel } | undefined { const slashIdx = modelStr.indexOf("/"); if (slashIdx <= 0) return undefined; const id = modelStr.slice(slashIdx + 1); const provider = modelStr.slice(0, slashIdx); - // Strip valid thinking level suffix (e.g., "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high") - const { base, level } = splitThinkingSuffix(id); - return level ? { provider, id: base, thinkingLevel: level } : { provider, id }; + // Strip strict thinking level suffixes first (e.g. "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high"). + const strict = splitThinkingSuffix(id); + if (strict.level) return { provider, id: strict.base, thinkingLevel: strict.level }; + // `max` is a provider-facing alias for xhigh, but real model IDs can end in + // `:max`. Context-aware callers pass a literal lookup so those models win. + const maxAlias = splitThinkingSuffix(id, -1, options); + if (maxAlias.level) { + return options?.isLiteralModelId?.(provider, id) === true + ? { provider, id } + : { provider, id: maxAlias.base, thinkingLevel: maxAlias.level }; + } + return { provider, id }; } /** @@ -149,7 +226,10 @@ function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: st } const suffix = modelId.slice(colonIdx + 1).trim(); - if (!suffix || parseThinkingLevel(suffix)) { + // `max` is a thinking-level alias (xhigh), never an OpenRouter route suffix, so + // `openrouter/:max` falls through to the max-aware selector split instead of + // being cloned into a literal `:max` model id with the reasoning level lost. + if (!suffix || parseThinkingSuffix(suffix, MAX_THINKING_SUFFIX_OPTIONS)) { return undefined; } @@ -196,6 +276,50 @@ function cloneModelWithRequestedId(model: Model, requestedId: string): Mode }; } +const AMAZON_BEDROCK_PROVIDER = "amazon-bedrock"; +const BEDROCK_INFERENCE_PROFILE_ARN = + /^arn:aws(?:-[a-z]+)*:bedrock:[a-z0-9-]+:[0-9]*:(?:application-inference-profile|inference-profile)\/[a-z0-9][a-z0-9._:-]*$/i; + +function hasBedrockInferenceProfileThinkingSuffix(modelId: string): boolean { + const { base, level } = splitThinkingSuffix(modelId); + return level !== undefined && BEDROCK_INFERENCE_PROFILE_ARN.test(base.trim()); +} + +function resolveBedrockInferenceProfileModelId( + modelId: string, + availableModels: readonly Model[], +): Model | undefined { + const requestedId = modelId.trim(); + if (hasBedrockInferenceProfileThinkingSuffix(requestedId) || !BEDROCK_INFERENCE_PROFILE_ARN.test(requestedId)) { + return undefined; + } + + const template = availableModels.find(model => model.provider.toLowerCase() === AMAZON_BEDROCK_PROVIDER); + if (!template) return undefined; + + return buildModel({ + id: requestedId, + name: "Bedrock inference profile", + api: "bedrock-converse-stream", + provider: AMAZON_BEDROCK_PROVIDER, + baseUrl: template.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: null, + maxTokens: null, + }); +} + +function resolveBedrockInferenceProfileReference( + provider: string, + modelId: string, + availableModels: readonly Model[], +): Model | undefined { + if (provider.toLowerCase() !== AMAZON_BEDROCK_PROVIDER) return undefined; + return resolveBedrockInferenceProfileModelId(modelId, availableModels); +} + const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; /** @@ -289,6 +413,11 @@ export function resolveProviderModelReference( } } + const bedrockInferenceProfile = resolveBedrockInferenceProfileReference(provider, modelId, availableModels); + if (bedrockInferenceProfile) { + return bedrockInferenceProfile; + } + if (normalizedProvider !== "openrouter") { return undefined; } @@ -470,6 +599,7 @@ function findExactCanonicalModelMatch( * The single model-matching engine. Tries, in order: * 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date * fallbacks included), + * 2. exact canonical id (coalesces provider variants), * 3. exact bare id (preference-ranked), * 4. retired effort-tier variant alias (collapsed catalog entries), @@ -502,6 +632,11 @@ function matchModel( return pickPreferredModel(exactMatches, context); } + const bedrockInferenceProfile = resolveBedrockInferenceProfileModelId(modelPattern, availableModels); + if (bedrockInferenceProfile) { + return bedrockInferenceProfile; + } + // Retired effort-tier variant ids (bare, no provider prefix) resolve to // their collapsed logical model; models from the providers whose table // declared the alias win ties. Auto-derived `X-thinking` pairs resolve @@ -625,8 +760,10 @@ function parseModelPatternWithContext( return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; } - // No match - try stripping a valid thinking suffix and recursing - const { base, level } = splitThinkingSuffix(pattern); + // No match - try stripping a valid thinking suffix and recursing. + // `max` is accepted only after the full pattern failed, so literal model IDs + // ending in `:max` keep winning over the alias. + const { base, level } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); if (level) { const result = parseModelPatternWithContext(base, availableModels, context, options); if (result.model) { @@ -730,7 +867,11 @@ function resolveDefaultInheritedPatterns( const resolved: string[] = []; for (const pattern of normalizeModelPatternList(configuredDefault)) { - const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(pattern, PREFIX_MODEL_ROLE.length); + const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix( + pattern, + PREFIX_MODEL_ROLE.length, + MAX_THINKING_SUFFIX_OPTIONS, + ); const aliasRole = getModelRoleAlias(aliasCandidate); if (aliasRole === role) { // Self-alias (e.g. modelRoles.default = "pi/smol") would loop back to the @@ -765,7 +906,11 @@ function resolveConfiguredRolePattern( const normalized = value.trim(); if (!normalized) return undefined; - const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(normalized, PREFIX_MODEL_ROLE.length); + const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix( + normalized, + PREFIX_MODEL_ROLE.length, + MAX_THINKING_SUFFIX_OPTIONS, + ); const role = getModelRoleAlias(aliasCandidate); if (!role) return [normalized]; if (visited.has(role)) return undefined; @@ -888,9 +1033,19 @@ export function resolveModelRoleValue( return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning }; } +interface ExplicitThinkingSelectorOptions { + isLiteralModelId?: (provider: string, id: string) => boolean; +} + +function isLiteralModelSelector(value: string, options?: ExplicitThinkingSelectorOptions): boolean { + const parsed = parseModelString(value); + return parsed !== undefined && options?.isLiteralModelId?.(parsed.provider, parsed.id) === true; +} + export function extractExplicitThinkingSelector( value: string | undefined, settings?: Settings, + options?: ExplicitThinkingSelectorOptions, ): ThinkingLevel | undefined { if (!value) return undefined; const normalized = value.trim(); @@ -900,9 +1055,13 @@ export function extractExplicitThinkingSelector( let current = normalized; while (!visited.has(current)) { visited.add(current); - const thinkingSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level; - if (thinkingSelector) { - return thinkingSelector; + const strictSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level; + if (strictSelector) { + return strictSelector; + } + const maxSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length, MAX_THINKING_SUFFIX_OPTIONS).level; + if (maxSelector && (current.startsWith(PREFIX_MODEL_ROLE) || !isLiteralModelSelector(current, options))) { + return maxSelector; } const expanded = expandRoleAlias(current, settings).trim(); if (!expanded || expanded === current) break; @@ -922,10 +1081,15 @@ export function resolveModelFromString( matchPreferences?: ModelMatchPreferences, modelRegistry?: CanonicalModelRegistry, ): Model | undefined { - const parsed = parseModelString(value); + const exact = available.find(model => `${model.provider}/${model.id}` === value); + if (exact) return exact; + const parsed = parseModelString(value, { + ...MAX_THINKING_SUFFIX_OPTIONS, + isLiteralModelId: (provider, id) => available.some(model => model.provider === provider && model.id === id), + }); if (parsed) { - const exact = available.find(model => model.provider === parsed.provider && model.id === parsed.id); - if (exact) return exact; + const parsedExact = available.find(model => model.provider === parsed.provider && model.id === parsed.id); + if (parsedExact) return parsedExact; } return parseModelPattern(value, available, matchPreferences, { modelRegistry }).model; } @@ -1065,7 +1229,10 @@ function resolveExactCanonicalScopePattern( modelRegistry: Pick, availableModels: Model[], ): { models: Model[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } | undefined { - const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern); + if (pattern.endsWith(":max") && hasExactModelPattern(pattern, availableModels)) { + return undefined; + } + const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS); const explicitThinkingLevel = thinkingLevel !== undefined; const variants = modelRegistry @@ -1111,17 +1278,13 @@ export async function resolveModelScope( for (const pattern of patterns) { // Check if pattern contains glob characters if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { - // Extract optional thinking level suffix (e.g., "provider/*:high") - const { base: globPattern, level: thinkingLevel } = splitThinkingSuffix(pattern); - const explicitThinkingLevel = thinkingLevel !== undefined; - - // Match against "provider/modelId" format OR just model ID - // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" - const matchingModels = availableModels.filter(m => { - const fullId = `${m.provider}/${m.id}`; - const glob = new Bun.Glob(globPattern.toLowerCase()); - return glob.match(fullId.toLowerCase()) || glob.match(m.id.toLowerCase()); - }); + // Extract optional thinking level suffix (e.g., "provider/*:high") only + // after literal `:max` globs had a chance to match real model IDs. + const { + models: matchingModels, + thinkingLevel, + explicitThinkingLevel, + } = resolveGlobScopePattern(pattern, availableModels); if (matchingModels.length === 0) { logger.warn(`No models match pattern "${pattern}"`); @@ -1226,13 +1389,8 @@ export function filterAvailableModelsByEnabledPatterns( for (const pattern of patterns) { if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { - const { base: globPattern } = splitThinkingSuffix(pattern); - const glob = new Bun.Glob(globPattern.toLowerCase()); - for (const model of available) { - const fullId = `${model.provider}/${model.id}`.toLowerCase(); - if (glob.match(fullId) || glob.match(model.id.toLowerCase())) { - addAllowed(model); - } + for (const model of resolveGlobScopePattern(pattern, available).models) { + addAllowed(model); } continue; } diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index 916c925d9..76cd08b6e 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -1,4 +1,11 @@ -import { type } from "arktype"; +import { scope } from "arktype"; + +// Config schemas validate at most a handful of times per process (on config +// load), so the eager JIT codegen ArkType runs at definition time is pure +// startup tax. A local jitless scope skips that codegen and falls back to +// interpreted traversal — ~65% cheaper to construct, validation correctness +// unchanged. (No `name`: duplicate module instances would collide.) +const { type } = scope({}, { jitless: true }); const OpenRouterRoutingSchema = type({ "only?": "string[]", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 6883ca107..ed1623c68 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -342,44 +342,38 @@ export const SETTINGS_SCHEMA = { }, // macOS power assertions (caffeinate flags). No-op on other platforms. - "power.preventIdleSleep": { - type: "boolean", - default: true, + "power.sleepPrevention": { + type: "enum", + values: ["off", "idle", "display", "system"] as const, + default: "idle", ui: { tab: "interaction", group: "Power (macOS)", - label: "Prevent Idle Sleep", - description: "Keep the system awake while a session is open (caffeinate -i)", - }, - }, - "power.preventSystemSleep": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Prevent System Sleep on AC", - description: "Block all system sleep while on AC power (caffeinate -s)", - }, - }, - "power.declareUserActive": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Declare User Active", - description: "Keep the display lit and treat the user as active (caffeinate -u)", - }, - }, - "power.preventDisplaySleep": { - type: "boolean", - default: false, - ui: { - tab: "interaction", - group: "Power (macOS)", - label: "Prevent Display Sleep", - description: "Keep the display from idle-sleeping while a session is open (caffeinate -d)", + label: "Sleep Prevention", + description: + "Prevent macOS sleep during active sessions. Each level is cumulative — it adds the flags of all lower levels.", + options: [ + { + value: "off", + label: "Off", + description: "Do not prevent any sleep", + }, + { + value: "idle", + label: "Prevent Idle Sleep", + description: "Keep the system awake while a session is open (caffeinate -i)", + }, + { + value: "display", + label: "Prevent Display Sleep", + description: "Also keep the display from idle-sleeping (caffeinate -i -d)", + }, + { + value: "system", + label: "Prevent System Sleep", + description: "Also block all system sleep on AC and declare the user active (caffeinate -i -d -s -u)", + }, + ], }, }, "advisor.enabled": { @@ -401,6 +395,7 @@ export const SETTINGS_SCHEMA = { group: "Advisor", label: "Advisor for Subagents", description: "Also enable the advisor on spawned task/eval subagents.", + condition: "advisorEnabled", }, }, "advisor.syncBacklog": { @@ -413,6 +408,7 @@ export const SETTINGS_SCHEMA = { label: "Advisor Sync Backlog", description: "Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.", + condition: "advisorEnabled", }, }, "advisor.immuneTurns": { @@ -432,6 +428,7 @@ export const SETTINGS_SCHEMA = { { value: "4", label: "4 turns" }, { value: "5", label: "5 turns" }, ], + condition: "advisorEnabled", }, }, shellPath: { type: "string", default: undefined }, @@ -809,11 +806,6 @@ export const SETTINGS_SCHEMA = { description: "Remove the 1-character horizontal padding from the left and right of the terminal output", }, }, - // Display rendering - "display.tabWidth": { - type: "number", - default: 3, - }, "display.shimmer": { type: "enum", @@ -854,6 +846,17 @@ export const SETTINGS_SCHEMA = { }, }, + "display.cacheMissMarker": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Cache Miss Marker", + description: "Show a divider above an assistant turn whose request lost (missed) the prompt cache", + }, + }, + showHardwareCursor: { type: "boolean", default: true, // will be computed based on platform if undefined @@ -872,7 +875,7 @@ export const SETTINGS_SCHEMA = { // Reasoning and prompts defaultThinkingLevel: { type: "enum", - values: [...THINKING_EFFORTS, AUTO_THINKING], + values: [...THINKING_EFFORTS, AUTO_THINKING, "max"], default: "high", ui: { tab: "model", @@ -919,14 +922,15 @@ export const SETTINGS_SCHEMA = { }, }, - repeatToolDescriptions: { + inlineToolDescriptors: { type: "boolean", default: false, ui: { tab: "model", group: "Prompt", - label: "Repeat Tool Descriptions", - description: "Render full tool descriptions in the system prompt instead of a tool name list", + label: "Inline Tool Descriptors", + description: + "Render full tool descriptors in the system prompt and strip top-level/nested descriptions from provider tool schemas so descriptor text is sent once", }, }, @@ -1519,6 +1523,18 @@ export const SETTINGS_SCHEMA = { }, }, + "collab.webUrl": { + type: "string", + default: "", + ui: { + tab: "interaction", + group: "Collab", + label: "Web UI URL", + description: + "Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only", + }, + }, + "collab.displayName": { type: "string", default: "", @@ -1878,7 +1894,7 @@ export const SETTINGS_SCHEMA = { { value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." }, { value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." }, { value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." }, - { value: "pi", label: "Pi", description: "Use the Pi owned dialect." }, + { value: "pi", label: "Pi", description: "Use the Pi owned dialect (compact sigil-delimited tool calls)." }, { value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." }, { value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." }, { value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 93b1c66d7..58f0398f8 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -22,9 +22,8 @@ import { isEnoent, logger, procmgr, - setDefaultTabWidth, } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; +import { JSONC, YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-roles"; import { loadCapability } from "../discovery"; @@ -668,9 +667,9 @@ export class Settings { // 1. Migrate from settings.json const settingsJsonPath = path.join(this.#agentDir, "settings.json"); try { - const parsed = JSON.parse(await Bun.file(settingsJsonPath).text()); + const parsed: unknown = JSONC.parse(await Bun.file(settingsJsonPath).text()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed)); + settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed as RawSettings)); migrated = true; try { fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`); @@ -910,6 +909,46 @@ export class Settings { } } + // power.preventIdleSleep / power.preventSystemSleep / power.declareUserActive + // / power.preventDisplaySleep (four booleans) → power.sleepPrevention enum. + // The enum is cumulative: each level adds the flags of all lower levels. + // Migration picks the highest level whose condition is met, scanning from + // most to least aggressive so a single enum value captures the old state. + if ( + !("sleepPrevention" in ((raw.power as Record) ?? {})) && + raw["power.sleepPrevention"] === undefined + ) { + const powerObj = raw.power as Record | undefined; + const getFlag = (key: string): boolean | undefined => { + const nested = powerObj?.[key]; + const flat = raw[`power.${key}`]; + const value = nested ?? flat; + return typeof value === "boolean" ? value : undefined; + }; + const idle = getFlag("preventIdleSleep"); + const system = getFlag("preventSystemSleep"); + const user = getFlag("declareUserActive"); + const display = getFlag("preventDisplaySleep"); + const anySet = idle !== undefined || system !== undefined || user !== undefined || display !== undefined; + if (anySet) { + const mode = system || user ? "system" : display ? "display" : idle !== false ? "idle" : "off"; + const powerRoot = (powerObj ?? {}) as Record; + powerRoot.sleepPrevention = mode; + raw.power = powerRoot; + } + // Clean up old keys (nested + flat) + if (powerObj) { + delete powerObj.preventIdleSleep; + delete powerObj.preventSystemSleep; + delete powerObj.declareUserActive; + delete powerObj.preventDisplaySleep; + } + delete raw["power.preventIdleSleep"]; + delete raw["power.preventSystemSleep"]; + delete raw["power.declareUserActive"]; + delete raw["power.preventDisplaySleep"]; + } + return raw; } @@ -1103,11 +1142,6 @@ const SETTING_HOOKS: Partial>> = { }); } }, - "display.tabWidth": value => { - if (typeof value === "number") { - setDefaultTabWidth(value); - } - }, "provider.appendOnlyContext": value => { if (typeof value === "string") { appendOnlyModeSignal.fire(value); diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 83c4c5964..c7cdad957 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -29,6 +29,7 @@ import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from import { buildSampleImage, ProtocolProbeComponent } from "./protocol-probe"; import { RawSseViewerComponent } from "./raw-sse"; import { resolveRawSseDebugBuffer } from "./raw-sse-buffer"; +import { getRemoteDebugger, type RemoteDebuggerInfo, startRemoteDebuggerServer } from "./remote-debugger"; import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle"; import { collectSystemInfo, formatSystemInfo } from "./system-info"; import { collectTerminalState, formatTerminalState } from "./terminal-info"; @@ -49,6 +50,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [ description: "Styling, links, text sizing, graphics, notify", }, { value: "raw-sse", label: "View: raw SSE stream", description: "Show live provider SSE frames" }, + { + value: "remote-debugger", + label: "Start: JS remote debugger", + description: "Expose JavaScriptCore inspector socket (experimental)", + }, { value: "transcript", label: "Export: TUI transcript", @@ -122,6 +128,9 @@ export class DebugSelectorComponent extends Container { case "raw-sse": await this.#handleViewRawSse(); break; + case "remote-debugger": + await this.#handleStartRemoteDebugger(); + break; case "system": await this.#handleViewSystemInfo(); break; @@ -353,6 +362,41 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } + async #handleStartRemoteDebugger(): Promise { + const existing = getRemoteDebugger(); + let info: RemoteDebuggerInfo; + try { + info = existing ?? (await startRemoteDebuggerServer()); + } catch (err) { + this.ctx.showError(`Failed to start remote debugger: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + const block = new TranscriptBlock(); + block.addChild( + new Text( + theme.fg( + "success", + `${theme.status.success} JavaScriptCore remote inspector ${existing ? "already running" : "started"}`, + ), + 1, + 0, + ), + ); + block.addChild(new Text(theme.fg("dim", `Listening on ${info.host}:${info.port}`), 1, 0)); + block.addChild( + new Text( + theme.fg( + "muted", + "Experimental WebKit RemoteInspectorServer socket (Bun marks it untested on macOS). One-way for this process — there is no stop. Attach a compatible WebKit/Safari Web Inspector client.", + ), + 1, + 0, + ), + ); + this.ctx.present(block); + } + async #handleViewSystemInfo(): Promise { try { const info = await collectSystemInfo(); diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index eb612a31c..4e45d37df 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -866,21 +866,21 @@ export class DebugLogViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } #copySelected() { diff --git a/packages/coding-agent/src/debug/raw-sse-buffer.ts b/packages/coding-agent/src/debug/raw-sse-buffer.ts index 1bb6d0b3e..b62614666 100644 --- a/packages/coding-agent/src/debug/raw-sse-buffer.ts +++ b/packages/coding-agent/src/debug/raw-sse-buffer.ts @@ -119,9 +119,16 @@ export class RawSseDebugBuffer { #records: RawSseDebugRecord[] = []; // Parallel to `#records`: `#recordChars[i]` is the precomputed char count // for `#records[i]`. Kept in lockstep by `#append` (push both) and - // `#enforceLimits` (shift both). See the comment above the class for why - // this is a sidecar array instead of a per-record property. + // `#enforceLimits` (advance `#head` to evict, then `slice` both together + // when compacting). See the comment above the class for why this is a + // sidecar array instead of a per-record property. #recordChars: number[] = []; + // Head-index ring over `#records`/`#recordChars`: index of the oldest live + // record. Eviction advances `#head` (amortized O(1)) rather than an O(n) + // front `shift()`; the dead `[0, #head)` prefix is reclaimed lazily by + // `#enforceLimits`. Live count is `#records.length - #head`; the live + // records are `#records[#head ..]`. + #head = 0; #totalChars = 0; #droppedRecords = 0; #droppedChars = 0; @@ -181,7 +188,7 @@ export class RawSseDebugBuffer { snapshot(): RawSseDebugSnapshot { return { - records: [...this.#records], + records: this.#records.slice(this.#head), droppedRecords: this.#droppedRecords, droppedChars: this.#droppedChars, totalEvents: this.#totalEvents, @@ -190,9 +197,12 @@ export class RawSseDebugBuffer { } toRawText(): string { - // Reads the live array directly: `rawRecordText` only computes a string - // from each record, so no caller-visible mutation is possible. - const body = this.#records.map(rawRecordText).join("\n"); + // Reads the live window directly: `rawRecordText` only computes a string + // from each record, so no caller-visible mutation is possible. With a + // non-empty dead prefix we map a slice past `#head`; `#head === 0` (the + // common case) maps `#records` in place with no extra copy. + const live = this.#head === 0 ? this.#records : this.#records.slice(this.#head); + const body = live.map(rawRecordText).join("\n"); if (this.#droppedRecords === 0) return body; const dropped = `: omp-debug-dropped records=${this.#droppedRecords} chars=${this.#droppedChars}\n\n`; return body.length > 0 ? `${dropped}${body}` : dropped; @@ -208,14 +218,25 @@ export class RawSseDebugBuffer { } #enforceLimits(): void { - while (this.#records.length > MAX_RAW_SSE_EVENTS || this.#totalChars > MAX_RAW_SSE_CHARS) { - if (this.#records.length === 0) return; - this.#records.shift(); - const chars = this.#recordChars.shift() ?? 0; + while (this.#records.length - this.#head > MAX_RAW_SSE_EVENTS || this.#totalChars > MAX_RAW_SSE_CHARS) { + if (this.#records.length - this.#head === 0) break; + const chars = this.#recordChars[this.#head] ?? 0; + this.#head += 1; this.#totalChars = Math.max(0, this.#totalChars - chars); this.#droppedRecords += 1; this.#droppedChars += chars; } + // Reclaim the consumed `[0, #head)` prefix once it grows large: one O(n) + // memmove amortized over many O(1) evictions, bounding the backing arrays + // to ~2x the live window. `#head >= MAX_RAW_SSE_EVENTS` covers the + // full-record-count steady state; `#head > liveCount` covers a small live + // window held by a few large records under the char budget. + const liveCount = this.#records.length - this.#head; + if (this.#head >= MAX_RAW_SSE_EVENTS || this.#head > liveCount) { + this.#records = this.#records.slice(this.#head); + this.#recordChars = this.#recordChars.slice(this.#head); + this.#head = 0; + } } #emit(): void { diff --git a/packages/coding-agent/src/debug/raw-sse.ts b/packages/coding-agent/src/debug/raw-sse.ts index b5c406a77..f1ba280f1 100644 --- a/packages/coding-agent/src/debug/raw-sse.ts +++ b/packages/coding-agent/src/debug/raw-sse.ts @@ -273,20 +273,20 @@ export class RawSseViewerComponent implements Component { } #frameTop(innerWidth: number): string { - return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`; + return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`; } #frameSeparator(innerWidth: number): string { - return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`; + return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`; } #frameBottom(innerWidth: number): string { - return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`; + return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`; } #frameLine(content: string, innerWidth: number): string { const truncated = truncateToWidth(content, innerWidth); const remaining = Math.max(0, innerWidth - visibleWidth(truncated)); - return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`; + return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`; } } diff --git a/packages/coding-agent/src/debug/remote-debugger.ts b/packages/coding-agent/src/debug/remote-debugger.ts new file mode 100644 index 000000000..170fc6e4c --- /dev/null +++ b/packages/coding-agent/src/debug/remote-debugger.ts @@ -0,0 +1,151 @@ +/** + * Bun JavaScriptCore remote inspector control. + * + * Wraps `bun:jsc`'s `startRemoteDebugger`, which exposes JavaScriptCore's + * built-in WebKit RemoteInspectorServer over a raw socket. The API is one-shot + * and rough around the edges (Bun documents it as untested, "may not be + * supported yet on macOS"): + * - it returns `void` and has no stop handle, so we track the live endpoint + * at module scope and make starting idempotent; + * - it rejects port `0`, so "let the OS pick" is implemented by reserving a + * free port via `node:net` and handing the concrete number to Bun; + * - on macOS (Bun 1.3.x) it throws a spurious "port already in use" error + * even when the server binds fine, so success is decided by a loopback + * probe rather than by whether the call threw. + */ + +import { startRemoteDebugger } from "bun:jsc"; +import * as net from "node:net"; + +const DEFAULT_HOST = "127.0.0.1"; +/** How long to keep probing for the inspector socket before giving up. */ +const PROBE_DEADLINE_MS = 1000; +const PROBE_INTERVAL_MS = 50; + +export interface RemoteDebuggerInfo { + host: string; + port: number; +} + +let active: RemoteDebuggerInfo | null = null; +/** In-flight start, shared so concurrent callers coalesce onto one launch. */ +let starting: Promise | null = null; + +/** Underlying starter signature; tests inject a disposable listener in its place. */ +export type RemoteDebuggerStarter = (host: string, port: number) => void; + +export interface StartRemoteDebuggerOptions { + /** Explicit port; when omitted a free port is reserved automatically. */ + port?: number; + /** Override the JSC starter. Defaults to `bun:jsc`'s `startRemoteDebugger`. */ + start?: RemoteDebuggerStarter; +} + +/** The live inspector endpoint for this process, or `null` if not started. */ +export function getRemoteDebugger(): RemoteDebuggerInfo | null { + return active; +} + +/** Reserve a free TCP port on `host` by binding to `0`, then releasing it. */ +async function reserveFreePort(host: string): Promise { + const server = net.createServer(); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, host, () => { + const addr = server.address(); + if (addr && typeof addr === "object") listening.resolve(addr.port); + else listening.reject(new Error("Failed to reserve a debugger port")); + }); + try { + return await listening.promise; + } finally { + const closed = Promise.withResolvers(); + server.close(() => closed.resolve()); + await closed.promise; + } +} + +/** Resolve once `host:port` accepts a TCP connection (within one attempt). */ +function tryConnect(host: string, port: number, timeoutMs: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + const socket = net.createConnection({ host, port }); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + return promise; +} + +/** Poll the inspector socket until it accepts a connection or the deadline passes. */ +async function waitForListening(host: string, port: number): Promise { + const deadline = Date.now() + PROBE_DEADLINE_MS; + do { + if (await tryConnect(host, port, PROBE_INTERVAL_MS)) return true; + await Bun.sleep(PROBE_INTERVAL_MS); + } while (Date.now() < deadline); + return false; +} + +/** + * Start the JavaScriptCore remote inspector for this process and return its + * endpoint. Idempotent: the underlying API cannot be stopped or rebound, so a + * second call returns the existing endpoint instead of starting again. When + * `port` is omitted a free port is reserved automatically. + * + * Throws only when the socket never comes up; Bun's spurious bind error is + * swallowed and overridden by the loopback probe. + */ +export async function startRemoteDebuggerServer(options: StartRemoteDebuggerOptions = {}): Promise { + if (active) return active; + starting ??= launch(options); + try { + return await starting; + } finally { + starting = null; + } +} + +async function launch({ port, start = startRemoteDebugger }: StartRemoteDebuggerOptions): Promise { + const host = DEFAULT_HOST; + const chosen = port ?? (await reserveFreePort(host)); + + // Something already on this port? Refuse up front: otherwise Bun throws a + // real bind error and our success probe would connect to that unrelated + // service, marking a bogus endpoint as the debugger. + if (await tryConnect(host, chosen, PROBE_INTERVAL_MS)) { + throw new Error(`Port ${host}:${chosen} is already in use; cannot start remote debugger there.`); + } + + let thrown: unknown; + try { + start(host, chosen); + } catch (err) { + // Bun's startRemoteDebugger throws a spurious bind error even on success, + // so defer the verdict to the loopback probe below. + thrown = err; + } + + if (await waitForListening(host, chosen)) { + active = { host, port: chosen }; + return active; + } + + throw thrown instanceof Error ? thrown : new Error(`Remote debugger socket never came up on ${host}:${chosen}`); +} + +/** + * Test-only: forget the tracked endpoint so a fresh start can be exercised. + * Does not (and cannot) stop a real JSC inspector — callers in tests own the + * disposable listener they injected. + */ +export function __resetRemoteDebuggerForTests(): void { + active = null; + starting = null; +} diff --git a/packages/coding-agent/src/debug/report-bundle.ts b/packages/coding-agent/src/debug/report-bundle.ts index 0e7914c47..f633ca64b 100644 --- a/packages/coding-agent/src/debug/report-bundle.ts +++ b/packages/coding-agent/src/debug/report-bundle.ts @@ -7,6 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { WorkProfile } from "@oh-my-pi/pi-natives"; import { APP_NAME, getLogPath, getLogsDir, getReportsDir, isEnoent } from "@oh-my-pi/pi-utils"; +import { writeArchive } from "../utils/zip"; import type { CpuProfile, HeapSnapshot } from "./profiler"; import { collectSystemInfo, sanitizeEnv } from "./system-info"; @@ -165,7 +166,7 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< } // Write archive - await Bun.Archive.write(outputPath, data, { compress: "gzip" }); + await writeArchive(outputPath, "tar.gz", Object.entries(data)); return { path: outputPath, files }; } diff --git a/packages/coding-agent/src/edit/file-snapshot-store.ts b/packages/coding-agent/src/edit/file-snapshot-store.ts index a1a94b0d9..3428cc2bd 100644 --- a/packages/coding-agent/src/edit/file-snapshot-store.ts +++ b/packages/coding-agent/src/edit/file-snapshot-store.ts @@ -102,7 +102,7 @@ const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/; /** * The 1-indexed file lines a hashline-formatted body actually displayed. * Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row - * (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` — + * (a `{ … }` brace pair) contributes only its boundary lines `NN` and `MM` — * the elided interior was never shown, so editing inside it must be rejected. */ export function parseSeenLinesFromHashlineBody(body: string): number[] { diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 1c81de0fa..e8fab3e0c 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -230,7 +230,7 @@ function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined } function formatEditTitlePath(pathValue: string, maxWidth?: number): string { - return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth); + return truncateEditTitlePath(replaceTabs(shortenPath(pathValue)), maxWidth); } function formatEditPathDisplay( @@ -275,8 +275,8 @@ function formatEditDescription( } function editHeaderLabelBudget(width: number, uiTheme: Theme): number { - const leftGlyphs = `${uiTheme.boxSharp.topLeft}${uiTheme.boxSharp.horizontal.repeat(3)}`; - return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxSharp.topRight) - 2); + const leftGlyphs = `${uiTheme.boxRound.topLeft}${uiTheme.boxRound.horizontal.repeat(3)}`; + return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxRound.topRight) - 2); } function renderEditHeader( @@ -326,11 +326,11 @@ function renderEditHeader( return buildHeader(fitted.description); } -function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string { +function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string { const previewLines = sanitizeText(text).split("\n"); let preview = "\n\n"; for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) { - preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`; + preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`; } if (previewLines.length > CALL_TEXT_PREVIEW_LINES) { preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`); @@ -395,7 +395,7 @@ function formatMultiFileStreamingDiff( if (!preview.diff && !preview.error) continue; const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`); if (preview.error) { - parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`); + parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`); continue; } if (preview.diff) { @@ -637,7 +637,7 @@ export const editToolRenderer = { callPreviewCaches, ); if (applyPatchSummary?.error) { - body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`; + body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), Math.max(1, width - 2)))}`; } const bodyLines = body ? body.split("\n") : []; while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift(); @@ -733,11 +733,11 @@ function renderSingleFileResult( let body = ""; if (isError) { - if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath)); + if (errorText) body = uiTheme.fg("error", replaceTabs(errorText)); } else if (details?.diff) { body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } else if (editDiffPreview) { - if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath)); + if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error)); else if (editDiffPreview.diff) body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } diff --git a/packages/coding-agent/src/eval/js/tool-bridge.ts b/packages/coding-agent/src/eval/js/tool-bridge.ts index 7b3745450..60768763b 100644 --- a/packages/coding-agent/src/eval/js/tool-bridge.ts +++ b/packages/coding-agent/src/eval/js/tool-bridge.ts @@ -1,4 +1,5 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { ToolSession } from "../../tools"; import { ToolError } from "../../tools/tool-errors"; import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge"; @@ -48,8 +49,8 @@ function normalizeArgs(args: unknown): unknown { return args; } const record = { ...(args as Record) }; - if (record._i === undefined) { - record._i = "js prelude"; + if (record[INTENT_FIELD] === undefined) { + record[INTENT_FIELD] = "js prelude"; } return record; } diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index b970d4e1e..d5f067e36 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -5,6 +5,7 @@ if "__omp_prelude_loaded__" not in globals(): from pathlib import Path import os, json, math, re from urllib.parse import unquote + INTENT_FIELD = "_i" # __omp_display is injected by runner.py before the prelude executes; it # mirrors IPython's display() semantics with the same MIME bundle output. @@ -479,8 +480,8 @@ if "__omp_prelude_loaded__" not in globals(): f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})" ) merged.update(kwargs) - if "_i" not in merged: - merged["_i"] = "py prelude" + if INTENT_FIELD not in merged: + merged[INTENT_FIELD] = "py prelude" return _bridge_call(self._name, merged) class _ToolProxy: diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 421b70603..1889c76e6 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -595,7 +595,11 @@ export class ExtensionRunner { } async emit(event: TEvent): Promise> { - const ctx = this.createContext(); + // Defer the per-event context allocation (and the Promise.race/Bun.sleep + // timeout machinery) to the first matching handler. Streaming sessions emit + // message_update / tool_execution_* per delta with usually no extension + // subscribed; building `ctx` for a zero-handler event is pure waste. + let ctx: ExtensionContext | undefined; let result: SessionBeforeEventResult | SessionCompactingResult | SessionStopEventResult | undefined; if (this.#isSessionShutdownEvent(event)) { @@ -604,17 +608,19 @@ export class ExtensionRunner { for (const ext of this.extensions) { const handlers = ext.handlers.get(event.type); if (!handlers || handlers.length === 0) continue; + ctx ??= this.createContext(); for (const handler of handlers) { promises.push(this.#runHandlerWithTimeout(handler, event, ctx, ext, timeoutMs)); } } - await Promise.all(promises); + if (promises.length > 0) await Promise.all(promises); return result as RunnerEmitResult; } for (const ext of this.extensions) { const handlers = ext.handlers.get(event.type); if (!handlers || handlers.length === 0) continue; + ctx ??= this.createContext(); for (const handler of handlers) { const handlerResult = await this.#runHandlerWithTimeout( diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 8b109c2c1..247287ff8 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -44,6 +44,7 @@ import type * as PiCodingAgent from "../../index"; import type { MemoryRuntimeContext } from "../../memory-backend"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; +import type { CompactMode } from "../../session/compact-modes"; import type { CustomMessage } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { @@ -287,6 +288,12 @@ export interface ContextUsage { export interface CompactOptions { onComplete?: (result: CompactionResult) => void; onError?: (error: Error) => void; + /** + * Force a one-off compaction mode for this invocation, overriding the + * configured `compaction.strategy` / `remoteEnabled` (the `/compact` + * subcommands: `soft` | `remote` | `snapcompact`). Omitted = configured behavior. + */ + mode?: CompactMode; } /** diff --git a/packages/coding-agent/src/goals/runtime.ts b/packages/coding-agent/src/goals/runtime.ts index 27b3189fb..ae65b9abf 100644 --- a/packages/coding-agent/src/goals/runtime.ts +++ b/packages/coding-agent/src/goals/runtime.ts @@ -178,8 +178,8 @@ export class GoalRuntime { } } - #markActiveAccounting(goal: Goal): void { - if (this.#wallClock.activeGoalId !== goal.id) { + #markActiveAccounting(goal: Goal, resetWallClock = false): void { + if (resetWallClock || this.#wallClock.activeGoalId !== goal.id) { this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id }; } if (this.#turnSnapshot) { @@ -195,6 +195,12 @@ export class GoalRuntime { } } + clearAccounting(): void { + this.#turnSnapshot = undefined; + this.#clearActiveAccounting(); + this.#budgetReportedFor = undefined; + } + onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void { this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } }; const state = this.#host.getState(); @@ -235,7 +241,7 @@ export class GoalRuntime { return; } await this.#withAccounting(async () => { - await this.#flushUsageLocked("suppressed"); + await this.#flushUsageLocked("suppressed", undefined, options?.reason === "internal"); this.#turnSnapshot = undefined; if (options?.reason !== "interrupted") return; const cloned = this.#getStateClone(); @@ -249,9 +255,14 @@ export class GoalRuntime { }); } - async onThreadResumed(): Promise { + async onThreadResumed(options?: { preserveActiveGoal?: boolean }): Promise { const state = this.#getStateClone(); if (!state) return undefined; + if (options?.preserveActiveGoal && state.enabled && state.goal.status === "active") { + this.#markActiveAccounting(state.goal, true); + await this.#commitState(state, { emit: true }); + return state; + } if (state.goal.status === "active") { state.enabled = false; state.goal.status = "paused"; @@ -301,6 +312,7 @@ export class GoalRuntime { async #flushUsageLocked( steering: GoalBudgetSteering, currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(), + persistWallClock = false, ): Promise { const state = this.#getStateClone(); if (!state?.enabled || !isAccountingStatus(state.goal)) return; @@ -333,10 +345,10 @@ export class GoalRuntime { if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) { this.#wallClock.lastAccountedAt += wallSeconds * 1000; } - // Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full - // objective snapshots. Keep the in-memory/UI state fresh, but persist only token/budget changes. - const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited; + // objective snapshots. Keep normal tool flushes in memory/UI only, but make wall-clock + // usage durable before internal session switches because the active runtime is leaving. + const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited || (persistWallClock && wallSeconds > 0); await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined }); if (state.goal.status !== "budget-limited") { diff --git a/packages/coding-agent/src/hindsight/mental-models.ts b/packages/coding-agent/src/hindsight/mental-models.ts index eb0e3fc29..e4d3e8c60 100644 --- a/packages/coding-agent/src/hindsight/mental-models.ts +++ b/packages/coding-agent/src/hindsight/mental-models.ts @@ -387,7 +387,7 @@ export function diffMentalModelContent(previous: string | null, current: string, if (out.length > maxLines) { const dropped = out.length - maxLines; - return `${out.slice(0, maxLines).join("\n")}\n… ${dropped} more line${dropped === 1 ? "" : "s"} elided`; + return `${out.slice(0, maxLines).join("\n")}\n[…${dropped}ln elided…]`; } return out.join("\n"); } diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.txt b/packages/coding-agent/src/internal-urls/docs-index.generated.txt new file mode 100644 index 000000000..e69de29bb diff --git a/packages/coding-agent/src/internal-urls/docs-index.ts b/packages/coding-agent/src/internal-urls/docs-index.ts new file mode 100644 index 000000000..c35b794b1 --- /dev/null +++ b/packages/coding-agent/src/internal-urls/docs-index.ts @@ -0,0 +1,102 @@ +/** + * Harness documentation index for the `omp://` protocol. + * + * Compiled binaries and the prepacked npm bundle inline a compressed index from + * `docs-index.generated.txt` (populated by `scripts/generate-docs-index.ts + * --generate` at build time). The format is two lines: + * 1. a plain JSON array of the sorted doc file names, and + * 2. a base64 gzip blob of the index-aligned doc bodies (`string[]`). + * Listing/completion (`getDocFilenames`) parses only the small first line and + * never inflates the blob; the bodies are gunzipped off the event loop (via the + * async `node:zlib` threadpool) lazily, once, on the first actual read. When the + * placeholder is empty (dev tree, source checkout), the index is read from the + * repo `docs/` directory on disk instead. + */ +import { readFileSync } from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { gunzip } from "node:zlib"; +import { Glob } from "bun"; +import docsEmbed from "./docs-index.generated.txt"; + +const gunzipAsync = promisify(gunzip); + +export interface DocsIndex { + /** Sorted documentation file names, relative to `docs/`. */ + readonly filenames: readonly string[]; + /** Resolve a doc body by path; inflates the embedded bodies off-thread, lazily, on first call. */ + getBody(relativePath: string): Promise; +} + +/** + * Decode a populated two-line embed (`\n`) + * into a lazily-inflating index, or `null` when there is no newline separator + * (the empty placeholder, or a malformed payload — the caller decides which). + * Reading `filenames` never touches the blob; the bodies are gunzipped off the + * event loop into a path→content table on the first `getBody` call, and that + * work is shared across concurrent reads. + */ +export function decodeDocsIndex(embed: string): DocsIndex | null { + const newline = embed.indexOf("\n"); + if (newline === -1) return null; + const filenames = JSON.parse(embed.slice(0, newline)) as string[]; + let bodies: Promise> | undefined; + return { + filenames, + getBody(relativePath: string): Promise { + bodies ??= (async () => { + const inflated = await gunzipAsync(Buffer.from(embed.slice(newline + 1), "base64")); + const decoded = JSON.parse(inflated.toString("utf8")) as string[]; + const map: Record = {}; + for (let i = 0; i < filenames.length; i++) map[filenames[i]] = decoded[i]; + return map; + })(); + return bodies.then(map => map[relativePath]); + }, + }; +} + +/** Dev tree / source checkout: build the index from the repo `docs/` directory. */ +function readDocsFromDisk(): DocsIndex { + const docsDir = path.resolve(import.meta.dir, "../../../../docs"); + const filenames: string[] = []; + const bodies: Record = {}; + for (const relativePath of new Glob("**/*.md").scanSync(docsDir)) { + const normalized = relativePath.split(path.sep).join("/"); + filenames.push(normalized); + bodies[normalized] = readFileSync(path.join(docsDir, relativePath), "utf8"); + } + filenames.sort(); + return { filenames, getBody: relativePath => Promise.resolve(bodies[relativePath]) }; +} + +let index: DocsIndex | undefined; +function getIndex(): DocsIndex { + if (index !== undefined) return index; + // Empty placeholder → dev tree / source checkout: read docs from disk. + if (docsEmbed.length === 0) { + index = readDocsFromDisk(); + return index; + } + // Populated embed in compiled binaries / npm bundle. A non-empty payload with + // no newline is a broken build (truncated/corrupt embed), not a placeholder. + const decoded = decodeDocsIndex(docsEmbed); + if (decoded === null) { + throw new Error( + "Malformed embedded docs index (docs-index.generated.txt): non-empty payload without a newline separator. " + + "Rebuild with `bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate`.", + ); + } + index = decoded; + return index; +} + +/** Sorted list of available documentation file names (relative to `docs/`). */ +export function getDocFilenames(): readonly string[] { + return getIndex().filenames; +} + +/** Resolve a documentation file's content, or `undefined` when not found. */ +export function getEmbeddedDoc(relativePath: string): Promise { + return getIndex().getBody(relativePath); +} diff --git a/packages/coding-agent/src/internal-urls/history-protocol.ts b/packages/coding-agent/src/internal-urls/history-protocol.ts index 89e96714e..e84a64449 100644 --- a/packages/coding-agent/src/internal-urls/history-protocol.ts +++ b/packages/coding-agent/src/internal-urls/history-protocol.ts @@ -40,9 +40,12 @@ export class HistoryProtocolHandler implements ProtocolHandler { async resolve(url: InternalUrl): Promise { const agentId = url.rawHost || url.hostname; const registry = AgentRegistry.global(); + // Advisor transcripts are observability-only — surfaced in the Agent Hub, never + // in the agent-facing roster. Hide them from the index, lookup, and completions. + const visible = registry.list().filter(ref => ref.kind !== "advisor"); if (!agentId) { - const content = this.#renderIndex(registry.list()); + const content = this.#renderIndex(visible); return { url: url.href, content, @@ -52,13 +55,14 @@ export class HistoryProtocolHandler implements ProtocolHandler { } let ref = registry.get(agentId); + if (ref?.kind === "advisor") ref = undefined; if (!ref) { // Case-insensitive fallback: agent ids are human-typed (e.g. AuthLoader). const lower = agentId.toLowerCase(); - ref = registry.list().find(candidate => candidate.id.toLowerCase() === lower); + ref = visible.find(candidate => candidate.id.toLowerCase() === lower); } if (!ref) { - const known = registry.list().map(candidate => candidate.id); + const known = visible.map(candidate => candidate.id); const knownStr = known.length > 0 ? known.join(", ") : "none"; throw new Error(`Unknown agent: ${agentId}\nKnown agents: ${knownStr}\nList all with history://`); } @@ -105,6 +109,7 @@ export class HistoryProtocolHandler implements ProtocolHandler { async complete(): Promise { return AgentRegistry.global() .list() + .filter(ref => ref.kind !== "advisor") .map(ref => ({ value: ref.id, description: `${ref.status} · ${ref.kind}${ref.parentId ? ` · parent ${ref.parentId}` : ""}`, diff --git a/packages/coding-agent/src/internal-urls/omp-protocol.ts b/packages/coding-agent/src/internal-urls/omp-protocol.ts index ee5e13534..9d0c2f532 100644 --- a/packages/coding-agent/src/internal-urls/omp-protocol.ts +++ b/packages/coding-agent/src/internal-urls/omp-protocol.ts @@ -8,7 +8,7 @@ * - omp://.md - Reads a specific documentation file */ import * as path from "node:path"; -import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated"; +import { getDocFilenames, getEmbeddedDoc } from "./docs-index"; import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types"; /** @@ -34,16 +34,17 @@ export class OmpProtocolHandler implements ProtocolHandler { } async complete(): Promise { - return EMBEDDED_DOC_FILENAMES.map(value => ({ value })); + return getDocFilenames().map(value => ({ value })); } async #listDocs(url: InternalUrl): Promise { - if (EMBEDDED_DOC_FILENAMES.length === 0) { + const filenames = getDocFilenames(); + if (filenames.length === 0) { throw new Error("No documentation files found"); } - const listing = EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](omp://${f})`).join("\n"); - const content = `# Documentation\n\n${EMBEDDED_DOC_FILENAMES.length} files available:\n\n${listing}\n`; + const listing = filenames.map(f => `- [${f}](omp://${f})`).join("\n"); + const content = `# Documentation\n\n${filenames.length} files available:\n\n${listing}\n`; return { url: url.href, @@ -70,12 +71,12 @@ export class OmpProtocolHandler implements ProtocolHandler { return this.#listDocs(url); } - const content = EMBEDDED_DOCS[docPath]; + const content = await getEmbeddedDoc(docPath); if (content === undefined) { const lookup = docPath.replace(/\.md$/, ""); - const suggestions = EMBEDDED_DOC_FILENAMES.filter( - f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")), - ).slice(0, 5); + const suggestions = getDocFilenames() + .filter(f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, ""))) + .slice(0, 5); const suffix = suggestions.length > 0 ? `\nDid you mean: ${suggestions.join(", ")}` diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts index ec35fe5cb..a40d59ec5 100644 --- a/packages/coding-agent/src/irc/bus.ts +++ b/packages/coding-agent/src/irc/bus.ts @@ -98,6 +98,14 @@ export class IrcBus { if (!ref || ref.status === "aborted") { return { to: message.to, outcome: "failed", error: `Unknown or terminated agent "${message.to}".` }; } + // Advisor refs are observability-only transcripts, never messageable peers. + if (ref.kind === "advisor") { + return { + to: message.to, + outcome: "failed", + error: `Agent "${message.to}" is a read-only advisor transcript and cannot be messaged.`, + }; + } let revived = false; if (ref.status === "parked") { diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index a2b6293e1..3a4ab3af0 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits"; import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; import type { LspClient, + LspJsonRpcId, LspJsonRpcNotification, LspJsonRpcRequest, LspJsonRpcResponse, @@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: * Handle workspace/workspaceFolders requests from the server. */ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders"); } @@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson * Handle workspace/configuration requests from the server. */ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { items?: Array<{ section?: string }> }; const items = params?.items ?? []; const result = items.map(item => { @@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc * Handle workspace/applyEdit requests from the server. */ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { edit?: WorkspaceEdit }; if (!params?.edit) { await sendResponse( @@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest return; } if (message.method === "window/workDoneProgress/create") { - // Accept progress token registration from the server - if (typeof message.id === "number") { - await sendResponse(client, message.id, null, message.method); - } + // Accept progress token registration from the server. + await sendResponse(client, message.id, null, message.method); + return; + } + if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") { + // Some servers block semantic requests until dynamic registration succeeds. + await sendResponse(client, message.id, null, message.method); return; } - if (typeof message.id !== "number") return; await sendResponse(client, message.id, null, message.method, { code: -32601, message: `Method not found: ${message.method}`, @@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest */ async function sendResponse( client: LspClient, - id: number, + id: LspJsonRpcId, result: unknown, method: string, error?: { code: number; message: string; data?: unknown }, diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index c04e2f14c..f85834aed 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -598,7 +598,7 @@ async function runWorkspaceDiagnostics( // Limit output length const lines = combined.split("\n"); if (lines.length > 50) { - return { output: `${lines.slice(0, 50).join("\n")}\n... and ${lines.length - 50} more lines`, projectType }; + return { output: `${lines.slice(0, 50).join("\n")}\n[…${lines.length - 50}ln elided…]`, projectType }; } return { output: combined, projectType }; } catch (e) { @@ -2035,7 +2035,7 @@ export class LspTool implements AgentTool formatSymbolInformation(s, this.session.cwd)); const truncationLine = dedupedSymbols.length > WORKSPACE_SYMBOL_LIMIT - ? `\n... ${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} additional symbol(s) omitted` + ? `\n[…${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} symbols elided…]` : ""; return { content: [ diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index dbe21945b..8953cafc3 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -223,10 +223,10 @@ function renderHover( const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : ""; if (expanded) { - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const top = `${theme.boxRound.topLeft}${h.repeat(3)}`; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; let output = `${icon}${langLabel}`; if (beforeCode) { for (const line of beforeCode.split("\n")) { @@ -254,9 +254,9 @@ function renderHover( const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; } - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; - const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; + const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`; output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`; if (codeLines.length > 1) { diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index d33b8ccca..13f2c6b80 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -399,7 +399,7 @@ export interface LspClient { diagnostics: Map; diagnosticsVersion: number; openFiles: Map; - pendingRequests: Map; + pendingRequests: Map; messageBuffer: Uint8Array; isReading: boolean; /** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */ @@ -420,16 +420,19 @@ export interface LspClient { // JSON-RPC Protocol Types // ============================================================================= +/** JSON-RPC request/response identifier accepted by LSP peers. */ +export type LspJsonRpcId = number | string; + export interface LspJsonRpcRequest { jsonrpc: "2.0"; - id: number; + id: LspJsonRpcId; method: string; params: unknown; } export interface LspJsonRpcResponse { jsonrpc: "2.0"; - id?: number; + id?: LspJsonRpcId; result?: unknown; error?: { code: number; message: string; data?: unknown }; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6b0539ef4..1f5971f3b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -11,6 +11,7 @@ import { EventLoopKeepalive } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { $env, + directoryExists, getLogPath, getProjectDir, logger, @@ -72,7 +73,7 @@ import { shouldShowStartupSplash } from "./startup-splash"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt"; import { createPersistedSubagentReviverFactory } from "./task/persisted-revive"; import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export"; -import { AUTO_THINKING } from "./thinking"; +import { AUTO_THINKING, parseConfiguredThinkingLevel } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; import { getChangelogPath, @@ -133,9 +134,12 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [ "memory.backend", "memories.enabled", // Advisor is interactive-session assistance. Protocol hosts opt in explicitly - // instead of inheriting a user's globally-enabled local preference. + // instead of inheriting a user's globally-enabled local preference, and when + // they do opt in they get the default tuning rather than the user's local tuning. "advisor.enabled", "advisor.subagents", + "advisor.syncBacklog", + "advisor.immuneTurns", ]; const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [ @@ -572,7 +576,11 @@ async function moveMissingCwdSessionIfNeeded( return { status: "declined" }; } - const manager = await SessionManager.open(session.path, sessionDir); + // Open anchored at the (now-missing) recorded cwd: `open` otherwise falls back + // to the launch cwd, which would make the `moveTo` below a no-op whenever the + // move target equals the current project dir. moveTo never chdirs, so the + // stale cwd is only a relocation source, not a directory we enter. + const manager = await SessionManager.open(session.path, sessionDir, undefined, { initialCwd: sourceCwd }); await manager.moveTo(cwd, sessionDir); return { status: "moved", manager }; } @@ -748,6 +756,20 @@ function discoverAppendSystemPromptFile(): string | undefined { return undefined; } +/** Apply resolved CLI/discovered prompt files without bypassing system prompt templates. */ +export function applyResolvedSystemPromptInputs( + options: CreateAgentSessionOptions, + resolvedSystemPrompt: string | undefined, + resolvedAppendPrompt: string | undefined, +): void { + if (resolvedSystemPrompt) { + options.customSystemPrompt = resolvedSystemPrompt; + } + if (resolvedAppendPrompt) { + options.appendSystemPrompt = resolvedAppendPrompt; + } +} + async function buildSessionOptions( parsed: Args, scopedModels: ScopedModel[], @@ -857,7 +879,7 @@ async function buildSessionOptions( if (scopedModels.length > 0) { // `auto` is a session-level concept only; per-scoped-model (Ctrl+P) thinking // overrides stay concrete, so coerce the auto default to "unset" here. - const defaultThinkingLevelSetting = activeSettings.get("defaultThinkingLevel"); + const defaultThinkingLevelSetting = parseConfiguredThinkingLevel(activeSettings.get("defaultThinkingLevel")); const defaultThinkingLevel = defaultThinkingLevelSetting === AUTO_THINKING ? undefined : defaultThinkingLevelSetting; options.scopedModels = scopedModels.map(scopedModel => ({ @@ -872,13 +894,7 @@ async function buildSessionOptions( // (handled by caller before createAgentSession) // System prompt - if (resolvedSystemPrompt && resolvedAppendPrompt) { - options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)]; - } else if (resolvedSystemPrompt) { - options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)]; - } else if (resolvedAppendPrompt) { - options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt]; - } + applyResolvedSystemPromptInputs(options, resolvedSystemPrompt, resolvedAppendPrompt); // Tools if (parsed.noTools) { @@ -1039,6 +1055,13 @@ export async function runRootCommand( }); } + // --print-thoughts (single-shot print mode) must surface reasoning, so un-hide + // thinking before the session is built — otherwise a passive hideThinkingBlock + // setting makes the provider omit summaries and the flag prints nothing. An + // explicit --hide-thinking below still wins. + if (parsedArgs.printThoughts && !isProtocolMode && !isInteractive) { + settingsInstance.override("hideThinkingBlock", false); + } // Apply --hide-thinking CLI flag (ephemeral, not persisted) if (parsedArgs.hideThinking) { settingsInstance.override("hideThinkingBlock", true); @@ -1131,7 +1154,14 @@ export async function runRootCommand( // Resuming a session from another project: switch the process into that // project's directory and refresh cwd-derived caches before the session is // built, so settings discovery, plugins, and capabilities all scope to it. - if (selected.cwd && normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir())) { + // Skip the chdir when the recorded project directory is gone: `setProjectDir` + // would throw on the missing path. `SessionManager.open` then falls back to + // the launch cwd, so the resumed session simply stays where the user is. + if ( + selected.cwd && + normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir()) && + (await directoryExists(selected.cwd)) + ) { // Let the original (launch-cwd) plugin-root preload settle first so its // late resolution can't clobber the re-warm we trigger below. await pluginPreloadPromise.catch(() => {}); @@ -1373,6 +1403,7 @@ export async function runRootCommand( messages: initialArgs.messages, initialMessage, initialImages, + printThoughts: initialArgs.printThoughts, }); if ($env.PI_TIMING) { logger.printTimings(); diff --git a/packages/coding-agent/src/markit/NOTICE b/packages/coding-agent/src/markit/NOTICE new file mode 100644 index 000000000..09c69d6aa --- /dev/null +++ b/packages/coding-agent/src/markit/NOTICE @@ -0,0 +1,32 @@ +This directory contains an in-house document-to-markdown engine adapted from +markit-ai (https://github.com/Michaelliv/markit), used under the MIT License. + + Copyright (c) 2026 Michael Liv + +Only the converters for the document formats omp supports are ported (pdf, +docx, pptx, xlsx, epub); the CLI, plugin/provider, and unused converters +(html, image, audio, plain-text, rss, github, wikipedia, csv, json, yaml, +ipynb, iwork, zip, xml) were dropped. Legacy binary `.doc`/`.ppt`/`.xls` and +`.rtf` are routed by the read/fetch tools but have no converter — they surface +a conversion error, exactly as upstream markit did. Logic is ported faithfully +so conversion output matches the upstream package. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/coding-agent/src/markit/converters/docx.ts b/packages/coding-agent/src/markit/converters/docx.ts new file mode 100644 index 000000000..5dfe643fb --- /dev/null +++ b/packages/coding-agent/src/markit/converters/docx.ts @@ -0,0 +1,56 @@ +// Adapted from markit-ai (MIT). See ../NOTICE. +import * as path from "node:path"; +import mammoth from "mammoth"; +import { createTurndown, normalizeTablesHtml } from "../../utils/turndown"; +import type { ConversionResult, Converter, StreamInfo } from "../types"; + +const EXTENSIONS = [".docx"]; +const MIMETYPES = ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"]; + +export class DocxConverter implements Converter { + name = "docx"; + + accepts(streamInfo: StreamInfo): boolean { + if (streamInfo.extension && EXTENSIONS.includes(streamInfo.extension)) { + return true; + } + if (streamInfo.mimetype && MIMETYPES.some(m => streamInfo.mimetype?.startsWith(m))) { + return true; + } + return false; + } + + async convert(input: Buffer, streamInfo: StreamInfo): Promise { + const imageDir = streamInfo.imageDir; + let imageCount = 0; + const convertImage = imageDir + ? mammoth.images.imgElement(image => { + imageCount++; + const ext = (image.contentType?.split("/")[1] || "png").replace("jpeg", "jpg"); + const filename = `image_${imageCount}.${ext}`; + const filepath = path.join(imageDir, filename); + return image.read("base64").then(async base64 => { + await Bun.write(filepath, Buffer.from(base64, "base64")); + return { src: filepath, alt: `image_${imageCount}` }; + }); + }) + : mammoth.images.imgElement(image => { + imageCount++; + const contentType = image.contentType || "image/png"; + return image.read("base64").then(base64 => { + return { + src: `data:${contentType};base64,${base64.slice(0, 0)}`, + alt: `image_${imageCount}`, + }; + }); + }); + const { value: html } = await mammoth.convertToHtml({ buffer: input }, { convertImage }); + const turndown = createTurndown(); + let markdown = turndown.turndown(normalizeTablesHtml(html)); + // Replace data URI images with comment placeholders when no imageDir + if (!imageDir) { + markdown = markdown.replace(/!\[([^\]]*)\]\(data:[^)]*\)/g, ""); + } + return { markdown: markdown.trim() }; + } +} diff --git a/packages/coding-agent/src/markit/converters/epub.ts b/packages/coding-agent/src/markit/converters/epub.ts new file mode 100644 index 000000000..dda23c0e9 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/epub.ts @@ -0,0 +1,136 @@ +// Adapted from markit-ai (MIT). See ../NOTICE. +import { XMLParser } from "fast-xml-parser"; +import { createTurndown, normalizeTablesHtml } from "../../utils/turndown"; +import { unzip, unzipText } from "../../utils/zip"; +import type { ConversionResult, Converter, StreamInfo } from "../types"; + +const EXTENSIONS = [".epub"]; +const MIMETYPES = ["application/epub", "application/epub+zip", "application/x-epub+zip"]; + +/** A metadata value: a bare string, or a node carrying `#text` and/or array children. */ +type MetaValue = string | MetaNode; +interface MetaNode { + "#text"?: string; + [index: number]: MetaValue; +} +interface Metadata { + "dc:title"?: MetaValue; + "dc:creator"?: MetaValue; + "dc:language"?: MetaValue; + "dc:publisher"?: MetaValue; + "dc:date"?: MetaValue; + "dc:description"?: MetaValue; +} +interface ManifestItem { + "@_id": string; + "@_href": string; +} +interface SpineItem { + "@_idref": string; +} +interface OpfDoc { + package?: { + metadata?: Metadata; + manifest?: { item?: ManifestItem | ManifestItem[] }; + spine?: { itemref?: SpineItem | SpineItem[] }; + }; +} +interface Rootfile { + "@_full-path": string; +} +interface ContainerDoc { + container?: { rootfiles?: { rootfile?: Rootfile | Rootfile[] } }; +} + +export class EpubConverter implements Converter { + name = "epub"; + + accepts(streamInfo: StreamInfo): boolean { + if (streamInfo.extension && EXTENSIONS.includes(streamInfo.extension)) return true; + if (streamInfo.mimetype && MIMETYPES.some(m => streamInfo.mimetype?.startsWith(m))) return true; + return false; + } + + async convert(input: Buffer, _streamInfo: StreamInfo): Promise { + const entries = unzip(input); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + textNodeName: "#text", + processEntities: { maxTotalExpansions: 1_000_000 }, + }); + // Find content.opf path from container.xml + const containerXml = unzipText(entries, "META-INF/container.xml"); + if (!containerXml) throw new Error("Invalid EPUB: missing container.xml"); + const container = parser.parse(containerXml) as ContainerDoc; + const rootfile = container.container?.rootfiles?.rootfile; + const opfPath = Array.isArray(rootfile) ? rootfile[0]["@_full-path"] : rootfile?.["@_full-path"]; + if (!opfPath) throw new Error("Invalid EPUB: missing rootfile path"); + // Parse content.opf + const opfXml = unzipText(entries, opfPath); + if (!opfXml) throw new Error("Invalid EPUB: missing content.opf"); + const opf = parser.parse(opfXml) as OpfDoc; + // Extract metadata + const meta: Metadata = opf.package?.metadata ?? {}; + const metadata: Record = { + title: this.getText(meta["dc:title"]), + authors: this.getTextArray(meta["dc:creator"]).join(", ") || undefined, + language: this.getText(meta["dc:language"]), + publisher: this.getText(meta["dc:publisher"]), + date: this.getText(meta["dc:date"]), + description: this.getText(meta["dc:description"]), + }; + // Build manifest map (id → href) + const manifestItems = opf.package?.manifest?.item; + const itemList = Array.isArray(manifestItems) ? manifestItems : manifestItems ? [manifestItems] : []; + const manifest = new Map(); + for (const item of itemList) { + manifest.set(item["@_id"], item["@_href"]); + } + // Get spine order + const spineItems = opf.package?.spine?.itemref; + const spineList = Array.isArray(spineItems) ? spineItems : spineItems ? [spineItems] : []; + const spineOrder = spineList.map(s => s["@_idref"]); + // Resolve file paths + const basePath = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : ""; + const turndown = createTurndown(); + const sections: string[] = []; + // Add metadata header + const metaLines: string[] = []; + for (const key in metadata) { + const value = metadata[key]; + if (value) metaLines.push(`**${key.charAt(0).toUpperCase() + key.slice(1)}:** ${value}`); + } + if (metaLines.length > 0) sections.push(metaLines.join("\n")); + // Convert spine files + for (const idref of spineOrder) { + const href = manifest.get(idref); + if (!href) continue; + const filePath = basePath ? `${basePath}/${href}` : href; + const html = unzipText(entries, filePath); + if (!html) continue; + // Strip script/style, convert to markdown + const cleaned = html.replace(//gi, "").replace(//gi, ""); + const md = turndown.turndown(normalizeTablesHtml(cleaned)).trim(); + if (md) sections.push(md); + } + return { + markdown: sections.join("\n\n").trim(), + title: metadata.title, + }; + } + + getText(node: MetaValue | undefined): string | undefined { + if (!node) return undefined; + if (typeof node === "string") return node; + if (node["#text"]) return String(node["#text"]); + if (Array.isArray(node)) return this.getText(node[0]); + return undefined; + } + + getTextArray(node: MetaValue | undefined): (string | undefined)[] { + if (!node) return []; + const list = Array.isArray(node) ? node : [node]; + return list.map(n => this.getText(n)).filter(Boolean); + } +} diff --git a/packages/coding-agent/src/markit/converters/mammoth.d.ts b/packages/coding-agent/src/markit/converters/mammoth.d.ts new file mode 100644 index 000000000..012333658 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/mammoth.d.ts @@ -0,0 +1,24 @@ +// Minimal ambient types for `mammoth` (ships no types). Declares only what +// DocxConverter uses. See ../NOTICE. +declare module "mammoth" { + interface MammothImage { + contentType?: string; + read(encoding: "base64"): Promise; + } + interface ImgAttributes { + src: string; + alt?: string; + } + type ConvertImageHandler = (image: MammothImage) => Promise; + interface ConvertOptions { + convertImage?: ConvertImageHandler; + } + interface ConvertResult { + value: string; + messages: unknown[]; + } + export const images: { imgElement(fn: ConvertImageHandler): ConvertImageHandler }; + export function convertToHtml(input: { buffer: Buffer }, options?: ConvertOptions): Promise; + const _default: { convertToHtml: typeof convertToHtml; images: typeof images }; + export default _default; +} diff --git a/packages/coding-agent/src/markit/converters/pdf/columns.ts b/packages/coding-agent/src/markit/converters/pdf/columns.ts new file mode 100644 index 000000000..ab3c83bcb --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/columns.ts @@ -0,0 +1,103 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * Multi-column layout detection and text box reordering. + * + * Many PDFs (legal documents, datasheets, academic papers) use two-column + * layouts. Without column detection, text boxes are ordered by Y position + * only, interleaving left and right column content. + * + * Algorithm: + * 1. Collect left edges of all text boxes on the page + * 2. Find the largest horizontal gap between consecutive left edges + * 3. If gap > MIN_GAP_RATIO of the text width and both sides have + * enough boxes → multi-column detected + * 4. Assign each text box to a column based on its center X + * 5. Return columns in reading order (left-to-right, top-to-bottom) + * + * This only detects the column structure. The caller is responsible for + * processing each column's text boxes independently (table detection, + * rendering, etc.). + */ +import type { TextBox } from "./types"; + +export interface ColumnLayout { + /** Number of columns detected (1 = single column, 2+ = multi-column). */ + columnCount: number; + /** Text boxes grouped by column, in reading order (left to right). */ + columns: TextBox[][]; + /** X positions of column boundaries (between columns). */ + boundaries: number[]; +} + +/** + * Minimum gap as a fraction of the total text width to consider a column + * boundary. A two-column layout typically has ~50% gap; we use a lower + * threshold to catch asymmetric columns. + */ +const MIN_GAP_RATIO = 0.15; +/** Minimum number of text boxes on each side of the gap. */ +const MIN_BOXES_PER_COLUMN = 4; +/** Minimum gap in absolute points to avoid splitting on small whitespace. */ +const MIN_GAP_PTS = 40; + +/** + * Detect column layout and return text boxes grouped by column. + * + * For single-column pages, returns all boxes in one group. + * For multi-column pages, returns boxes split by column in reading order. + */ +export function detectColumns(textBoxes: TextBox[]): ColumnLayout { + if (textBoxes.length < MIN_BOXES_PER_COLUMN * 2) { + return { columnCount: 1, columns: [textBoxes], boundaries: [] }; + } + // Collect unique left edges (rounded to avoid float noise) + const lefts = [...new Set(textBoxes.map(tb => Math.round(tb.bounds.left)))].sort((a, b) => a - b); + if (lefts.length < 2) { + return { columnCount: 1, columns: [textBoxes], boundaries: [] }; + } + const textXMin = lefts[0]; + const textXMax = Math.max(...textBoxes.map(tb => Math.round(tb.bounds.right))); + const textWidth = textXMax - textXMin; + if (textWidth <= 0) { + return { columnCount: 1, columns: [textBoxes], boundaries: [] }; + } + // Find the largest gap between consecutive left-edge positions + let maxGap = 0; + let gapLeft = 0; + let gapRight = 0; + for (let i = 1; i < lefts.length; i++) { + const gap = lefts[i] - lefts[i - 1]; + if (gap > maxGap) { + maxGap = gap; + gapLeft = lefts[i - 1]; + gapRight = lefts[i]; + } + } + const gapRatio = maxGap / textWidth; + if (gapRatio < MIN_GAP_RATIO || maxGap < MIN_GAP_PTS) { + return { columnCount: 1, columns: [textBoxes], boundaries: [] }; + } + // Split point is the midpoint of the gap + const splitX = (gapLeft + gapRight) / 2; + // Assign boxes to columns based on center X + const leftCol: TextBox[] = []; + const rightCol: TextBox[] = []; + for (const tb of textBoxes) { + const cx = (tb.bounds.left + tb.bounds.right) / 2; + if (cx < splitX) { + leftCol.push(tb); + } else { + rightCol.push(tb); + } + } + // Validate both columns have enough content + if (leftCol.length < MIN_BOXES_PER_COLUMN || rightCol.length < MIN_BOXES_PER_COLUMN) { + return { columnCount: 1, columns: [textBoxes], boundaries: [] }; + } + return { + columnCount: 2, + columns: [leftCol, rightCol], + boundaries: [splitX], + }; +} diff --git a/packages/coding-agent/src/markit/converters/pdf/extract.ts b/packages/coding-agent/src/markit/converters/pdf/extract.ts new file mode 100644 index 000000000..943711e19 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/extract.ts @@ -0,0 +1,574 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * PDF content extraction using mupdf. + * + * Extracts text boxes (with position, font size, bold) and vector line + * segments (table borders) from each page. Uses mupdf's native WASM + * engine for fast parsing, and reads raw content streams for vector graphics. + * + * Coordinate system: PDF native (origin = bottom-left, Y increases upward). + */ +import type * as mupdf from "mupdf"; +import type { ImageRegion, PageContent, Segment, TextBox } from "./types"; + +// mupdf instantiates its WASM module via a top-level await. A static +// `import * as mupdf` would pull that await into this module's init, which makes +// the whole bundled markit chunk's `__esm` init async — and bun's compiled +// bundler fails to await that init transitively through the `../markit` barrel, +// exposing the converter classes before their module-level consts initialize +// (e.g. `EXTENSIONS` reads as undefined). Importing mupdf lazily keeps the chunk +// init synchronous and also keeps the ~10MB wasm off non-PDF conversions. +let mupdfModule: typeof mupdf | undefined; +async function loadMupdf(): Promise { + if (!mupdfModule) { + mupdfModule = await import("mupdf"); + } + return mupdfModule; +} + +/** mupdf structured-text JSON bounding box (top-left origin). */ +interface StextBBox { + x: number; + y: number; + w: number; + h: number; +} + +/** Font metadata attached to a structured-text line. */ +interface StextFont { + size?: number; + weight?: string; + name?: string; +} + +/** A line within a text block in mupdf structured-text JSON. */ +interface StextLine { + text?: string; + font?: StextFont; + bbox: StextBBox; +} + +/** A block (text or image) in mupdf structured-text JSON. */ +interface StextBlock { + type: string; + bbox: StextBBox; + lines: StextLine[]; +} + +/** Parsed mupdf structured-text JSON for a page. */ +interface StructuredTextJSON { + blocks: StextBlock[]; +} + +/** A raw text fragment before merging into word/phrase boxes. */ +interface RawTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; + fontSize: number; + isBold: boolean; +} + +// --------------------------------------------------------------------------- +// Text extraction +// --------------------------------------------------------------------------- +/** Y tolerance for merging text fragments on the same visual line. */ +const SAME_LINE_Y_TOLERANCE = 2; +/** Max horizontal gap (pts) to merge adjacent fragments into one text box. */ +const MAX_MERGE_GAP = 14; + +/** + * Merge horizontally adjacent raw text items on the same visual line into + * word/phrase-level text boxes. + */ +function mergeIntoWords(raws: RawTextItem[]): RawTextItem[] { + if (raws.length === 0) return []; + // Sort by Y descending (top-first in bottom-left coords), then X ascending + const sorted = [...raws].sort((a, b) => { + const dy = b.y - a.y; + return Math.abs(dy) > SAME_LINE_Y_TOLERANCE ? dy : a.x - b.x; + }); + const merged: RawTextItem[] = []; + let cur = { ...sorted[0] }; + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + const sameY = Math.abs(next.y - cur.y) <= SAME_LINE_Y_TOLERANCE; + const close = next.x <= cur.x + cur.width + MAX_MERGE_GAP; + if (sameY && close) { + const gap = next.x - (cur.x + cur.width); + const sep = gap > 1 ? " " : ""; + cur.text += sep + next.text; + cur.width = next.x + next.width - cur.x; + cur.height = Math.max(cur.height, next.height); + cur.fontSize = Math.max(cur.fontSize, next.fontSize); + cur.isBold = cur.isBold || next.isBold; + } else { + merged.push(cur); + cur = { ...next }; + } + } + merged.push(cur); + return merged; +} + +/** + * Extract text boxes from a mupdf page using structured text output. + * + * mupdf's structured text JSON uses top-left origin; we convert to + * bottom-left (standard PDF coordinates) using the page height. + */ +function extractTextBoxes( + page: mupdf.Page, + pageNumber: number, + pageHeight: number, + stext?: StructuredTextJSON, +): TextBox[] { + if (!stext) { + stext = JSON.parse(page.toStructuredText("preserve-whitespace").asJSON()) as StructuredTextJSON; + } + const raws: RawTextItem[] = []; + for (const block of stext.blocks) { + if (block.type !== "text") continue; + for (const line of block.lines) { + const text = line.text?.trim(); + if (!text) continue; + const fontSize = line.font?.size ?? 0; + const weight = line.font?.weight ?? "normal"; + const fontName = line.font?.name ?? ""; + const isBold = weight === "bold" || /bold/i.test(fontName) || /Black|Heavy/i.test(fontName); + // mupdf bbox: {x, y, w, h} in top-left coords + // Convert to bottom-left: pdfY = pageHeight - (bbox.y + bbox.h) + const bboxY = line.bbox.y; + const bboxH = line.bbox.h; + const pdfY = pageHeight - (bboxY + bboxH); + raws.push({ + text, + x: line.bbox.x, + y: pdfY, + width: line.bbox.w, + height: bboxH, + fontSize, + isBold, + }); + } + } + const words = mergeIntoWords(raws); + return words + .map((w, i) => ({ + id: `p${pageNumber}-t${i}`, + text: w.text.trim(), + pageNumber, + fontSize: w.fontSize, + isBold: w.isBold, + bounds: { + left: w.x, + right: w.x + w.width, + bottom: w.y, + top: w.y + w.height, + }, + })) + .filter(b => b.text.length > 0); +} + +// --------------------------------------------------------------------------- +// Vector segment extraction from raw content stream +// --------------------------------------------------------------------------- +/** Minimum aspect ratio for a filled rect to be considered a line. */ +const LINE_ASPECT_THRESHOLD = 6; +/** Minimum length (pts) for a segment to count. */ +const MIN_LENGTH = 2; +/** Maximum thickness (pts) for a border line (filters out filled areas). */ +const MAX_THICKNESS = 3; + +/** + * Convert a thin filled rectangle to a horizontal or vertical segment. + * Returns null if the rect doesn't look like a border line. + */ +function thinRectToSegment(id: string, x: number, y: number, w: number, h: number): Segment | null { + const aw = Math.abs(w); + const ah = Math.abs(h); + if (aw > ah * LINE_ASPECT_THRESHOLD && aw >= MIN_LENGTH && ah <= MAX_THICKNESS) { + // Horizontal line + const cy = y + ah / 2; + return { id, x1: x, y1: cy, x2: x + aw, y2: cy }; + } + if (ah > aw * LINE_ASPECT_THRESHOLD && ah >= MIN_LENGTH && aw <= MAX_THICKNESS) { + // Vertical line + const cx = x + aw / 2; + return { id, x1: cx, y1: y, x2: cx, y2: y + ah }; + } + return null; +} + +/** + * Emit 4 edge segments from a stroked rectangle. + */ +function pushStrokedRectEdges(segments: Segment[], id: string, x: number, y: number, w: number, h: number): void { + const aw = Math.abs(w); + const ah = Math.abs(h); + const base = id; + if (aw >= MIN_LENGTH) { + segments.push({ id: `${base}-b`, x1: x, y1: y, x2: x + aw, y2: y }); + segments.push({ + id: `${base}-t`, + x1: x, + y1: y + ah, + x2: x + aw, + y2: y + ah, + }); + } + if (ah >= MIN_LENGTH) { + segments.push({ id: `${base}-l`, x1: x, y1: y, x2: x, y2: y + ah }); + segments.push({ + id: `${base}-r`, + x1: x + aw, + y1: y, + x2: x + aw, + y2: y + ah, + }); + } +} + +const CTM_IDENTITY = [1, 0, 0, 1, 0, 0]; + +/** Concatenate two affine matrices: result = parent × child. */ +function ctmConcat(p: number[], c: number[]): number[] { + return [ + p[0] * c[0] + p[2] * c[1], + p[1] * c[0] + p[3] * c[1], + p[0] * c[2] + p[2] * c[3], + p[1] * c[2] + p[3] * c[3], + p[0] * c[4] + p[2] * c[5] + p[4], + p[1] * c[4] + p[3] * c[5] + p[5], + ]; +} + +function ctmApply(m: number[], x: number, y: number): [number, number] { + return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; +} + +// --------------------------------------------------------------------------- +// Content stream parsing +// --------------------------------------------------------------------------- +/** + * Parse a PDF content stream and extract line segments from thin filled + * rectangles (re+f), stroked rectangles (re+S), and explicit lines (m/l+S). + * Tracks the CTM via q/Q/cm operators so coordinates are in page space. + */ +function extractSegmentsFromContentStream(raw: string, pageNumber: number): Segment[] { + const segments: Segment[] = []; + const tokens = tokenizeContentStream(raw); + let idx = 0; + let strokeWidth = 1.0; + // Graphics state stack (q/Q): saves CTM + strokeWidth + let ctm = [...CTM_IDENTITY]; + const stateStack: Array<{ ctm: number[]; strokeWidth: number }> = []; + // State for path building (in user coordinates, pre-CTM) + let curX = 0; + let curY = 0; + let pathStartX = 0; + let pathStartY = 0; + const pendingRects: Array<{ x: number; y: number; w: number; h: number }> = []; + const pendingLines: Array<{ x1: number; y1: number; x2: number; y2: number }> = []; + function flushPath(mode: "fill" | "stroke"): void { + const sid = () => `p${pageNumber}-s${segments.length}`; + if (mode === "fill") { + for (const r of pendingRects) { + // Transform the rect corners through CTM, then check if it's a thin line + const [x0, y0] = ctmApply(ctm, r.x, r.y); + const [x1, y1] = ctmApply(ctm, r.x + r.w, r.y + r.h); + const seg = thinRectToSegment( + sid(), + Math.min(x0, x1), + Math.min(y0, y1), + Math.abs(x1 - x0), + Math.abs(y1 - y0), + ); + if (seg) segments.push(seg); + } + } else if (mode === "stroke" && strokeWidth <= MAX_THICKNESS) { + for (const r of pendingRects) { + const [x0, y0] = ctmApply(ctm, r.x, r.y); + const [x1, y1] = ctmApply(ctm, r.x + r.w, r.y + r.h); + pushStrokedRectEdges( + segments, + sid(), + Math.min(x0, x1), + Math.min(y0, y1), + Math.abs(x1 - x0), + Math.abs(y1 - y0), + ); + } + for (const l of pendingLines) { + const [lx1, ly1] = ctmApply(ctm, l.x1, l.y1); + const [lx2, ly2] = ctmApply(ctm, l.x2, l.y2); + const dx = Math.abs(lx2 - lx1); + const dy = Math.abs(ly2 - ly1); + // Only keep H/V lines + if ((dx >= MIN_LENGTH && dy < 1) || (dy >= MIN_LENGTH && dx < 1)) { + segments.push({ id: sid(), x1: lx1, y1: ly1, x2: lx2, y2: ly2 }); + } + } + } + pendingRects.length = 0; + pendingLines.length = 0; + } + while (idx < tokens.length) { + const t = tokens[idx]; + if (t === "q") { + stateStack.push({ ctm: [...ctm], strokeWidth }); + } else if (t === "Q") { + const saved = stateStack.pop(); + if (saved) { + ctm = saved.ctm; + strokeWidth = saved.strokeWidth; + } + } else if (t === "cm" && idx >= 6) { + const a = Number(tokens[idx - 6]); + const b = Number(tokens[idx - 5]); + const c = Number(tokens[idx - 4]); + const d = Number(tokens[idx - 3]); + const e = Number(tokens[idx - 2]); + const f = Number(tokens[idx - 1]); + ctm = ctmConcat(ctm, [a, b, c, d, e, f]); + } else if (t === "w" && idx >= 1) { + strokeWidth = Number(tokens[idx - 1]) || strokeWidth; + } else if (t === "re" && idx >= 4) { + const x = Number(tokens[idx - 4]); + const y = Number(tokens[idx - 3]); + const w = Number(tokens[idx - 2]); + const h = Number(tokens[idx - 1]); + if (Number.isFinite(x + y + w + h)) { + pendingRects.push({ x, y, w, h }); + } + } else if (t === "m" && idx >= 2) { + curX = Number(tokens[idx - 2]); + curY = Number(tokens[idx - 1]); + pathStartX = curX; + pathStartY = curY; + } else if (t === "l" && idx >= 2) { + const x2 = Number(tokens[idx - 2]); + const y2 = Number(tokens[idx - 1]); + pendingLines.push({ x1: curX, y1: curY, x2, y2 }); + curX = x2; + curY = y2; + } else if (t === "h") { + // closePath: line back to start + if (curX !== pathStartX || curY !== pathStartY) { + pendingLines.push({ + x1: curX, + y1: curY, + x2: pathStartX, + y2: pathStartY, + }); + } + curX = pathStartX; + curY = pathStartY; + } else if (t === "f" || t === "F" || t === "f*") { + flushPath("fill"); + } else if (t === "S" || t === "s") { + if (t === "s") { + // closeStroke: implicit closePath + if (curX !== pathStartX || curY !== pathStartY) { + pendingLines.push({ + x1: curX, + y1: curY, + x2: pathStartX, + y2: pathStartY, + }); + } + } + flushPath("stroke"); + } else if (t === "B" || t === "B*" || t === "b" || t === "b*") { + // fill + stroke combined + flushPath("fill"); + flushPath("stroke"); + } else if (t === "n") { + // end path without painting — discard + pendingRects.length = 0; + pendingLines.length = 0; + } + idx++; + } + return segments; +} + +/** + * Fast tokenizer for PDF content streams. + * Splits on whitespace, skipping comments and string literals. + */ +function tokenizeContentStream(raw: string): string[] { + const tokens: string[] = []; + const len = raw.length; + let i = 0; + while (i < len) { + const ch = raw.charCodeAt(i); + // Skip whitespace + if (ch <= 32) { + i++; + continue; + } + // Skip comments + if (ch === 37 /* % */) { + while (i < len && raw.charCodeAt(i) !== 10) i++; + continue; + } + // Skip string literals (...) + if (ch === 40 /* ( */) { + let depth = 1; + i++; + while (i < len && depth > 0) { + const c = raw.charCodeAt(i); + if (c === 92 /* \ */) { + i++; + } else if (c === 40) { + depth++; + } else if (c === 41) { + depth--; + } + i++; + } + continue; + } + // Skip hex strings <...> + if (ch === 60 /* < */ && i + 1 < len && raw.charCodeAt(i + 1) !== 60) { + i++; + while (i < len && raw.charCodeAt(i) !== 62) i++; + i++; // skip > + continue; + } + // Skip dict delimiters << >> + if (ch === 60 && i + 1 < len && raw.charCodeAt(i + 1) === 60) { + i += 2; + continue; + } + if (ch === 62 && i + 1 < len && raw.charCodeAt(i + 1) === 62) { + i += 2; + continue; + } + // Regular token: read until whitespace or delimiter + const start = i; + while (i < len) { + const c = raw.charCodeAt(i); + if (c <= 32 || c === 40 || c === 41 || c === 60 || c === 62 || c === 37) break; + i++; + } + if (i > start) { + tokens.push(raw.substring(start, i)); + } + } + return tokens; +} + +// --------------------------------------------------------------------------- +// Image region detection +// --------------------------------------------------------------------------- +/** Minimum area (pts²) for an image to be considered a diagram, not an icon. */ +const MIN_IMAGE_AREA = 5000; + +function extractImageRegions(stext: StructuredTextJSON, pageNumber: number, pageHeight: number): ImageRegion[] { + const regions: ImageRegion[] = []; + for (const block of stext.blocks) { + if (block.type !== "image") continue; + const { x, y, w, h } = block.bbox; + if (w * h < MIN_IMAGE_AREA) continue; // skip tiny icons + // Convert Y from mupdf (top-left) to PDF (bottom-left) for ordering + const pdfTopY = pageHeight - y; + regions.push({ + id: `p${pageNumber}-img${regions.length}`, + pageNumber, + bbox: { x, y, w, h }, + topY: pdfTopY, + }); + } + return regions; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +/** + * Render an image region from a PDF page as a PNG buffer. + * Uses mupdf's DrawDevice to render just the cropped area at 2x resolution. + */ +export async function renderImageRegion(input: Uint8Array, region: ImageRegion): Promise { + const m = await loadMupdf(); + const doc = m.Document.openDocument(input, "application/pdf"); + const page = doc.loadPage(region.pageNumber - 1); + const pad = 10; + const bx = region.bbox.x - pad; + const by = region.bbox.y - pad; + const bw = region.bbox.w + 2 * pad; + const bh = region.bbox.h + 2 * pad; + const scale = 2; + const pw = Math.round(bw * scale); + const ph = Math.round(bh * scale); + const pix = new m.Pixmap(m.ColorSpace.DeviceRGB, [0, 0, pw, ph], false); + pix.clear(255); + const matrix: mupdf.Matrix = [scale, 0, 0, scale, -bx * scale, -by * scale]; + const dl = page.toDisplayList(); + const dev = new m.DrawDevice(matrix, pix); + dl.run(dev, m.Matrix.identity); + dev.close(); + return pix.asPNG(); +} + +/** + * Extract text boxes and vector segments from all pages of a PDF buffer. + */ +export async function extractPages(input: Uint8Array): Promise { + const m = await loadMupdf(); + const doc = m.Document.openDocument(input, "application/pdf"); + const pages: PageContent[] = []; + for (let i = 0; i < doc.countPages(); i++) { + const pageNumber = i + 1; + const page = doc.loadPage(i); + const bounds = page.getBounds(); + const pageHeight = bounds[3] - bounds[1]; + // Single structured text pass with both flags + const stext = JSON.parse( + page.toStructuredText("preserve-whitespace,preserve-images").asJSON(), + ) as StructuredTextJSON; + // Extract text boxes and image regions from the same parse + const textBoxes = extractTextBoxes(page, pageNumber, pageHeight, stext); + const images = extractImageRegions(stext, pageNumber, pageHeight); + // Extract vector segments from raw content stream + let segments: Segment[] = []; + try { + const pageObj = (page as mupdf.PDFPage).getObject(); + const contents = pageObj.get("Contents"); + if (contents) { + let rawBytes: Uint8Array; + if (contents.isArray()) { + // Multiple content streams — concatenate + const parts: Uint8Array[] = []; + const len = contents.length ?? 0; + for (let j = 0; j < len; j++) { + const stream = contents.get(j); + if (stream?.readStream) { + parts.push(stream.readStream().asUint8Array()); + } + } + const totalLen = parts.reduce((s, p) => s + p.length, 0); + rawBytes = new Uint8Array(totalLen); + let offset = 0; + for (const part of parts) { + rawBytes.set(part, offset); + offset += part.length; + } + } else { + rawBytes = contents.readStream().asUint8Array(); + } + const raw = new TextDecoder().decode(rawBytes); + segments = extractSegmentsFromContentStream(raw, pageNumber); + } + } catch { + // Content stream extraction failed — proceed with text only + } + pages.push({ pageNumber, textBoxes, segments, images }); + } + return pages; +} diff --git a/packages/coding-agent/src/markit/converters/pdf/grid.ts b/packages/coding-agent/src/markit/converters/pdf/grid.ts new file mode 100644 index 000000000..a856588ff --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/grid.ts @@ -0,0 +1,780 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * Table grid detection from vector segments and text boxes. + * + * Ported from @oharato/pdf2md-ts with TypeScript types and without + * CJK-specific borderless table heuristics. The core algorithm: + * + * 1. Classify segments as horizontal or vertical lines + * 2. Group horizontal Y-lines into table groups (split by vertical gaps) + * 3. For each group: + * a. Full grid (H+V lines): build cells from grid intersections, + * place text via raycasting + * b. H-line only (no V lines): infer columns from text X positions + * 4. Prune empty rows/cols + * + * Coordinate system: PDF native (bottom-left origin, Y increases upward). + */ +import type { Segment, TableCell, TableGrid, TextBox } from "./types"; + +export interface GridResult { + grids: TableGrid[]; + consumedIds: string[]; +} + +type RayDirection = "up" | "down" | "left" | "right"; + +interface Ray { + direction: RayDirection; + segmentId: string | null; + distance: number; +} + +interface Interval { + min: number; + max: number; +} + +function castRaysForTextBox(textBox: TextBox, segments: Segment[]): Ray[] { + const cx = (textBox.bounds.left + textBox.bounds.right) / 2; + const cy = (textBox.bounds.top + textBox.bounds.bottom) / 2; + let up: Ray = { direction: "up", segmentId: null, distance: Infinity }; + let down: Ray = { direction: "down", segmentId: null, distance: Infinity }; + let left: Ray = { direction: "left", segmentId: null, distance: Infinity }; + let right: Ray = { + direction: "right", + segmentId: null, + distance: Infinity, + }; + for (const seg of segments) { + const isH = Math.abs(seg.y1 - seg.y2) < 0.5; + const isV = Math.abs(seg.x1 - seg.x2) < 0.5; + if (isH) { + const minX = Math.min(seg.x1, seg.x2); + const maxX = Math.max(seg.x1, seg.x2); + if (cx >= minX && cx <= maxX) { + const d = seg.y1 - cy; + if (d >= 0 && d < up.distance) up = { direction: "up", segmentId: seg.id, distance: d }; + const dd = cy - seg.y1; + if (dd >= 0 && dd < down.distance) down = { direction: "down", segmentId: seg.id, distance: dd }; + } + } + if (isV) { + const minY = Math.min(seg.y1, seg.y2); + const maxY = Math.max(seg.y1, seg.y2); + if (cy >= minY && cy <= maxY) { + const d = cx - seg.x1; + if (d >= 0 && d < left.distance) left = { direction: "left", segmentId: seg.id, distance: d }; + const rd = seg.x1 - cx; + if (rd >= 0 && rd < right.distance) right = { direction: "right", segmentId: seg.id, distance: rd }; + } + } + } + return [up, down, left, right]; +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- +const AXIS_EPSILON = 0.8; +const PAGE_MARGIN = 20; + +function uniqueSorted(values: number[]): number[] { + const sorted = [...values].sort((a, b) => a - b); + const result: number[] = []; + for (const v of sorted) { + if (result.length === 0 || Math.abs(result[result.length - 1] - v) > 1) result.push(v); + } + return result; +} + +// --------------------------------------------------------------------------- +// Y-line group splitting +// --------------------------------------------------------------------------- +function chainCoversRange(intervals: Interval[], lowerY: number, upperY: number, eps: number): boolean { + const sorted = [...intervals].sort((a, b) => a.min - b.min); + let covered = lowerY; + for (const iv of sorted) { + if (iv.min > covered + eps) break; + if (iv.max > covered) covered = iv.max; + if (covered >= upperY - eps) return true; + } + return false; +} + +function countBridgingVLineCols(upperY: number, lowerY: number, verticals: Segment[]): number { + const eps = 1.5; + const byX = new Map(); + for (const seg of verticals) { + const rx = Math.round(seg.x1); + if (!byX.has(rx)) byX.set(rx, []); + byX.get(rx)?.push({ min: Math.min(seg.y1, seg.y2), max: Math.max(seg.y1, seg.y2) }); + } + let count = 0; + for (const intervals of byX.values()) { + if (chainCoversRange(intervals, lowerY, upperY, eps)) count++; + } + return count; +} + +function bridgingXSet(upperY: number, lowerY: number, verticals: Segment[]): Set { + const eps = 1.5; + const xs = new Set(); + const byX = new Map(); + for (const seg of verticals) { + const rx = Math.round(seg.x1); + if (!byX.has(rx)) byX.set(rx, []); + byX.get(rx)?.push({ min: Math.min(seg.y1, seg.y2), max: Math.max(seg.y1, seg.y2) }); + } + for (const [rx, intervals] of byX) { + if (chainCoversRange(intervals, lowerY, upperY, eps)) xs.add(rx); + } + return xs; +} + +const MIN_RICH_BRIDGING_COLS = 3; + +function splitYLinesIntoGroups(yLines: number[], verticals: Segment[]): number[][] { + if (yLines.length === 0) return []; + const eps = 1.5; + const allX = verticals.map(s => Math.round(s.x1)); + const globalXMin = allX.length > 0 ? Math.min(...allX) : 0; + const globalXMax = allX.length > 0 ? Math.max(...allX) : 0; + const groups: number[][] = []; + let currentGroup = [yLines[0]]; + let prevBridgingCols = -1; + for (let i = 1; i < yLines.length; i++) { + const upperY = yLines[i - 1]; + const lowerY = yLines[i]; + const cols = countBridgingVLineCols(upperY, lowerY, verticals); + if (cols === 0) { + groups.push(currentGroup); + currentGroup = [yLines[i]]; + prevBridgingCols = -1; + continue; + } + if (prevBridgingCols >= MIN_RICH_BRIDGING_COLS && cols < MIN_RICH_BRIDGING_COLS) { + const bxs = bridgingXSet(upperY, lowerY, verticals); + const isOuterFrameOnly = [...bxs].every( + x => Math.abs(x - globalXMin) <= eps || Math.abs(x - globalXMax) <= eps, + ); + if (!isOuterFrameOnly) { + groups.push(currentGroup); + currentGroup = [yLines[i - 1], yLines[i]]; + prevBridgingCols = cols; + continue; + } + } + currentGroup.push(yLines[i]); + prevBridgingCols = cols; + } + groups.push(currentGroup); + return groups; +} + +// --------------------------------------------------------------------------- +// Sub-row Y-cluster expansion +// --------------------------------------------------------------------------- +const Y_CLUSTER_GAP = 10; +const MIN_COLS_IN_TOP_CLUSTER = 2; + +function assignToYCluster(y: number, clusters: number[]): number { + let closest = 0; + let closestDist = Math.abs(y - clusters[0]); + for (let k = 1; k < clusters.length; k++) { + const d = Math.abs(y - clusters[k]); + if (d < closestDist) { + closestDist = d; + closest = k; + } + } + return closest; +} + +function expandSubRowsByYClusters( + originalRows: number, + cols: number, + cells: TableCell[], + cellBoxes: Map, +): number { + let addedRows = 0; + for (let origRow = 0; origRow < originalRows; origRow++) { + const currentRow = origRow + addedRows; + const rowCellInfos: Array<{ cell: TableCell; col: number; boxes: TextBox[] }> = []; + for (let col = 0; col < cols; col++) { + const cell = cells.find(c => c.row === currentRow && c.col === col); + if (!cell) continue; + const boxes = cellBoxes.get(cell); + if (boxes && boxes.length > 0) rowCellInfos.push({ cell, col, boxes }); + } + if (rowCellInfos.length === 0) continue; + const allMidYs = rowCellInfos.flatMap(({ boxes }) => boxes.map(b => (b.bounds.top + b.bounds.bottom) / 2)); + const sortedY = [...new Set(allMidYs.map(y => Math.round(y * 10) / 10))].sort((a, b) => b - a); + const clusters = [sortedY[0]]; + for (let i = 1; i < sortedY.length; i++) { + if (clusters[clusters.length - 1] - sortedY[i] > Y_CLUSTER_GAP) { + clusters.push(sortedY[i]); + } + } + if (clusters.length < 2) continue; + const colsInTopCluster = new Set(); + const totalNonEmptyCols = new Set(); + for (const { col, boxes } of rowCellInfos) { + totalNonEmptyCols.add(col); + if (boxes.some(b => assignToYCluster((b.bounds.top + b.bounds.bottom) / 2, clusters) === 0)) { + colsInTopCluster.add(col); + } + } + if (colsInTopCluster.size < MIN_COLS_IN_TOP_CLUSTER) continue; + if (colsInTopCluster.size >= totalNonEmptyCols.size) continue; + const sparseColsHaveMultipleBoxes = rowCellInfos.some( + ({ col, boxes }) => !colsInTopCluster.has(col) && boxes.length > 1, + ); + if (!sparseColsHaveMultipleBoxes) continue; + const numSubRows = clusters.length; + const numNewRows = numSubRows - 1; + for (const cell of cells) { + if (cell.row > currentRow) cell.row += numNewRows; + } + for (let subRow = 1; subRow < numSubRows; subRow++) { + for (let col = 0; col < cols; col++) { + cells.push({ + row: currentRow + subRow, + col, + text: "", + rowSpan: 1, + colSpan: 1, + }); + } + } + for (const { cell: origCell, col, boxes } of rowCellInfos) { + const subRowBoxGroups: TextBox[][] = Array.from({ length: numSubRows }, () => []); + for (const box of boxes) { + const cy = (box.bounds.top + box.bounds.bottom) / 2; + subRowBoxGroups[assignToYCluster(cy, clusters)].push(box); + } + cellBoxes.set(origCell, subRowBoxGroups[0]); + if (subRowBoxGroups[0].length === 0) cellBoxes.delete(origCell); + for (let subRow = 1; subRow < numSubRows; subRow++) { + if (subRowBoxGroups[subRow].length > 0) { + const newCell = cells.find(c => c.row === currentRow + subRow && c.col === col); + if (newCell) cellBoxes.set(newCell, subRowBoxGroups[subRow]); + } + } + } + addedRows += numNewRows; + } + return originalRows + addedRows; +} + +// --------------------------------------------------------------------------- +// Cross-column text box splitting +// --------------------------------------------------------------------------- +/** + * Find which column a horizontal position falls into. + * Returns -1 if outside the grid. + */ +function findCol(x: number, xLines: number[]): number { + for (let i = 0; i < xLines.length - 1; i++) { + if (x >= xLines[i] && x <= xLines[i + 1]) return i; + } + return -1; +} + +/** + * When a text box spans across one or more vertical column boundaries, + * split it into multiple virtual text boxes — one per column — with the + * text divided proportionally by width. + * + * We split at word boundaries closest to the proportional split point + * so we don't chop words in half. + */ +function splitCrossColumnBoxes(textBoxes: TextBox[], xLines: number[]): TextBox[] { + const result: TextBox[] = []; + const MARGIN = 5; // allow small overlap before considering it cross-column + for (const tb of textBoxes) { + const leftCol = findCol(tb.bounds.left + MARGIN, xLines); + const rightCol = findCol(tb.bounds.right - MARGIN, xLines); + // Not spanning columns, or outside grid — keep as-is + if (leftCol < 0 || rightCol < 0 || leftCol === rightCol) { + result.push(tb); + continue; + } + // Text box spans from leftCol to rightCol — split it + const totalWidth = tb.bounds.right - tb.bounds.left; + if (totalWidth <= 0) { + result.push(tb); + continue; + } + const words = tb.text.split(/\s+/); + if (words.length <= 1) { + // Single word spanning columns — just assign to whichever col has more overlap + result.push(tb); + continue; + } + // For each column boundary crossing, find the best word-boundary split + let remainingWords = [...words]; + let currentLeft = tb.bounds.left; + for (let col = leftCol; col <= rightCol && remainingWords.length > 0; col++) { + const colRight = col < xLines.length - 1 ? xLines[col + 1] : tb.bounds.right; + const segmentRight = Math.min(colRight, tb.bounds.right); + if (col === rightCol) { + // Last column — take all remaining words + result.push({ + ...tb, + id: `${tb.id}-split${col}`, + text: remainingWords.join(" "), + bounds: { + ...tb.bounds, + left: currentLeft, + right: tb.bounds.right, + }, + }); + remainingWords = []; + } else { + // Find how many words fit in this column segment proportionally + const segmentWidth = segmentRight - currentLeft; + const fractionOfTotal = segmentWidth / totalWidth; + const approxChars = Math.round(fractionOfTotal * tb.text.length); + // Walk words to find the split closest to the proportional point + let charCount = 0; + let splitIdx = 0; + for (let w = 0; w < remainingWords.length; w++) { + const nextCount = charCount + remainingWords[w].length + (w > 0 ? 1 : 0); + if (nextCount > approxChars && splitIdx > 0) break; + charCount = nextCount; + splitIdx = w + 1; + } + if (splitIdx === 0) splitIdx = 1; // take at least one word + if (splitIdx >= remainingWords.length) { + // All remaining words fit here + result.push({ + ...tb, + id: `${tb.id}-split${col}`, + text: remainingWords.join(" "), + bounds: { + ...tb.bounds, + left: currentLeft, + right: segmentRight, + }, + }); + remainingWords = []; + } else { + const partWords = remainingWords.slice(0, splitIdx); + result.push({ + ...tb, + id: `${tb.id}-split${col}`, + text: partWords.join(" "), + bounds: { + ...tb.bounds, + left: currentLeft, + right: segmentRight, + }, + }); + remainingWords = remainingWords.slice(splitIdx); + currentLeft = segmentRight; + } + } + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Full grid table (H + V lines) +// --------------------------------------------------------------------------- +function buildCells(rows: number, cols: number): TableCell[] { + const cells: TableCell[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + cells.push({ row, col, text: "", rowSpan: 1, colSpan: 1 }); + } + } + return cells; +} + +function buildTableGrid( + pageNumber: number, + yLines: number[], + xLines: number[], + filteredSegments: Segment[], + textBoxes: TextBox[], +): { grid: TableGrid; consumedIds: string[] } { + let rows = yLines.length - 1; + const cols = xLines.length - 1; + const cells = buildCells(rows, cols); + const consumedIds: string[] = []; + const yMin = yLines[yLines.length - 1]; + const yMax = yLines[0]; + const xMin = xLines[0]; + const xMax = xLines[xLines.length - 1]; + // Split text boxes that span multiple columns before placement + const splitBoxes = splitCrossColumnBoxes(textBoxes, xLines); + // Track which split piece IDs get placed in cells, so we can consume + // the original (unsplit) text box IDs too. + const placedSplitIds = new Set(); + // Look for header text boxes just above the grid. + // Use the ORIGINAL (unsplit) text boxes for header detection so that + // wide paragraph text isn't falsely split into column-sized header chunks. + // Reject boxes wider than 1.5 columns — those are paragraph text, not headers. + const avgColWidth = (xMax - xMin) / cols; + const maxHeaderBoxWidth = avgColWidth * 1.5; + const headerBoxes = textBoxes.filter(tb => { + const cy = (tb.bounds.top + tb.bounds.bottom) / 2; + const cx = (tb.bounds.left + tb.bounds.right) / 2; + const boxWidth = tb.bounds.right - tb.bounds.left; + return cy > yMax && cy <= yMax + 20 && cx >= xMin && cx <= xMax && boxWidth <= maxHeaderBoxWidth; + }); + if (headerBoxes.length > 0) { + rows += 1; + for (const cell of cells) cell.row += 1; + for (let col = 0; col < cols; col++) { + cells.push({ row: 0, col, text: "", rowSpan: 1, colSpan: 1 }); + } + for (const tb of headerBoxes) { + const cx = (tb.bounds.left + tb.bounds.right) / 2; + const col = xLines.findIndex((lineX, idx) => { + const next = xLines[idx + 1]; + return next !== undefined && cx >= lineX && cx <= next; + }); + if (col >= 0 && col < cols) { + const cell = cells.find(c => c.row === 0 && c.col === col); + if (cell) { + cell.text = cell.text.length === 0 ? tb.text : `${cell.text} ${tb.text}`; + consumedIds.push(tb.id); + } + } + } + } + const cellBoxes = new Map(); + for (const tb of splitBoxes) { + const cx = (tb.bounds.left + tb.bounds.right) / 2; + const cy = (tb.bounds.top + tb.bounds.bottom) / 2; + if (cy < yMin || cy > yMax || cx < xMin || cx > xMax) continue; + const rays = castRaysForTextBox(tb, filteredSegments); + const rayConfidence = rays.filter(r => r.segmentId !== null).length; + let row = yLines.findIndex((lineY, idx) => { + const next = yLines[idx + 1]; + return next !== undefined && cy <= lineY && cy >= next; + }); + if (row < 0 || row >= (headerBoxes.length > 0 ? rows - 1 : rows)) continue; + if (headerBoxes.length > 0) row += 1; + const col = xLines.findIndex((lineX, idx) => { + const next = xLines[idx + 1]; + return next !== undefined && cx >= lineX && cx <= next; + }); + if (col < 0 || col >= cols) continue; + if (rayConfidence === 0) continue; + const cell = cells.find(c => c.row === row && c.col === col); + if (!cell) continue; + if (!cellBoxes.has(cell)) cellBoxes.set(cell, []); + cellBoxes.get(cell)?.push(tb); + consumedIds.push(tb.id); + if (tb.id.includes("-split")) placedSplitIds.add(tb.id); + } + rows = expandSubRowsByYClusters(rows, cols, cells, cellBoxes); + // Merge text boxes within each cell into cell text + for (const [cell, boxes] of cellBoxes.entries()) { + boxes.sort((a, b) => b.bounds.top - a.bounds.top); + const lines: string[] = []; + let currentLine: string[] = []; + let currentY = boxes[0].bounds.top; + for (const box of boxes) { + if (Math.abs(box.bounds.top - currentY) > 5) { + lines.push(currentLine.join(" ")); + currentLine = [box.text]; + currentY = box.bounds.top; + } else { + currentLine.push(box.text); + } + } + if (currentLine.length > 0) lines.push(currentLine.join(" ")); + cell.text = lines.join("
"); + } + const grid = pruneEmptyRowsAndCols({ + pageNumber, + rows, + cols, + cells, + warnings: [], + topY: yLines[0], + isBorderless: false, + }); + // Also consume the original (unsplit) text box IDs when any of their + // split pieces were placed in a cell. + for (const splitId of placedSplitIds) { + const origId = splitId.replace(/-split\d+$/, ""); + if (!consumedIds.includes(origId)) { + consumedIds.push(origId); + } + } + return { grid, consumedIds }; +} + +// --------------------------------------------------------------------------- +// H-line-only table (inferred columns) +// --------------------------------------------------------------------------- +const COL_GAP_THRESHOLD = 20; +const HONLY_ROW_GAP = 30; +const HONLY_ROW_TOLERANCE = 8; +const MIN_TABLE_HEIGHT = 24; +const MIN_LEFT_SPREAD = 50; + +function inferXLinesFromBoxes(textBoxes: TextBox[], xMin: number, xMax: number): number[] { + const centers = textBoxes.map(tb => (tb.bounds.left + tb.bounds.right) / 2).sort((a, b) => a - b); + if (centers.length === 0) return [xMin, xMax]; + const boundaries = [xMin]; + for (let i = 1; i < centers.length; i++) { + if (centers[i] - centers[i - 1] >= COL_GAP_THRESHOLD) { + boundaries.push((centers[i - 1] + centers[i]) / 2); + } + } + boundaries.push(xMax); + return boundaries; +} + +function buildHLineOnlyTable( + pageNumber: number, + yLines: number[], + xMin: number, + xMax: number, + textBoxes: TextBox[], + alreadyConsumed: Set, +): { grid: TableGrid; consumedIds: string[] } | null { + const yMax = yLines[0]; + const yMin = yLines[yLines.length - 1]; + const candidates = textBoxes.filter(tb => !alreadyConsumed.has(tb.id)); + const BOX_LEFT_TOLERANCE = 30; + const inRange = candidates.filter(tb => { + const cy = (tb.bounds.top + tb.bounds.bottom) / 2; + return ( + tb.bounds.left >= xMin - BOX_LEFT_TOLERANCE && + tb.bounds.right <= xMax + BOX_LEFT_TOLERANCE && + cy >= yMin && + cy <= yMax + ); + }); + // Extend downward below yMin + const belowYMin = candidates + .filter(tb => { + const cx = (tb.bounds.left + tb.bounds.right) / 2; + const cy = (tb.bounds.top + tb.bounds.bottom) / 2; + return cx >= xMin && cx <= xMax && cy < yMin; + }) + .sort((a, b) => (b.bounds.top + b.bounds.bottom) / 2 - (a.bounds.top + a.bounds.bottom) / 2); + const extensionBoxes: TextBox[] = []; + let lastY = yMin; + for (const tb of belowYMin) { + const cy = (tb.bounds.top + tb.bounds.bottom) / 2; + if (lastY - cy > HONLY_ROW_GAP) break; + extensionBoxes.push(tb); + lastY = cy; + } + const allBoxes = [...inRange, ...extensionBoxes]; + if (allBoxes.length === 0) return null; + const leftEdges = allBoxes.map(tb => tb.bounds.left); + if (Math.max(...leftEdges) - Math.min(...leftEdges) < MIN_LEFT_SPREAD) return null; + const xLines = inferXLinesFromBoxes(allBoxes, xMin, xMax); + if (xLines.length < 2) return null; + const cols = xLines.length - 1; + // Build visual rows + const visualRows: Array<{ midY: number; boxes: TextBox[] }> = []; + const sortedBoxes = [...allBoxes].sort((a, b) => { + const ya = (a.bounds.top + a.bounds.bottom) / 2; + const yb = (b.bounds.top + b.bounds.bottom) / 2; + if (Math.abs(ya - yb) > 0.5) return yb - ya; + return a.bounds.left - b.bounds.left; + }); + for (const box of sortedBoxes) { + const cy = (box.bounds.top + box.bounds.bottom) / 2; + const last = visualRows[visualRows.length - 1]; + if (last && Math.abs(last.midY - cy) <= HONLY_ROW_TOLERANCE) { + last.boxes.push(box); + } else { + visualRows.push({ midY: cy, boxes: [box] }); + } + } + if (visualRows.length === 0) return null; + const cells: TableCell[] = []; + const consumedIds: string[] = []; + for (let rowIdx = 0; rowIdx < visualRows.length; rowIdx++) { + const vrow = visualRows[rowIdx]; + const colBoxes = new Map(); + for (const box of vrow.boxes) { + const cx = (box.bounds.left + box.bounds.right) / 2; + const col = xLines.findIndex((lineX, idx) => { + const next = xLines[idx + 1]; + return next !== undefined && cx >= lineX && cx <= next; + }); + if (col >= 0 && col < cols) { + if (!colBoxes.has(col)) colBoxes.set(col, []); + colBoxes.get(col)?.push(box); + } + } + for (let c = 0; c < cols; c++) { + const cbs = (colBoxes.get(c) ?? []).sort((a, b) => a.bounds.left - b.bounds.left); + cells.push({ + row: rowIdx, + col: c, + text: cbs.map(b => b.text).join(" "), + rowSpan: 1, + colSpan: 1, + }); + consumedIds.push(...cbs.map(b => b.id)); + } + } + const contentTopY = visualRows.length > 0 ? visualRows[0].midY : yMax; + const grid = pruneEmptyRowsAndCols({ + pageNumber, + rows: visualRows.length, + cols, + cells, + warnings: [], + topY: contentTopY, + isBorderless: false, + }); + return { grid, consumedIds }; +} + +// --------------------------------------------------------------------------- +// Pruning +// --------------------------------------------------------------------------- +function pruneEmptyRowsAndCols(table: TableGrid): TableGrid { + const occupiedRows = new Set(table.cells.filter(c => c.text.trim().length > 0).map(c => c.row)); + const occupiedCols = new Set(table.cells.filter(c => c.text.trim().length > 0).map(c => c.col)); + if (occupiedRows.size === 0) return table; + const rowMap = new Map(); + let newRow = 0; + for (let r = 0; r < table.rows; r++) { + if (occupiedRows.has(r)) rowMap.set(r, newRow++); + } + const colMap = new Map(); + let newCol = 0; + for (let c = 0; c < table.cols; c++) { + if (occupiedCols.has(c)) colMap.set(c, newCol++); + } + const prunedCells = table.cells + .filter(c => occupiedRows.has(c.row) && occupiedCols.has(c.col)) + .map(c => ({ + ...c, + row: rowMap.get(c.row) ?? c.row, + col: colMap.get(c.col) ?? c.col, + })); + return { ...table, rows: newRow, cols: newCol, cells: prunedCells }; +} + +// --------------------------------------------------------------------------- +// Diagram vs table discrimination +// --------------------------------------------------------------------------- +/** Maximum column count for a plausible data table. */ +const MAX_TABLE_COLS = 25; + +/** + * Returns true if a grid looks like a vector diagram rather than a data table. + * + * Heuristics (any match → diagram): + * 1. Column count > 25 (diagrams create many X-lines from box edges) + * 2. Fill ratio < 25% (most cells empty — scattered boxes) + * 3. Fill < 50% AND duplicate text ratio > 30% (repeating labels in a + * diagram layout, e.g. "Hash", "Transaction" appearing in each column) + * 4. Fill < 50% AND cols >= 6 (moderate sparseness with wide grid) + */ +function isDiagram(grid: TableGrid): boolean { + const totalCells = grid.rows * grid.cols; + if (totalCells === 0) return true; + const filled = grid.cells.filter(c => c.text.trim().length > 0); + const fillRatio = filled.length / totalCells; + // Very high column count + if (grid.cols > MAX_TABLE_COLS) return true; + // Very sparse + if (fillRatio < 0.25) return true; + // Compute duplicate text ratio among non-trivial cells. + // Exclude short values (≤3 chars) like "—", "V", "YES", "NO" which + // naturally repeat in real data tables. + const substantive = filled.filter(c => c.text.trim().length > 3); + const uniqueTexts = new Set(substantive.map(c => c.text.trim())).size; + const dupRatio = substantive.length > 2 ? 1 - uniqueTexts / substantive.length : 0; + // Sparse + highly duplicated substantive text → repeating diagram + if (fillRatio < 0.5 && dupRatio > 0.3) return true; + // High duplication + wide grid → repeating diagram even at moderate fill + if (dupRatio > 0.4 && grid.cols >= 6) return true; + // Sparse + wide grid with no substantive text to judge + if (fillRatio < 0.4 && grid.cols >= 6) return true; + return false; +} + +/** + * Detect all table grids on a single page from its text boxes and segments. + */ +export function resolveTableGrids(pageNumber: number, textBoxes: TextBox[], segments: Segment[]): GridResult { + const vertical = segments.filter(s => Math.abs(s.x1 - s.x2) <= AXIS_EPSILON); + const horizontal = segments.filter(s => Math.abs(s.y1 - s.y2) <= AXIS_EPSILON); + // Filter segments to the text's visible area + const textYValues = textBoxes.flatMap(t => [t.bounds.bottom, t.bounds.top]); + const textYMin = textYValues.length > 0 ? Math.min(...textYValues) - PAGE_MARGIN : -Infinity; + const textYMax = textYValues.length > 0 ? Math.max(...textYValues) + PAGE_MARGIN : Infinity; + const textXValues = textBoxes.flatMap(t => [t.bounds.left, t.bounds.right]); + const textXMin = textXValues.length > 0 ? Math.min(...textXValues) - 100 : -Infinity; + const textXMax = textXValues.length > 0 ? Math.max(...textXValues) + 100 : Infinity; + const filteredH = horizontal.filter( + s => s.y1 >= textYMin && s.y1 <= textYMax && s.x1 <= textXMax && s.x2 >= textXMin, + ); + const hMaxX2 = filteredH.length > 0 ? Math.max(...filteredH.map(s => s.x2)) : textXMax; + const vSegXMax = Math.max(textXMax, hMaxX2 + PAGE_MARGIN); + const filteredV = vertical.filter(s => { + const segMin = Math.min(s.y1, s.y2); + const segMax = Math.max(s.y1, s.y2); + return segMax >= textYMin && segMin <= textYMax && s.x1 >= textXMin && s.x1 <= vSegXMax; + }); + const allYLines = uniqueSorted(filteredH.flatMap(s => [s.y1, s.y2])).sort((a, b) => b - a); + if (allYLines.length < 2) { + return { grids: [], consumedIds: [] }; + } + const filteredSegments = [...filteredH, ...filteredV]; + const yGroups = splitYLinesIntoGroups(allYLines, filteredV); + const grids: TableGrid[] = []; + const gridConsumedIds: string[][] = []; + // Flat set for the alreadyConsumed check in H-line-only tables + const allConsumedIds: string[] = []; + for (const yLines of yGroups) { + if (yLines.length < 2) continue; + const yMin = yLines[yLines.length - 1]; + const yMax = yLines[0]; + const groupVerticals = filteredV.filter(s => { + const segMin = Math.min(s.y1, s.y2); + const segMax = Math.max(s.y1, s.y2); + return segMin < yMax - 1.5 && segMax > yMin + 1.5; + }); + const groupXLines = uniqueSorted(groupVerticals.flatMap(s => [s.x1, s.x2])); + if (groupXLines.length < 2) { + if (yMax - yMin < MIN_TABLE_HEIGHT) continue; + const groupHoriz = filteredH.filter(s => s.y1 >= yMin - 1.5 && s.y1 <= yMax + 1.5); + if (groupHoriz.length === 0) continue; + const hxMin = Math.min(...groupHoriz.map(s => s.x1)); + const hxMax = Math.max(...groupHoriz.map(s => s.x2)); + const result = buildHLineOnlyTable(pageNumber, yLines, hxMin, hxMax, textBoxes, new Set(allConsumedIds)); + if (result) { + grids.push(result.grid); + gridConsumedIds.push(result.consumedIds); + allConsumedIds.push(...result.consumedIds); + } + continue; + } + if (yMax - yMin < MIN_TABLE_HEIGHT) continue; + const result = buildTableGrid(pageNumber, yLines, groupXLines, filteredSegments, textBoxes); + grids.push(result.grid); + gridConsumedIds.push(result.consumedIds); + allConsumedIds.push(...result.consumedIds); + } + // Filter out grids that look like vector diagrams, not data tables. + // Their consumed text box IDs are released so the text becomes free text. + const filteredGrids: TableGrid[] = []; + const filteredConsumedIds: string[] = []; + for (let i = 0; i < grids.length; i++) { + if (isDiagram(grids[i])) continue; + filteredGrids.push(grids[i]); + filteredConsumedIds.push(...gridConsumedIds[i]); + } + return { grids: filteredGrids, consumedIds: filteredConsumedIds }; +} diff --git a/packages/coding-agent/src/markit/converters/pdf/headers.ts b/packages/coding-agent/src/markit/converters/pdf/headers.ts new file mode 100644 index 000000000..f21bfcfbd --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/headers.ts @@ -0,0 +1,106 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * Running header/footer detection and removal. + * + * Many PDFs have repeated text at the top or bottom of every page: + * document titles, chapter names, page numbers, copyright notices. + * These pollute the markdown output as false headings or noise. + * + * Algorithm: + * 1. For each page, bucket text boxes by Y position (top/bottom zones) + * 2. Collect the text content at each zone across all pages + * 3. Text appearing on >20% of pages OR 8+ consecutive pages is a + * running header/footer + * 4. Remove matching text boxes before further processing + */ +import type { PageContent } from "./types"; + +/** Minimum number of pages to enable header/footer detection. */ +const MIN_PAGES = 5; +/** Minimum Y position for top zone (from bottom of page in PDF coords). */ +const TOP_ZONE_MIN_Y = 700; +/** Maximum Y position for bottom zone. */ +const BOTTOM_ZONE_MAX_Y = 80; +/** + * Minimum consecutive pages a text must appear on to be considered a + * running header/footer. Catches both document-wide headers (appearing + * on every page) and chapter-specific headers (appearing on 4+ consecutive + * pages within a chapter). + */ +const MIN_CONSECUTIVE_PAGES = 8; + +/** + * Detect and remove running headers and footers from all pages. + * Mutates the pages array in place, removing header/footer text boxes. + * + * Uses two strategies: + * 1. Global frequency: text appearing on > 20% of all pages + * 2. Consecutive runs: text appearing on 8+ consecutive pages + */ +export function stripHeadersFooters(pages: PageContent[]): void { + if (pages.length < MIN_PAGES) return; + // Step 1: Build per-page zone text sets + const pageZoneTexts: Set[] = []; + for (const page of pages) { + const zoneTexts = new Set(); + for (const tb of page.textBoxes) { + const midY = (tb.bounds.top + tb.bounds.bottom) / 2; + if (midY >= TOP_ZONE_MIN_Y || midY <= BOTTOM_ZONE_MAX_Y) { + const key = tb.text.trim().replace(/\s+/g, " "); + if (key.length > 0) zoneTexts.add(key); + } + } + pageZoneTexts.push(zoneTexts); + } + // Step 2: Count global frequency AND longest consecutive run for each text + const globalCount = new Map(); + const maxConsecutive = new Map(); + // Collect all unique zone texts + const allTexts = new Set(); + for (const zts of pageZoneTexts) { + for (const t of zts) allTexts.add(t); + } + for (const text of allTexts) { + let total = 0; + let consecutive = 0; + let maxRun = 0; + for (const zts of pageZoneTexts) { + if (zts.has(text)) { + total++; + consecutive++; + if (consecutive > maxRun) maxRun = consecutive; + } else { + consecutive = 0; + } + } + globalCount.set(text, total); + maxConsecutive.set(text, maxRun); + } + // Step 3: Identify running headers/footers + const globalThreshold = Math.max(3, Math.floor(pages.length * 0.2)); + const repeatedTexts = new Set(); + for (const text of allTexts) { + const gc = globalCount.get(text) ?? 0; + const mc = maxConsecutive.get(text) ?? 0; + // Global: appears on 20%+ of pages + if (gc >= globalThreshold) { + repeatedTexts.add(text); + continue; + } + // Consecutive: appears on 8+ consecutive pages (chapter-level headers) + if (mc >= MIN_CONSECUTIVE_PAGES) { + repeatedTexts.add(text); + } + } + if (repeatedTexts.size === 0) return; + // Step 4: Remove matching text boxes from each page + for (const page of pages) { + page.textBoxes = page.textBoxes.filter(tb => { + const midY = (tb.bounds.top + tb.bounds.bottom) / 2; + if (midY < TOP_ZONE_MIN_Y && midY > BOTTOM_ZONE_MAX_Y) return true; + const normalized = tb.text.trim().replace(/\s+/g, " "); + return !repeatedTexts.has(normalized); + }); + } +} diff --git a/packages/coding-agent/src/markit/converters/pdf/index.ts b/packages/coding-agent/src/markit/converters/pdf/index.ts new file mode 100644 index 000000000..7e092a8dc --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/index.ts @@ -0,0 +1,146 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * PDF to Markdown converter. + * + * Uses mupdf (native WASM) for fast PDF parsing and a custom pipeline for + * table detection via vector line extraction + raycasting. + * + * Pipeline: + * 1. Extract text boxes + vector segments + image regions per page (mupdf) + * 2. Detect column layout (single vs multi-column) + * 3. Per column: detect table grids from segments (grid detection + raycasting) + * 4. Render diagrams as PNG files (if output directory provided) + * 5. Render tables as markdown tables, free text as paragraphs/headings + */ +import * as path from "node:path"; +import type { ConversionResult, Converter, StreamInfo } from "../../types"; +import { detectColumns } from "./columns"; +import { extractPages, renderImageRegion } from "./extract"; +import { resolveTableGrids } from "./grid"; +import { stripHeadersFooters } from "./headers"; +import { renderPageContent } from "./render"; +import type { Segment, TextBox } from "./types"; + +const EXTENSIONS = [".pdf"]; +const MIMETYPES = ["application/pdf", "application/x-pdf"]; + +type ImageBlock = { topY: number; markdown: string }; + +/** + * Process a set of text boxes (one column or full page): run table detection, + * separate free text, and render to markdown. + */ +function processColumn( + pageNumber: number, + textBoxes: TextBox[], + segments: Segment[], + imageBlocks: ImageBlock[], +): string { + const { grids, consumedIds } = resolveTableGrids(pageNumber, textBoxes, segments); + const consumedSet = new Set(consumedIds); + const freeTextBoxes = textBoxes.filter(tb => !consumedSet.has(tb.id)); + return renderPageContent(freeTextBoxes, grids, imageBlocks, textBoxes); +} + +export class PdfConverter implements Converter { + name = "pdf"; + + accepts(streamInfo: StreamInfo): boolean { + if (streamInfo.extension && EXTENSIONS.includes(streamInfo.extension)) { + return true; + } + if (streamInfo.mimetype && MIMETYPES.some(m => streamInfo.mimetype?.startsWith(m))) { + return true; + } + return false; + } + + async convert(input: Buffer, streamInfo: StreamInfo): Promise { + const pdfBytes = new Uint8Array(input); + const pages = await extractPages(pdfBytes); + // Remove running headers/footers before processing. + stripHeadersFooters(pages); + const imageDir = streamInfo.imageDir; + + const pageMarkdowns: string[] = []; + for (const page of pages) { + // Build image blocks for this page. + const imageBlocks: ImageBlock[] = []; + if (imageDir && page.images.length > 0) { + for (const img of page.images) { + const filename = `${img.id}.png`; + const filepath = path.join(imageDir, filename); + try { + const png = await renderImageRegion(pdfBytes, img); + await Bun.write(filepath, png); + imageBlocks.push({ topY: img.topY, markdown: `![${img.id}](${filepath})` }); + } catch { + // Image rendering failed — skip. + } + } + } else if (page.images.length > 0) { + for (const img of page.images) { + imageBlocks.push({ + topY: img.topY, + markdown: ``, + }); + } + } + + // Detect column layout. + // If the page has vertical segments (tables), suppress column detection + // when one detected column is very narrow — that's a table's first column, + // not a page layout column. + const layout = detectColumns(page.textBoxes); + if (layout.columnCount > 1 && page.segments.some(s => Math.abs(s.x1 - s.x2) <= 0.8)) { + const pageXMin = Math.min(...page.textBoxes.map(tb => tb.bounds.left)); + const pageXMax = Math.max(...page.textBoxes.map(tb => tb.bounds.right)); + const pageWidth = pageXMax - pageXMin; + const minColFraction = 0.3; + const tooNarrow = layout.columns.some(col => { + const colXMin = Math.min(...col.map(tb => tb.bounds.left)); + const colXMax = Math.max(...col.map(tb => tb.bounds.right)); + return (colXMax - colXMin) / pageWidth < minColFraction; + }); + if (tooNarrow) { + layout.columnCount = 1; + layout.columns = [page.textBoxes]; + layout.boundaries = []; + } + } + + if (layout.columnCount === 1) { + // Single column — process normally. + const md = processColumn(page.pageNumber, page.textBoxes, page.segments, imageBlocks); + if (md.length > 0) pageMarkdowns.push(md); + } else { + // Multi-column — process each column independently, then join. + const columnMarkdowns: string[] = []; + for (const colBoxes of layout.columns) { + // Filter segments to those within this column's X range. + const colXMin = Math.min(...colBoxes.map(tb => tb.bounds.left)); + const colXMax = Math.max(...colBoxes.map(tb => tb.bounds.right)); + const margin = 10; + const colSegments = page.segments.filter(seg => { + const segXMin = Math.min(seg.x1, seg.x2); + const segXMax = Math.max(seg.x1, seg.x2); + return segXMax >= colXMin - margin && segXMin <= colXMax + margin; + }); + // Images go with the first column only (no X info to split by). + const md = processColumn( + page.pageNumber, + colBoxes, + colSegments, + columnMarkdowns.length === 0 ? imageBlocks : [], + ); + if (md.length > 0) columnMarkdowns.push(md); + } + const joined = columnMarkdowns.join("\n\n"); + if (joined.length > 0) pageMarkdowns.push(joined); + } + } + + return { markdown: pageMarkdowns.join("\n\n") }; + } +} diff --git a/packages/coding-agent/src/markit/converters/pdf/render.ts b/packages/coding-agent/src/markit/converters/pdf/render.ts new file mode 100644 index 000000000..a1a025f28 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/render.ts @@ -0,0 +1,501 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** + * Markdown rendering for PDF pages. + * + * Converts table grids and free text boxes into markdown, handling: + * - Table grid → markdown table (`| col | col |`) + * - Free text → paragraphs with heading detection (by font size) + * - Content ordering (top-to-bottom via Y coordinate) + * - Paragraph wrap merging (lines broken across PDF line boundaries) + * - Page number removal + * + * Ported from @oharato/pdf2md-ts, stripped of CJK/TDnet-specific logic. + */ +import type { ContentBlock, TableGrid, TextBox } from "./types"; + +/** A free-text line grouped from horizontally adjacent text boxes. */ +interface RenderLine { + text: string; + topY: number; + fontSize: number; + isBold: boolean; + isTabular: boolean; +} + +/** A content block carrying the Y of its last wrapped line during merging. */ +type WrapBlock = ContentBlock & { lastTopY: number }; + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- +/** Convert full-width ASCII characters (A→A, !→! etc.) to normal ASCII. */ +function normalizeFullWidthAscii(text: string): string { + return text.replace(/[!-~]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)); +} + +function escapePipes(text: string): string { + return normalizeFullWidthAscii(text).replaceAll("|", "\\|").replaceAll("\n", "
"); +} + +/** Parse a markdown pipe-delimited row into cell strings. */ +function parsePipeRow(line: string): string[] { + const trimmed = line.trim(); + if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return []; + return trimmed + .slice(1, -1) + .split("|") + .map(cell => cell.trim()); +} + +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- +/** + * Render a TableGrid as a markdown table. + */ +export function renderTableToMarkdown(table: TableGrid): string { + if (table.rows === 0 || table.cols === 0) return ""; + const matrix = Array.from({ length: table.rows }, () => Array.from({ length: table.cols }, () => "")); + for (const cell of table.cells) { + if (cell.row < table.rows && cell.col < table.cols) { + matrix[cell.row][cell.col] = escapePipes(cell.text.trim()); + } + } + const normalized = normalizeShiftedSparseColumns(matrix); + const promoted = promoteSubHeaderPrefixes(normalized); + const header = `| ${promoted[0].join(" | ")} |`; + const divider = `| ${Array.from({ length: promoted[0].length }, () => "---").join(" | ")} |`; + const body = promoted + .slice(1) + .map(row => `| ${row.join(" | ")} |`) + .join("\n"); + return [header, divider, body].filter(l => l.length > 0).join("\n"); +} + +/** + * Fix tables with ≥5 columns where sparse single-value columns are + * misaligned. Shifts those values to the adjacent dense column and + * removes the now-empty sparse columns. + */ +function normalizeShiftedSparseColumns(matrix: string[][]): string[][] { + if (matrix.length === 0 || matrix[0].length < 5) return matrix; + const _rows = matrix.length; + const cols = matrix[0].length; + const counts = Array.from({ length: cols }, (_, c) => + matrix.reduce((n, row) => n + (row[c].trim().length > 0 ? 1 : 0), 0), + ); + const denseCols = new Set( + counts + .map((count, col) => ({ count, col })) + .filter(({ col, count }) => col === 0 || count >= 2) + .map(({ col }) => col), + ); + const sparseCols = counts + .map((count, col) => ({ count, col })) + .filter(({ col, count }) => col > 0 && col < cols - 1 && count === 1) + .map(({ col }) => col); + if (sparseCols.length < 2 || denseCols.size < 4) return matrix; + const moves: Array<{ from: number; to: number; row: number }> = []; + for (const from of sparseCols) { + const row = matrix.findIndex(r => r[from].trim().length > 0); + const to = from + 1; + if (row < 0) return matrix; + if (!denseCols.has(to)) return matrix; + if (matrix[row][to].trim().length > 0) return matrix; + moves.push({ from, to, row }); + } + const copy = matrix.map(row => [...row]); + for (const { from, to, row } of moves) { + copy[row][to] = copy[row][to].trim().length > 0 ? `${copy[row][to]} ${copy[row][from]}` : copy[row][from]; + copy[row][from] = ""; + } + const keepCols = Array.from({ length: cols }, (_, c) => c).filter(c => copy.some(row => row[c].trim().length > 0)); + if (keepCols.length === cols) return copy; + return copy.map(row => keepCols.map(c => row[c])); +} + +/** + * When a data row has ≥2 parenthesized qualifiers in non-first columns + * (and the first column is empty), promote them into the header row. + */ +function promoteSubHeaderPrefixes(matrix: string[][]): string[][] { + if (matrix.length < 2) return matrix; + const PAREN_RE = /^\([^)]{1,40}\)$/; + const result = matrix.map(row => [...row]); + const cols = matrix[0].length; + const rowsToRemove = new Set(); + for (let r = 1; r < result.length; r++) { + if (rowsToRemove.has(r)) continue; + const promotable: Array<{ col: number; prefix: string; isFullCell: boolean }> = []; + for (let col = 1; col < cols; col++) { + const cell = (result[r][col] ?? "").trim(); + if (!cell) continue; + const parts = cell.split("
"); + if (parts.length === 1 && PAREN_RE.test(cell)) { + promotable.push({ col, prefix: cell, isFullCell: true }); + } else if (parts.length >= 2 && PAREN_RE.test(parts[0].trim())) { + promotable.push({ + col, + prefix: parts[0].trim(), + isFullCell: false, + }); + } + } + if (promotable.length < 2) continue; + if (promotable.some(p => p.isFullCell) && result[r][0].trim().length > 0) continue; + for (const { col, prefix, isFullCell } of promotable) { + result[0][col] = result[0][col].trim() ? `${result[0][col]} ${prefix}` : prefix; + if (isFullCell) { + result[r][col] = ""; + } else { + const parts = result[r][col].split("
"); + result[r][col] = parts.slice(1).join("
"); + } + } + if (result[r].every(cell => cell.trim().length === 0)) { + rowsToRemove.add(r); + } + } + return result.filter((_, r) => !rowsToRemove.has(r)); +} + +// --------------------------------------------------------------------------- +// Free text rendering +// --------------------------------------------------------------------------- +/** Y tolerance for grouping text boxes onto the same visual line. */ +const TEXT_LINE_Y_TOLERANCE = 3; +/** Minimum X gap between adjacent boxes to mark line as tabular. */ +const TABULAR_X_GAP = 30; +/** + * Minimum font size (pts) to consider when computing the modal body font. + * Tiny labels from diagrams, footnote markers, and superscripts are excluded + * so they don't skew the modal toward small sizes. + */ +const MIN_BODY_FONT_SIZE = 7; + +/** + * Compute the most frequent font size among text boxes, ignoring very small + * text that likely comes from diagrams, footnotes, or superscripts. + */ +function modalFontSize(textBoxes: TextBox[]): number { + const counts = new Map(); + for (const tb of textBoxes) { + const size = Math.round((tb.fontSize ?? 0) * 10) / 10; + if (size < MIN_BODY_FONT_SIZE) continue; + counts.set(size, (counts.get(size) ?? 0) + 1); + } + let modal = 0; + let maxCount = 0; + for (const [size, count] of counts) { + if (count > maxCount) { + maxCount = count; + modal = size; + } + } + return modal; +} + +/** Group free text boxes into horizontal lines, sorted top-to-bottom. */ +function groupFreeTextIntoLines(textBoxes: TextBox[]): RenderLine[] { + if (textBoxes.length === 0) return []; + const sorted = [...textBoxes].sort((a, b) => { + const ya = (a.bounds.top + a.bounds.bottom) / 2; + const yb = (b.bounds.top + b.bounds.bottom) / 2; + const dy = yb - ya; + if (Math.abs(dy) > TEXT_LINE_Y_TOLERANCE) return dy; + return a.bounds.left - b.bounds.left; + }); + const lines: RenderLine[] = []; + let curParts = [sorted[0].text]; + let curBoxes = [sorted[0]]; + let curY = (sorted[0].bounds.top + sorted[0].bounds.bottom) / 2; + let curTopY = curY; + let curFontSize = sorted[0].fontSize; + let curIsBold = sorted[0].isBold; + const finishLine = () => { + let isTabular = false; + for (let j = 1; j < curBoxes.length; j++) { + if (curBoxes[j].bounds.left - curBoxes[j - 1].bounds.right > TABULAR_X_GAP) { + isTabular = true; + break; + } + } + lines.push({ + text: curParts.join(" "), + topY: curTopY, + fontSize: curFontSize, + isBold: curIsBold, + isTabular, + }); + }; + for (let i = 1; i < sorted.length; i++) { + const box = sorted[i]; + const cy = (box.bounds.top + box.bounds.bottom) / 2; + if (Math.abs(cy - curY) <= TEXT_LINE_Y_TOLERANCE) { + curParts.push(box.text); + curBoxes.push(box); + curFontSize = Math.max(curFontSize, box.fontSize); + curIsBold = curIsBold || box.isBold; + } else { + finishLine(); + curParts = [box.text]; + curBoxes = [box]; + curY = cy; + curTopY = cy; + curFontSize = box.fontSize; + curIsBold = box.isBold; + } + } + finishLine(); + return lines; +} + +/** Determine markdown heading prefix based on font size relative to body. */ +function headingPrefix(fontSize: number, bodyFontSize: number, isBold: boolean): string { + if (bodyFontSize <= 0) return ""; + const ratio = fontSize / bodyFontSize; + // Large headings (>2x body size) + if (ratio >= 2.0) return "# "; + // Medium headings (~1.5x body size) + if (ratio >= 1.4) return "## "; + // Small headings (bold and slightly larger) + if (ratio >= 1.1 && isBold) return "### "; + return ""; +} + +// --------------------------------------------------------------------------- +// Block merging +// --------------------------------------------------------------------------- +/** Merge consecutive blocks with the same heading prefix (wrapped headings). */ +function mergeConsecutiveHeadings(blocks: ContentBlock[], bodyFS: number): ContentBlock[] { + if (blocks.length === 0) return []; + const HEADING_RE = /^(#{1,6} )/; + const maxGap = Math.max(bodyFS * 3, 30); + const merged: ContentBlock[] = []; + let cur: ContentBlock = { ...blocks[0] }; + for (let i = 1; i < blocks.length; i++) { + const next = blocks[i]; + const curMatch = cur.content.match(HEADING_RE); + const nextMatch = next.content.match(HEADING_RE); + const gap = cur.topY - next.topY; + if (curMatch && nextMatch && curMatch[1] === nextMatch[1] && gap <= maxGap) { + cur = { + topY: cur.topY, + content: `${cur.content} ${next.content.slice(nextMatch[1].length)}`, + isTabular: cur.isTabular || next.isTabular, + }; + } else { + merged.push(cur); + cur = { ...next }; + } + } + merged.push(cur); + return merged; +} + +/** + * Merge consecutive plain-text blocks that are wrapped lines of the same paragraph. + */ +function mergeParagraphWraps(blocks: ContentBlock[], bodyFS: number): ContentBlock[] { + if (blocks.length === 0 || bodyFS <= 0) return blocks; + const HEADING_RE = /^#{1,6} /; + const SENTENCE_END_RE = /[.!?…)\]]\s*$/; + const maxGap = bodyFS * 2.0; + const MIN_WRAP_LENGTH = 25; + const merged: ContentBlock[] = []; + let cur: WrapBlock = { ...blocks[0], lastTopY: blocks[0].topY }; + for (let i = 1; i < blocks.length; i++) { + const next = blocks[i]; + const curIsBody = !HEADING_RE.test(cur.content) && !cur.content.startsWith("|"); + const nextIsBody = !HEADING_RE.test(next.content) && !next.content.startsWith("|"); + const gap = cur.lastTopY - next.topY; + const isWrap = + curIsBody && + nextIsBody && + !cur.isTabular && + !next.isTabular && + gap > 0 && + gap <= maxGap && + cur.content.length > MIN_WRAP_LENGTH && + !SENTENCE_END_RE.test(cur.content); + if (isWrap) { + cur = { + topY: cur.topY, + lastTopY: next.topY, + content: `${cur.content.trimEnd()} ${next.content.trimStart()}`, + isTabular: false, + }; + } else { + merged.push({ topY: cur.topY, content: cur.content }); + cur = { ...next, lastTopY: next.topY }; + } + } + merged.push({ topY: cur.topY, content: cur.content }); + return merged; +} + +/** Remove page number blocks near the bottom of the page. */ +function removePageNumbers(blocks: ContentBlock[]): ContentBlock[] { + const PAGE_NUM_RE = /^(?:#{1,6}\s*)?\d+\s*$/; + const BOTTOM_Y = 120; + return blocks.filter((block, idx) => { + const isBottom = idx >= blocks.length - 3; + const isLowY = block.topY <= BOTTOM_Y; + const isPageNum = PAGE_NUM_RE.test(block.content.trim()); + return !(isBottom && isLowY && isPageNum); + }); +} + +// --------------------------------------------------------------------------- +// Detached first-column table reconstruction +// --------------------------------------------------------------------------- +/** + * Fix tables where the first column was emitted as free text blocks + * around a markdown table containing only the right-side columns. + * + * Detects: a plain-text header line with (N+1) tokens above an N-column + * markdown table, plus short label lines whose count matches the table's + * logical row count. Reconstructs into a proper (N+1)-column table. + */ +function normalizeDetachedFirstColumnTables(blocks: ContentBlock[]): ContentBlock[] { + const HEADING_RE = /^#{1,6}\s/; + const isTableBlock = (text: string) => text.trimStart().startsWith("|"); + const isPlainBlock = (text: string) => !HEADING_RE.test(text) && !isTableBlock(text); + const isShortLabel = (text: string) => { + const t = text.trim(); + return t.length > 0 && t.length <= 40; + }; + const splitTokens = (text: string) => + text + .trim() + .split(/[ \t]+/) + .filter(Boolean); + const replacements = new Map(); + const remove = new Set(); + for (let tableIdx = 0; tableIdx < blocks.length; tableIdx++) { + if (remove.has(tableIdx)) continue; + const tableBlock = blocks[tableIdx]; + if (!isTableBlock(tableBlock.content)) continue; + const tableLines = tableBlock.content + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("|")); + const dataRows = tableLines + .filter(line => !/^\|\s*[-: ]+\|/.test(line)) + .map(parsePipeRow) + .filter(row => row.length > 0); + if (dataRows.length === 0) continue; + const cols = dataRows[0].length; + if (cols < 2 || dataRows.some(row => row.length !== cols)) continue; + // Expand by
count to get logical row count + const logicalRows: string[][] = []; + for (const row of dataRows) { + const splitCells = row.map(cell => cell.split("
").map(p => p.trim())); + const rowSpan = Math.max(...splitCells.map(parts => parts.length)); + for (let k = 0; k < rowSpan; k++) { + logicalRows.push(splitCells.map(parts => parts[k] ?? "")); + } + } + if (logicalRows.length < 2) continue; + // Find header with (cols + 1) non-numeric tokens + let headerIdx = -1; + let headerTokens: string[] = []; + for (let i = Math.max(0, tableIdx - 4); i <= tableIdx - 1; i++) { + const text = normalizeFullWidthAscii(blocks[i].content).trim(); + if (!isPlainBlock(text)) continue; + const tokens = splitTokens(text); + if (tokens.length === cols + 1 && tokens.every(tok => !/[0-9]/.test(tok))) { + headerIdx = i; + headerTokens = tokens; + } + } + if (headerIdx < 0) continue; + // Collect short label lines above/below table + const aboveLabels: Array<{ idx: number; text: string }> = []; + for (let i = tableIdx - 1; i > headerIdx; i--) { + const text = normalizeFullWidthAscii(blocks[i].content).trim(); + if (!isPlainBlock(text) || !isShortLabel(text)) break; + aboveLabels.push({ idx: i, text }); + } + aboveLabels.reverse(); + const belowLabels: Array<{ idx: number; text: string }> = []; + for (let i = tableIdx + 1; i < blocks.length; i++) { + const text = normalizeFullWidthAscii(blocks[i].content).trim(); + if (!isPlainBlock(text) || !isShortLabel(text)) break; + belowLabels.push({ idx: i, text }); + } + const labels = [...aboveLabels, ...belowLabels]; + if (labels.length !== logicalRows.length) continue; + // Reconstruct the full table + const normalizedLines: string[] = []; + normalizedLines.push(`| ${headerTokens.join(" | ")} |`); + normalizedLines.push(`| ${Array.from({ length: cols + 1 }, () => "---").join(" | ")} |`); + for (let r = 0; r < logicalRows.length; r++) { + normalizedLines.push(`| ${labels[r].text} | ${logicalRows[r].join(" | ")} |`); + } + replacements.set(tableIdx, normalizedLines.join("\n")); + remove.add(headerIdx); + for (const label of labels) remove.add(label.idx); + } + if (replacements.size === 0 && remove.size === 0) return blocks; + const out: ContentBlock[] = []; + for (let i = 0; i < blocks.length; i++) { + if (remove.has(i)) continue; + const replaced = replacements.get(i); + if (replaced) { + out.push({ topY: blocks[i].topY, content: replaced }); + } else { + out.push(blocks[i]); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +/** + * Render one page's content: free text and tables interleaved top-to-bottom. + */ +export function renderPageContent( + freeTextBoxes: TextBox[], + tables: TableGrid[], + imageBlocks: Array<{ topY: number; markdown: string }> = [], + allTextBoxes?: TextBox[], +): string { + const blocks: ContentBlock[] = []; + // Use ALL text boxes (before table/diagram filtering) for modal font size, + // so that diagram labels released as free text don't skew the body size. + const bodyFS = modalFontSize(allTextBoxes ?? freeTextBoxes); + // Free text lines + for (const line of groupFreeTextIntoLines(freeTextBoxes)) { + const prefix = headingPrefix(line.fontSize, bodyFS, line.isBold); + blocks.push({ + topY: line.topY, + content: prefix + line.text, + isTabular: prefix === "" && line.isTabular, + }); + } + // Tables + for (const table of tables) { + const md = renderTableToMarkdown(table); + if (md.length > 0) { + blocks.push({ topY: table.topY, content: md }); + } + } + // Images + for (const img of imageBlocks) { + blocks.push({ topY: img.topY, content: img.markdown }); + } + // Sort top-to-bottom (higher Y = higher on page = comes first) + blocks.sort((a, b) => b.topY - a.topY); + const cleaned = removePageNumbers(blocks); + const headingsMerged = mergeConsecutiveHeadings(cleaned, bodyFS); + const merged = mergeParagraphWraps(headingsMerged, bodyFS); + const normalized = normalizeDetachedFirstColumnTables(merged); + return normalized + .map(b => b.content) + .join("\n\n") + .trim(); +} diff --git a/packages/coding-agent/src/markit/converters/pdf/types.ts b/packages/coding-agent/src/markit/converters/pdf/types.ts new file mode 100644 index 000000000..4168adf45 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pdf/types.ts @@ -0,0 +1,84 @@ +// Adapted from markit-ai (MIT). See ../../NOTICE. + +/** Bounding box in PDF coordinate space (origin = bottom-left). */ +export type Bounds = { + left: number; + right: number; + /** Higher value = higher on the page. */ + top: number; + bottom: number; +}; + +/** A text fragment with position and font metadata. */ +export type TextBox = { + id: string; + text: string; + bounds: Bounds; + pageNumber: number; + /** Dominant font size in points. */ + fontSize: number; + /** True if rendered bold (font name or rendering mode). */ + isBold: boolean; +}; + +/** A horizontal or vertical line segment extracted from vector graphics. */ +export type Segment = { + id: string; + x1: number; + y1: number; + x2: number; + y2: number; +}; + +/** A single cell in a resolved table grid. */ +export type TableCell = { + row: number; + col: number; + text: string; + rowSpan: number; + colSpan: number; +}; + +/** A resolved table grid ready for markdown rendering. */ +export type TableGrid = { + pageNumber: number; + rows: number; + cols: number; + cells: TableCell[]; + warnings: string[]; + /** Top Y coordinate (PDF space: larger = higher on page). */ + topY: number; + /** True for tables detected without vector borders. */ + isBorderless: boolean; +}; + +/** An image/diagram region detected on a page. */ +export type ImageRegion = { + id: string; + pageNumber: number; + /** Bounding box in mupdf coordinates (top-left origin). */ + bbox: { + x: number; + y: number; + w: number; + h: number; + }; + /** Y position in PDF coordinates (bottom-left) for ordering. */ + topY: number; +}; + +/** Result of extracting content from a single PDF page. */ +export type PageContent = { + pageNumber: number; + textBoxes: TextBox[]; + segments: Segment[]; + images: ImageRegion[]; +}; + +/** A block of rendered content (text paragraph or table). */ +export type ContentBlock = { + topY: number; + content: string; + /** True if this line has wide gaps between text boxes (column headers). */ + isTabular?: boolean; +}; diff --git a/packages/coding-agent/src/markit/converters/pptx.ts b/packages/coding-agent/src/markit/converters/pptx.ts new file mode 100644 index 000000000..996da6248 --- /dev/null +++ b/packages/coding-agent/src/markit/converters/pptx.ts @@ -0,0 +1,325 @@ +// Adapted from markit-ai (MIT). See ../NOTICE. +import * as path from "node:path"; +import { XMLParser } from "fast-xml-parser"; +import { unzip, unzipText } from "../../utils/zip"; +import type { ConversionResult, Converter, StreamInfo } from "../types"; + +const EXTENSIONS = [".pptx"]; +const MIMETYPES = ["application/vnd.openxmlformats-officedocument.presentationml.presentation"]; + +/** A text value: bare string/number, or a `{ "#text" }` node when the element carries attributes. */ +type XmlText = string | number | { "#text"?: string }; + +interface TextRun { + "a:t"?: XmlText; +} +interface Paragraph { + "a:r"?: TextRun | TextRun[]; +} +interface TextBody { + "a:p"?: Paragraph | Paragraph[]; +} +interface CNvPr { + "@_name": string; +} +interface Placeholder { + "@_type": string; +} +interface NvPr { + "p:ph"?: Placeholder; +} +interface NvSpPr { + "p:cNvPr"?: CNvPr; + "p:nvPr"?: NvPr; +} +interface NvPicPr { + "p:cNvPr"?: CNvPr; +} +interface Shape { + "p:txBody"?: TextBody; + "p:nvSpPr"?: NvSpPr; +} +interface Blip { + "@_r:embed": string; +} +interface BlipFill { + "a:blip"?: Blip; +} +interface Picture { + "p:blipFill"?: BlipFill; + "p:nvSpPr"?: NvSpPr; + "p:nvPicPr"?: NvPicPr; +} +interface TableCell { + "a:txBody"?: TextBody; +} +interface TableRow { + "a:tc"?: TableCell | TableCell[]; +} +interface Table { + "a:tr"?: TableRow | TableRow[]; +} +interface GraphicData { + "a:tbl"?: Table; +} +interface Graphic { + "a:graphicData"?: GraphicData; +} +interface GraphicFrame { + "a:graphic"?: Graphic; +} +interface SpTree { + "p:sp"?: Shape | Shape[]; + "p:pic"?: Picture | Picture[]; + "p:graphicFrame"?: GraphicFrame | GraphicFrame[]; +} +interface CSld { + "p:spTree"?: SpTree; +} +interface SlideDoc { + "p:sld"?: { "p:cSld"?: CSld }; +} +interface NotesDoc { + "p:notes"?: { "p:cSld"?: CSld }; +} +interface SldId { + "@_r:id": string; +} +interface PresentationDoc { + "p:presentation"?: { "p:sldIdLst"?: { "p:sldId"?: SldId | SldId[] } }; +} +interface Relationship { + "@_Id": string; + "@_Target": string; +} +interface RelationshipsDoc { + Relationships?: { Relationship?: Relationship | Relationship[] }; +} + +export class PptxConverter implements Converter { + name = "pptx"; + + accepts(streamInfo: StreamInfo): boolean { + if (streamInfo.extension && EXTENSIONS.includes(streamInfo.extension)) return true; + if (streamInfo.mimetype && MIMETYPES.some(m => streamInfo.mimetype?.startsWith(m))) return true; + return false; + } + + async convert(input: Buffer, streamInfo: StreamInfo): Promise { + const entries = unzip(input); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + textNodeName: "#text", + processEntities: { maxTotalExpansions: 1_000_000 }, + }); + // Get slide order from presentation.xml + const presXml = unzipText(entries, "ppt/presentation.xml"); + if (!presXml) throw new Error("Invalid PPTX: missing presentation.xml"); + const pres = parser.parse(presXml) as PresentationDoc; + const sldIdList = pres["p:presentation"]?.["p:sldIdLst"]?.["p:sldId"]; + const sldIds = Array.isArray(sldIdList) ? sldIdList : sldIdList ? [sldIdList] : []; + // Get relationship mappings + const relsXml = unzipText(entries, "ppt/_rels/presentation.xml.rels"); + const rels = relsXml ? (parser.parse(relsXml) as RelationshipsDoc) : null; + const relList = rels?.Relationships?.Relationship; + const relArray = Array.isArray(relList) ? relList : relList ? [relList] : []; + const relMap = new Map(); + for (const r of relArray) { + relMap.set(r["@_Id"], r["@_Target"]); + } + // Map slide IDs to file paths in order + const slidePaths: string[] = []; + for (const sld of sldIds) { + const rId = sld["@_r:id"]; + const target = relMap.get(rId); + if (target) slidePaths.push(`ppt/${target}`); + } + // If we couldn't resolve from rels, fall back to finding slide files + if (slidePaths.length === 0) { + const slideFiles = Object.keys(entries) + .filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f)) + .sort((a, b) => { + const na = parseInt(a.match(/slide(\d+)/)?.[1] || "0", 10); + const nb = parseInt(b.match(/slide(\d+)/)?.[1] || "0", 10); + return na - nb; + }); + slidePaths.push(...slideFiles); + } + const imageDir = streamInfo.imageDir; + const sections: string[] = []; + let imageCount = 0; + for (let i = 0; i < slidePaths.length; i++) { + const slideXml = unzipText(entries, slidePaths[i]); + if (!slideXml) continue; + const slide = parser.parse(slideXml) as SlideDoc; + const spTree = slide["p:sld"]?.["p:cSld"]?.["p:spTree"]; + if (!spTree) continue; + // Parse slide-level rels for image references + const slideRelsPath = `${slidePaths[i].replace("slides/slide", "slides/_rels/slide")}.rels`; + const slideRelsXml = unzipText(entries, slideRelsPath); + const slideRelMap = new Map(); + if (slideRelsXml) { + const slideRels = parser.parse(slideRelsXml) as RelationshipsDoc; + const relItems = toList(slideRels?.Relationships?.Relationship); + for (const r of relItems) { + slideRelMap.set(r["@_Id"], r["@_Target"]); + } + } + const slideLines = [``]; + const shapes = spTree["p:sp"]; + const shapeList = Array.isArray(shapes) ? shapes : shapes ? [shapes] : []; + let isTitle = true; + for (const shape of shapeList) { + const text = this.extractText(shape); + if (!text) continue; + if (isTitle) { + slideLines.push(`# ${text}`); + isTitle = false; + } else { + slideLines.push(text); + } + } + // Extract embedded images + const pics = toList(spTree["p:pic"]); + for (const pic of pics) { + const blipFill = pic["p:blipFill"]; + const rEmbed = blipFill?.["a:blip"]?.["@_r:embed"]; + if (!rEmbed) continue; + const target = slideRelMap.get(rEmbed); + if (!target) continue; + // Resolve relative target against slide directory + const imagePath = target.startsWith("/") ? target.slice(1) : `ppt/slides/${target}`; + // Normalize path (e.g. ppt/slides/../media/image1.png → ppt/media/image1.png) + const normalizedPath = imagePath + .split("/") + .reduce((parts, seg) => { + if (seg === "..") parts.pop(); + else parts.push(seg); + return parts; + }, []) + .join("/"); + const buf = entries[normalizedPath]; + if (!buf) continue; + imageCount++; + const name = + pic["p:nvSpPr"]?.["p:cNvPr"]?.["@_name"] || + pic["p:nvPicPr"]?.["p:cNvPr"]?.["@_name"] || + `image_${imageCount}`; + if (imageDir) { + try { + const ext = normalizedPath.split(".").pop() || "png"; + const filename = `slide${i + 1}_${imageCount}.${ext}`; + const filepath = path.join(imageDir, filename); + await Bun.write(filepath, buf); + slideLines.push(`![${name}](${filepath})`); + } catch { + slideLines.push(``); + } + } else { + slideLines.push(``); + } + } + // Tables + const graphicFrames = spTree["p:graphicFrame"]; + const gfList = Array.isArray(graphicFrames) ? graphicFrames : graphicFrames ? [graphicFrames] : []; + for (const gf of gfList) { + const table = this.extractTable(gf); + if (table) slideLines.push(table); + } + // Slide notes + const noteFile = slidePaths[i].replace("slides/slide", "notesSlides/notesSlide"); + const noteXml = unzipText(entries, noteFile); + if (noteXml) { + const note = parser.parse(noteXml) as NotesDoc; + const noteSpTree = note["p:notes"]?.["p:cSld"]?.["p:spTree"]; + if (noteSpTree) { + const noteShapes = noteSpTree["p:sp"]; + const noteList = Array.isArray(noteShapes) ? noteShapes : noteShapes ? [noteShapes] : []; + const noteTexts: string[] = []; + for (const ns of noteList) { + // Skip slide image placeholder + const phType = ns["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"]?.["@_type"]; + if (phType === "sldImg") continue; + const t = this.extractText(ns); + if (t) noteTexts.push(t); + } + if (noteTexts.length > 0) { + slideLines.push("\n### Notes:"); + slideLines.push(noteTexts.join("\n")); + } + } + } + sections.push(slideLines.join("\n")); + } + return { markdown: sections.join("\n\n").trim() }; + } + + extractText(shape: Shape): string { + const txBody = shape["p:txBody"]; + if (!txBody) return ""; + const paragraphs = txBody["a:p"]; + const pList = Array.isArray(paragraphs) ? paragraphs : paragraphs ? [paragraphs] : []; + const lines: string[] = []; + for (const p of pList) { + const runs = p["a:r"]; + const rList = Array.isArray(runs) ? runs : runs ? [runs] : []; + const parts: string[] = []; + for (const r of rList) { + const t = r["a:t"]; + if (t != null) parts.push(typeof t === "object" ? t["#text"] || "" : String(t)); + } + if (parts.length > 0) lines.push(parts.join("")); + } + return lines.join("\n").trim(); + } + + extractTable(gf: GraphicFrame): string | null { + const tbl = gf?.["a:graphic"]?.["a:graphicData"]?.["a:tbl"]; + if (!tbl) return null; + const rows = tbl["a:tr"]; + const rowList = Array.isArray(rows) ? rows : rows ? [rows] : []; + if (rowList.length === 0) return null; + const mdRows: string[][] = []; + for (const row of rowList) { + const cells = row["a:tc"]; + const cellList = Array.isArray(cells) ? cells : cells ? [cells] : []; + const cellTexts: string[] = []; + for (const cell of cellList) { + const txBody = cell["a:txBody"]; + if (!txBody) { + cellTexts.push(""); + continue; + } + const paragraphs = txBody["a:p"]; + const pList = Array.isArray(paragraphs) ? paragraphs : paragraphs ? [paragraphs] : []; + const parts: string[] = []; + for (const p of pList) { + const runs = p["a:r"]; + const rList = Array.isArray(runs) ? runs : runs ? [runs] : []; + for (const r of rList) { + const t = r["a:t"]; + if (t != null) parts.push(typeof t === "object" ? t["#text"] || "" : String(t)); + } + } + cellTexts.push(parts.join(" ")); + } + mdRows.push(cellTexts); + } + if (mdRows.length === 0) return null; + const [header, ...body] = mdRows; + const lines: string[] = []; + lines.push(`| ${header.join(" | ")} |`); + lines.push(`| ${header.map(() => "---").join(" | ")} |`); + for (const row of body) { + while (row.length < header.length) row.push(""); + lines.push(`| ${row.join(" | ")} |`); + } + return lines.join("\n"); + } +} + +function toList(val: T | T[] | undefined): T[] { + if (!val) return []; + return Array.isArray(val) ? val : [val]; +} diff --git a/packages/coding-agent/src/markit/converters/xlsx.ts b/packages/coding-agent/src/markit/converters/xlsx.ts new file mode 100644 index 000000000..5bfd50fda --- /dev/null +++ b/packages/coding-agent/src/markit/converters/xlsx.ts @@ -0,0 +1,173 @@ +// Adapted from markit-ai (MIT). See ../NOTICE. +import { XMLParser } from "fast-xml-parser"; +import { unzip, unzipText } from "../../utils/zip"; +import type { ConversionResult, Converter, StreamInfo } from "../types"; + +const EXTENSIONS = [".xlsx"]; +const MIMETYPES = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"]; + +/** A text value: bare string/number, or a `{ "#text" }` node when the element carries attributes. */ +type XmlText = string | number | { "#text"?: string }; + +interface RichTextRun { + t?: XmlText; +} +interface StringItem { + t?: XmlText; + r?: RichTextRun | RichTextRun[]; +} +interface Cell { + "@_t"?: string; + v?: string | number; + is?: StringItem; +} +interface Row { + c?: Cell | Cell[]; +} +interface WorksheetDoc { + worksheet?: { sheetData?: { row?: Row | Row[] } }; +} +interface Sheet { + "@_name": string; + "@_r:id": string; +} +interface WorkbookDoc { + workbook?: { sheets?: { sheet?: Sheet | Sheet[] } }; +} +interface SharedStringsDoc { + sst?: { si?: StringItem | StringItem[] }; +} +interface Relationship { + "@_Id": string; + "@_Target": string; +} +interface RelationshipsDoc { + Relationships?: { Relationship?: Relationship | Relationship[] }; +} + +export class XlsxConverter implements Converter { + name = "xlsx"; + + accepts(streamInfo: StreamInfo): boolean { + if (streamInfo.extension && EXTENSIONS.includes(streamInfo.extension)) return true; + if (streamInfo.mimetype && MIMETYPES.some(m => streamInfo.mimetype?.startsWith(m))) return true; + return false; + } + + async convert(input: Buffer, _streamInfo: StreamInfo): Promise { + const entries = unzip(input); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + textNodeName: "#text", + processEntities: { maxTotalExpansions: 1_000_000 }, + }); + // Parse shared strings + const ssXml = unzipText(entries, "xl/sharedStrings.xml"); + const ss = ssXml ? (parser.parse(ssXml) as SharedStringsDoc) : null; + const siList = ss?.sst?.si; + const shared = toArray(siList); + // Parse workbook for sheet names + const wbXml = unzipText(entries, "xl/workbook.xml"); + if (!wbXml) throw new Error("Invalid XLSX: missing workbook.xml"); + const wb = parser.parse(wbXml) as WorkbookDoc; + const sheets = toArray(wb.workbook?.sheets?.sheet); + // Parse workbook rels to map rIds to sheet files + const relsXml = unzipText(entries, "xl/_rels/workbook.xml.rels"); + const rels = relsXml ? (parser.parse(relsXml) as RelationshipsDoc) : null; + const relList = toArray(rels?.Relationships?.Relationship); + const relMap = new Map(); + for (const r of relList) { + relMap.set(r["@_Id"], r["@_Target"]); + } + const sections: string[] = []; + for (const sheet of sheets) { + const sheetName = sheet["@_name"]; + const rId = sheet["@_r:id"]; + const target = relMap.get(rId); + if (!target) continue; + const sheetPath = target.startsWith("/") ? target.slice(1) : `xl/${target}`; + const sheetXml = unzipText(entries, sheetPath); + if (!sheetXml) continue; + const parsed = parser.parse(sheetXml) as WorksheetDoc; + const rows = toArray(parsed.worksheet?.sheetData?.row); + if (rows.length === 0) continue; + // Extract all rows as string arrays + const tableRows: string[][] = []; + for (const row of rows) { + const cells = toArray(row.c); + const values: string[] = []; + for (const cell of cells) { + values.push(this.getCellValue(cell, shared)); + } + tableRows.push(values); + } + if (tableRows.length === 0) continue; + // Normalize column count + const maxCols = Math.max(...tableRows.map(r => r.length)); + for (const row of tableRows) { + while (row.length < maxCols) row.push(""); + } + sections.push(`## ${sheetName}`); + const [header, ...body] = tableRows; + const lines: string[] = []; + lines.push(`| ${header.join(" | ")} |`); + lines.push(`| ${header.map(() => "---").join(" | ")} |`); + for (const row of body) { + lines.push(`| ${row.join(" | ")} |`); + } + sections.push(lines.join("\n")); + } + return { markdown: sections.join("\n\n") }; + } + + getCellValue(cell: Cell, shared: StringItem[]): string { + // Shared string + if (cell["@_t"] === "s") { + return this.getSharedString(shared, Number(cell.v)); + } + // Inline string + if (cell["@_t"] === "inlineStr") { + const is = cell.is; + if (!is) return ""; + if (is.t != null) return textValue(is.t); + if (is.r) + return toArray(is.r) + .map(r => textValue(r.t)) + .join(""); + return ""; + } + // Boolean + if (cell["@_t"] === "b") { + return cell.v === 1 || cell.v === "1" ? "TRUE" : "FALSE"; + } + // Number or formula result + if (cell.v != null) return String(cell.v); + return ""; + } + + getSharedString(shared: StringItem[], idx: number): string { + const si = shared[idx]; + if (!si) return ""; + // Simple text + if (si.t != null) return textValue(si.t); + // Rich text runs + if (si.r) { + return toArray(si.r) + .map(r => textValue(r.t)) + .join(""); + } + return ""; + } +} + +function textValue(t: XmlText | undefined): string { + if (t == null) return ""; + if (typeof t === "object") return t["#text"] || ""; + return String(t); +} + +function toArray(val: T | T[] | undefined): T[] { + if (!val) return []; + return Array.isArray(val) ? val : [val]; +} diff --git a/packages/coding-agent/src/markit/index.ts b/packages/coding-agent/src/markit/index.ts new file mode 100644 index 000000000..558bcaf53 --- /dev/null +++ b/packages/coding-agent/src/markit/index.ts @@ -0,0 +1,2 @@ +export * from "./registry"; +export * from "./types"; diff --git a/packages/coding-agent/src/markit/registry.ts b/packages/coding-agent/src/markit/registry.ts new file mode 100644 index 000000000..1fa20f55a --- /dev/null +++ b/packages/coding-agent/src/markit/registry.ts @@ -0,0 +1,59 @@ +// Adapted from markit-ai (MIT). See ./NOTICE. +import * as path from "node:path"; +import { DocxConverter } from "./converters/docx"; +import { EpubConverter } from "./converters/epub"; +import { PdfConverter } from "./converters/pdf"; +import { PptxConverter } from "./converters/pptx"; +import { XlsxConverter } from "./converters/xlsx"; +import type { ConversionResult, Converter, MarkitOptions, StreamInfo } from "./types"; + +/** + * In-house document → markdown engine (replaces the `markit-ai` package). + * + * Only the document converters omp routes are registered (pdf, docx, pptx, + * xlsx, epub). The first converter whose `accepts()` returns true and whose + * `convert()` succeeds wins. + */ +export class Markit { + readonly #converters: readonly Converter[]; + readonly #options: MarkitOptions; + + constructor(options: MarkitOptions = {}) { + this.#options = options; + this.#converters = [ + new PdfConverter(), + new DocxConverter(), + new PptxConverter(), + new XlsxConverter(), + new EpubConverter(), + ]; + } + + async convertFile(filePath: string, extra?: { imageDir?: string }): Promise { + const buffer = Buffer.from(await Bun.file(filePath).arrayBuffer()); + const streamInfo: StreamInfo = { + localPath: filePath, + extension: path.extname(filePath).toLowerCase(), + filename: path.basename(filePath), + ...extra, + }; + return this.convert(buffer, streamInfo); + } + + async convert(input: Buffer, streamInfo: StreamInfo): Promise { + const errors: { converter: string; error: Error }[] = []; + for (const converter of this.#converters) { + if (!converter.accepts(streamInfo)) continue; + try { + return await converter.convert(input, streamInfo, this.#options); + } catch (err) { + errors.push({ converter: converter.name, error: err instanceof Error ? err : new Error(String(err)) }); + } + } + if (errors.length > 0) { + const details = errors.map(e => ` ${e.converter}: ${e.error.message}`).join("\n"); + throw new Error(`Conversion failed:\n${details}`); + } + throw new Error(`Unsupported format: ${streamInfo.extension || streamInfo.mimetype || "unknown"}`); + } +} diff --git a/packages/coding-agent/src/markit/types.ts b/packages/coding-agent/src/markit/types.ts new file mode 100644 index 000000000..1b9f6c8f7 --- /dev/null +++ b/packages/coding-agent/src/markit/types.ts @@ -0,0 +1,35 @@ +// Adapted from markit-ai (MIT). See ./NOTICE. + +export interface StreamInfo { + mimetype?: string; + extension?: string; + charset?: string; + filename?: string; + localPath?: string; + url?: string; + /** Directory to write extracted images/diagrams. */ + imageDir?: string; +} + +export interface ConversionResult { + markdown: string; + title?: string; +} + +export interface MarkitOptions { + /** Describe an image, return markdown. Receives raw bytes and mimetype. */ + describe?: (image: Buffer, mimetype: string) => Promise; + /** Transcribe audio, return text. Receives raw bytes and mimetype. */ + transcribe?: (audio: Buffer, mimetype: string) => Promise; + /** Extra instructions appended to the image description prompt. */ + prompt?: string; +} + +export interface Converter { + /** Human-readable name for error messages. */ + name: string; + /** Quick check: can this converter handle the given stream? */ + accepts(streamInfo: StreamInfo): boolean; + /** Convert the source to markdown. */ + convert(input: Buffer, streamInfo: StreamInfo, options?: MarkitOptions): Promise; +} diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 469290dbe..0f568347d 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -68,10 +68,17 @@ import type { SessionInfo as StoredSessionInfo } from "../../session/session-lis import { SessionManager } from "../../session/session-manager"; import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands"; +import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "../../stt/models"; import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking"; import { normalizeLocalScheme } from "../../tools/path-utils"; import { runResolveInvocation } from "../../tools/resolve"; import { ToolError } from "../../tools/tool-errors"; +import { + DEFAULT_TTS_LOCAL_MODEL_KEY, + DEFAULT_TTS_VOICE, + TTS_LOCAL_MODELS, + TTS_LOCAL_VOICE_OPTIONS, +} from "../../tts/models"; import { canonicalizeMessage } from "../../utils/thinking-display"; import { createAcpClientBridge } from "./acp-client-bridge"; import { @@ -91,6 +98,7 @@ const MODEL_CONFIG_ID = "model"; const THINKING_CONFIG_ID = "thinking"; const THINKING_OFF = "off"; const SESSION_PAGE_SIZE = 50; +const SPEECH_MODELS_LIST_METHOD = "speech.models.list"; /** * Delay between `session/new` (or `session/load` / `session/resume` / * `unstable_session/fork`) returning and the agent firing the first @@ -195,6 +203,59 @@ type MCPSourceMap = { type CreateAcpSession = (cwd: string) => Promise; +type AcpSpeechOption = { + value: string; + label: string; + description?: string; +}; + +type AcpSpeechVoiceOption = { + value: string; + label: string; +}; + +type AcpSpeechTtsModelOption = AcpSpeechOption & { + voices: AcpSpeechVoiceOption[]; +}; + +function buildAcpSpeechModelsCatalog(): Record { + const voices = TTS_LOCAL_VOICE_OPTIONS.map(({ value, label }) => ({ value, label })); + return { + settings: { + speechToTextModel: "stt.modelName", + textToSpeechModel: "tts.localModel", + textToSpeechVoice: "tts.localVoice", + speechVoice: "speech.voice", + }, + defaults: { + speechToTextModel: DEFAULT_STT_MODEL_KEY, + textToSpeechModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + voice: DEFAULT_TTS_VOICE, + }, + speechToText: { + setting: "stt.modelName", + defaultValue: DEFAULT_STT_MODEL_KEY, + models: STT_MODEL_OPTIONS.map(({ value, label, description }) => ({ value, label, description })), + }, + textToSpeech: { + modelSetting: "tts.localModel", + voiceSetting: "tts.localVoice", + speechVoiceSetting: "speech.voice", + defaultModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + defaultVoice: DEFAULT_TTS_VOICE, + models: TTS_LOCAL_MODELS.map( + ({ key, label, description, voices: modelVoices }): AcpSpeechTtsModelOption => ({ + value: key, + label, + description, + voices: modelVoices.map(({ id, label: voiceLabel }) => ({ value: id, label: voiceLabel })), + }), + ), + voices, + }, + }; +} + /** * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation` * surface. Skills/extensions ask for one value at a time (a chosen option, a @@ -850,6 +911,8 @@ export class AcpAgent implements Agent { async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> { switch (method) { + case SPEECH_MODELS_LIST_METHOD: + return buildAcpSpeechModelsCatalog(); case "_omp/sessions/listAll": { const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000; const sessions = await SessionManager.listAll(); diff --git a/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts new file mode 100644 index 000000000..b14467171 --- /dev/null +++ b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "../../../config/settings"; +import type { CustomMessage, SkillPromptDetails } from "../../../session/messages"; +import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme"; +import { SkillMessageComponent } from "../skill-message"; + +// Drop SGR colors and OSC 8 hyperlink wrappers so assertions see the visible text only. +const strip = (lines: readonly string[]): string => + lines + .join("\n") + .replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-9;]*m/g, ""); + +function makeMessage( + details: SkillPromptDetails, + content = "Use the atomic-commit workflow.", +): CustomMessage { + return { role: "custom", customType: "skill-prompt", content, display: true, details, timestamp: Date.now() }; +} + +describe("SkillMessageComponent", () => { + let uiTheme: Theme; + + beforeAll(async () => { + await Settings.init({ inMemory: true }); + const loaded = await getThemeByName("dark"); + if (!loaded) throw new Error("theme unavailable"); + uiTheme = loaded; + setThemeInstance(uiTheme); + }); + + const skillPath = path.join(os.homedir(), ".agent/skills/atomic-commit/SKILL.md"); + + it("renders a compact, outlined card instead of the archaic key:value dump", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88 }), + ); + const text = strip(component.render(80)); + + // New look: an icon-tagged "skill" header with the name and a single meta line. + expect(text).toContain("skill"); + expect(text).toContain("atomic-commit"); + expect(text).toContain("88 lines"); + + // The card is drawn with an outline. + expect(text).toContain(uiTheme.boxRound.topLeft); + expect(text).toContain(uiTheme.boxRound.bottomRight); + + // Path is home-shortened and never leaks the absolute home dir. + expect(text).toContain("~/.agent/skills/atomic-commit/SKILL.md"); + expect(text).not.toContain(os.homedir()); + + // The old archaic framing is gone. + expect(text).not.toContain("[skill]"); + expect(text).not.toContain("Skill:"); + expect(text).not.toContain("Path:"); + expect(text).not.toContain("Prompt:"); + }); + + it("flattens multi-line args onto the single-line header", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88, args: "stage all\nthen split" }), + ); + const text = strip(component.render(80)); + // Whitespace (including the newline) collapsed to single spaces so the header can't break. + expect(text).toContain("stage all then split"); + expect(text).not.toContain("stage all\nthen split"); + }); + + it("uses a singular unit for a one-line prompt", () => { + const component = new SkillMessageComponent(makeMessage({ name: "tiny", path: skillPath, lineCount: 1 })); + const text = strip(component.render(80)); + expect(text).toContain("1 line"); + expect(text).not.toContain("1 lines"); + }); + + it("reveals the prompt body under a calm subheader only when expanded", () => { + const details: SkillPromptDetails = { name: "atomic-commit", path: skillPath, lineCount: 88 }; + const body = "Step one: stage hunks."; + + const collapsed = new SkillMessageComponent(makeMessage(details, body)); + expect(strip(collapsed.render(80))).not.toContain(body); + + const expanded = new SkillMessageComponent(makeMessage(details, body)); + expanded.setExpanded(true); + const text = strip(expanded.render(80)); + expect(text).toContain("prompt"); + expect(text).toContain(body); + }); +}); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index 1a9ad6c1a..87837ddcd 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -321,7 +321,7 @@ class TwoColumnBody implements Component { const rightLines = this.rightPane.render(rightWidth); const lineCount = this.maxHeight; const out: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < lineCount; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 1ff98a92d..42aed6f3d 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -15,63 +15,26 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; -import type { Usage } from "@oh-my-pi/pi-ai"; -import { Container, Editor, Ellipsis, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; -import type { AdvisorMessageDetails } from "../../advisor"; -import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui"; +import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { ADVISOR_TRANSCRIPT_FILENAME } from "../../advisor"; import type { KeyId } from "../../config/keybindings"; -import { settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; -import type { AgentSession } from "../../session/agent-session"; -import { - BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, - type CustomMessage, - isSilentAbort, - LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, - resolveAbortLabel, - SKILL_PROMPT_MESSAGE_TYPE, - type SkillPromptDetails, - USER_INTERRUPT_LABEL, -} from "../../session/messages"; -import type { SessionMessageEntry } from "../../session/session-entries"; -import { parseSessionEntries } from "../../session/session-loader"; -import { createIrcMessageCard } from "../../tools/irc"; +import { USER_INTERRUPT_LABEL } from "../../session/messages"; import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; -import { canonicalizeMessage } from "../../utils/thinking-display"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; -import { getEditorTheme, theme } from "../theme/theme"; +import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; -import { createAdvisorMessageCard } from "./advisor-message"; -import { AssistantMessageComponent } from "./assistant-message"; -import { createBackgroundTanDispatchBlock } from "./background-tan-message"; -import { BashExecutionComponent } from "./bash-execution"; -import { BranchSummaryMessageComponent } from "./branch-summary-message"; -import { CollabPromptMessageComponent } from "./collab-prompt-message"; -import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message"; -import { CustomMessageComponent } from "./custom-message"; +import { AgentTranscriptViewer } from "./agent-transcript-viewer"; import { DynamicBorder } from "./dynamic-border"; -import { EvalExecutionComponent } from "./eval-execution"; -import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; -import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group"; -import { SkillMessageComponent } from "./skill-message"; -import { formatContextUsage } from "./status-line/context-thresholds"; -import { ToolExecutionComponent } from "./tool-execution"; -import { TranscriptBlock, TranscriptContainer } from "./transcript-container"; -import { createUsageRowBlock } from "./usage-row"; -import { UserMessageComponent } from "./user-message"; -/** Lines per page for PageUp/PageDown */ -const PAGE_SIZE = 15; /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; -/** Debounce for live-session transcript refreshes */ -const CHAT_REFRESH_DEBOUNCE_MS = 80; -/** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */ +/** Double-tap window for the table's left-left "close hub" gesture. */ const LEFT_TAP_WINDOW_MS = 500; /** Compute the max content width for the current terminal, accounting for chrome. */ @@ -120,8 +83,33 @@ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string, } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; - const id = entry.name.slice(0, -6); const sessionFile = path.join(dir, entry.name); + // The advisor transcript is observability-only: register it as a non-peer + // `advisor` kind under its owning session so the Hub can show its read-only + // transcript, but it never joins agent-facing rosters and is not revivable. + if (entry.name === ADVISOR_TRANSCRIPT_FILENAME) { + const owner = parentId ?? MAIN_AGENT_ID; + const advisorId = `${owner}/advisor`; + const existing = registry.get(advisorId); + // Never clobber a non-advisor ref that happens to share this id (a freak + // user task literally named `/advisor`): leave it, skip the advisor. + if (existing && existing.kind !== "advisor") continue; + if (existing?.sessionFile !== sessionFile) { + // The id is reused across `/new`; refresh it to the current session's file. + if (existing) registry.unregister(advisorId); + registry.register({ + id: advisorId, + displayName: "advisor", + kind: "advisor", + parentId: owner, + session: null, + sessionFile, + status: "parked", + }); + } + continue; + } + const id = entry.name.slice(0, -6); if (!registry.get(id)) { registry.register({ id, @@ -190,29 +178,17 @@ export class AgentHubOverlayComponent extends Container { #unsubscribers: Array<() => void> = []; #ageTimer: NodeJS.Timeout | undefined; #remote: AgentHubRemote | undefined; - #remoteFetchInFlight = false; - /** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */ - #remoteFetchToken = 0; - #remoteTranscriptUnavailable = false; // Table state - #view: "table" | "chat" = "table"; #rows: AgentRef[] = []; #selectedRow = 0; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; + /** Double-tap window state for the table's left-left "close hub" gesture. */ + #lastLeftTap = 0; - // Chat state - #chatAgentId: string | undefined; - #editor: Editor; - #sessionUnsubscribe: (() => void) | undefined; - #attachedSession: AgentSession | undefined; - #chatRefreshTimer: NodeJS.Timeout | undefined; - #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined; - - // Chat transcript: the same component renderers as the main session - // transcript, assembled incrementally from the persisted JSONL entries. + // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). #ui: TUI; #getTool: ((name: string) => AgentTool | undefined) | undefined; #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined; @@ -220,25 +196,10 @@ export class AgentHubOverlayComponent extends Container { #hideThinkingBlock: (() => boolean) | undefined; #expandKeys: KeyId[]; #focusAgent: ((id: string) => Promise) | undefined; - #chatLog = new TranscriptContainer(); - #chatEntriesRef: SessionMessageEntry[] | undefined; - #chatBuiltCount = 0; - #chatPendingTools = new Map(); - #chatReadArgs = new Map>(); - #chatReadGroup: ReadToolGroupComponent | null = null; - #pendingUsage: Usage | undefined; - #chatWaitingPoll: ToolExecutionComponent | null = null; - #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = []; - #chatExpanded = false; - #chatPlaceholder: string | undefined; - // Viewport state - #scrollOffset = 0; - #lastMaxScroll = 0; - #viewportHeight = 20; - #wasAtBottom = true; - #viewerHeaderLines: string[] = []; - #lastLeftTap = 0; + // Fullscreen transcript overlay opened by openChat(), if any. + #transcriptOverlay: OverlayHandle | undefined; + #transcriptViewer: AgentTranscriptViewer | undefined; constructor(deps: AgentHubDeps) { super(); @@ -265,10 +226,6 @@ export class AgentHubOverlayComponent extends Container { this.#expandKeys = deps.expandKeys ?? ["ctrl+o"]; this.#focusAgent = deps.focusAgent; - this.#editor = new Editor(getEditorTheme()); - this.#editor.setMaxHeight(4); - this.#editor.onSubmit = text => this.#submitChatMessage(text); - this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange())); this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange())); this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS); @@ -294,17 +251,11 @@ export class AgentHubOverlayComponent extends Container { clearInterval(this.#ageTimer); this.#ageTimer = undefined; } - if (this.#chatRefreshTimer) { - clearTimeout(this.#chatRefreshTimer); - this.#chatRefreshTimer = undefined; - } - this.#detachLiveSession(); - this.#resetChatLog(); + this.#closeTranscriptOverlay(); } override render(width: number): readonly string[] { - const lines = this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width); - return lines.map(line => clampHubLine(line, width)); + return this.#renderTable(width).map(line => clampHubLine(line, width)); } handleInput(keyData: string): void { @@ -315,30 +266,53 @@ export class AgentHubOverlayComponent extends Container { return; } } - if (this.#view === "table") { - this.#handleTableInput(keyData); - } else { - this.#handleChatInput(keyData); - } + this.#handleTableInput(keyData); } - /** Open the chat view for an agent id (public for table Enter and tests). */ + /** + * Open the fullscreen transcript viewer for an agent id (public for table Enter + * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it + * owns the alternate screen; the hub table stays mounted underneath and is + * restored when the viewer closes. No-op without a real TUI (render-only test stub). + */ openChat(id: string): void { if (!this.#registry.get(id)) return; - this.#view = "chat"; - this.#chatAgentId = id; + if (typeof this.#ui.showOverlay !== "function") return; + this.#closeTranscriptOverlay(); this.#notice = undefined; - this.#transcriptCache = undefined; - this.#remoteTranscriptUnavailable = false; - this.#remoteFetchInFlight = false; - this.#remoteFetchToken++; - this.#resetChatLog(); - this.#scrollOffset = 0; - this.#wasAtBottom = true; - this.#lastLeftTap = 0; - this.#editor.setText(""); - this.#attachLiveSession(); - this.#rebuildChatContent(); + const viewer = new AgentTranscriptViewer({ + agentId: id, + registry: this.#registry, + remote: this.#remote, + observers: this.#observers, + lifecycle: this.#remote ? undefined : this.#lifecycle, + ui: this.#ui, + getTool: this.#getTool, + getMessageRenderer: this.#getMessageRenderer, + cwd: this.#cwd, + hideThinkingBlock: this.#hideThinkingBlock, + expandKeys: this.#expandKeys, + hubKeys: this.#hubKeys, + requestRender: this.#requestRender, + onClose: () => this.#closeTranscriptOverlay(), + onHubClose: () => { + this.#closeTranscriptOverlay(); + this.#onDone(); + }, + }); + this.#transcriptViewer = viewer; + this.#transcriptOverlay = this.#ui.showOverlay(viewer, { width: "100%", margin: 0, fullscreen: true }); + this.#ui.setFocus(viewer); + this.#requestRender(); + } + + /** Close and dispose the transcript overlay, restoring focus to the hub table. */ + #closeTranscriptOverlay(): void { + this.#transcriptOverlay?.hide(); + this.#transcriptOverlay = undefined; + this.#transcriptViewer?.dispose(); + this.#transcriptViewer = undefined; + if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); this.#requestRender(); } @@ -348,12 +322,6 @@ export class AgentHubOverlayComponent extends Container { #onDataChange(): void { this.#refreshRows(); - if (this.#view === "chat") { - // A revive/park swaps the live session out from under the chat view. - this.#attachLiveSession(); - this.#scheduleChatRefresh(); - return; - } this.#requestRender(); } @@ -389,39 +357,6 @@ export class AgentHubOverlayComponent extends Container { this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); } - /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */ - #attachLiveSession(): void { - // Remote refs carry no live session handle; refreshes come from observer onChange. - if (this.#remote) return; - const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined; - if (session === this.#attachedSession) return; - this.#detachLiveSession(); - if (!session) return; - this.#attachedSession = session; - this.#sessionUnsubscribe = session.subscribe(event => { - if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") { - this.#scheduleChatRefresh(); - } - }); - } - - #detachLiveSession(): void { - this.#sessionUnsubscribe?.(); - this.#sessionUnsubscribe = undefined; - this.#attachedSession = undefined; - } - - #scheduleChatRefresh(): void { - if (this.#chatRefreshTimer) return; - this.#chatRefreshTimer = setTimeout(() => { - this.#chatRefreshTimer = undefined; - if (this.#view !== "chat") return; - this.#rebuildChatContent(); - this.#requestRender(); - }, CHAT_REFRESH_DEBOUNCE_MS); - this.#chatRefreshTimer.unref?.(); - } - #observableFor(id: string): ObservableSession | undefined { return this.#observers.getSessions().find(s => s.id === id); } @@ -553,7 +488,9 @@ export class AgentHubOverlayComponent extends Container { #activateAgent(ref: AgentRef): void { this.#notice = undefined; const focusAgent = this.#focusAgent; - if (this.#remote || !focusAgent) { + // Advisor refs are read-only transcripts with no live/ revivable session; + // open the in-hub chat view (file-backed) instead of trying to focus one. + if (ref.kind === "advisor" || this.#remote || !focusAgent) { this.openChat(ref.id); return; } @@ -571,6 +508,11 @@ export class AgentHubOverlayComponent extends Container { #reviveSelected(): void { const ref = this.#rows[this.#selectedRow]; if (!ref) return; + if (ref.kind === "advisor") { + this.#notice = `"${ref.id}" is a read-only advisor transcript — nothing to revive.`; + this.#requestRender(); + return; + } if (ref.status !== "parked") { this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`; this.#requestRender(); @@ -595,6 +537,11 @@ export class AgentHubOverlayComponent extends Container { #killSelected(): void { const ref = this.#rows[this.#selectedRow]; if (!ref) return; + if (ref.kind === "advisor") { + this.#notice = `"${ref.id}" is a read-only advisor transcript — cannot be killed.`; + this.#requestRender(); + return; + } this.#notice = undefined; if (this.#remote) { this.#remote.kill(ref.id); @@ -616,774 +563,4 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender(); })(); } - - // ======================================================================== - // Chat view - // ======================================================================== - - #renderChat(width: number): string[] { - const termHeight = process.stdout.rows || 40; - const innerWidth = Math.max(20, width - 2); - const editorLines = this.#editor.render(innerWidth); - const noticeLine = this.#notice - ? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}` - : undefined; - const footerLines = this.#buildChatFooterLines(); - - // Header: border + headerLines + border; footer: notice? + editor + footer + border - const headerChrome = this.#viewerHeaderLines.length + 2; - const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1; - this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome); - - const contentLines: readonly string[] = this.#chatPlaceholder - ? [theme.fg("dim", this.#chatPlaceholder)] - : this.#chatLog.render(innerWidth); - - const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight); - this.#lastMaxScroll = maxScroll; - if (this.#wasAtBottom) this.#scrollOffset = maxScroll; - this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll)); - - const lines: string[] = []; - lines.push(...new DynamicBorder().render(width)); - for (const headerLine of this.#viewerHeaderLines) { - lines.push(` ${headerLine}`); - } - lines.push(...new DynamicBorder().render(width)); - - const scrollView = new ScrollView( - contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight), - { - height: this.#viewportHeight, - scrollbar: "auto", - totalRows: contentLines.length, - theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) }, - }, - ); - scrollView.setScrollOffset(this.#scrollOffset); - for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`); - - if (noticeLine) lines.push(noticeLine); - for (const editorLine of editorLines) lines.push(` ${editorLine}`); - lines.push(...footerLines); - lines.push(...new DynamicBorder().render(width)); - return lines; - } - - #buildChatFooterLines(): string[] { - const lines: string[] = []; - const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined; - const statsLine = this.#buildStatsLine(observed); - if (statsLine) lines.push(` ${statsLine}`); - lines.push( - ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`, - ); - return lines; - } - - #buildStatsLine(observed: ObservableSession | undefined): string { - const progress = observed?.progress; - if (!progress) return ""; - const stats: string[] = []; - // Current per-turn context — match the status line's `%/` gauge (e.g. `5.1%/1M`). - if (progress.contextTokens && progress.contextTokens > 0) { - const ctx = - progress.contextWindow && progress.contextWindow > 0 - ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow) - : `${formatNumber(progress.contextTokens)}`; - stats.push(ctx); - } - if (progress.durationMs > 0) { - stats.push(formatDuration(progress.durationMs)); - } - const parts: string[] = []; - if (stats.length > 0 || progress.toolCount > 0) { - const toolCountStat = - progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined; - const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment)); - parts.push(theme.fg("dim", statSegments.join(theme.sep.dot))); - } - if (progress.cost > 0) { - parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); - } - return parts.join(theme.sep.dot); - } - - /** Rebuild the chat header and sync transcript components from new entries */ - #rebuildChatContent(): void { - const id = this.#chatAgentId; - const ref = id ? this.#registry.get(id) : undefined; - - // Load transcript first so model info is available for the header - let messageEntries: SessionMessageEntry[] | null = null; - if (this.#remote) { - if (id) this.#fetchRemoteTranscript(id); - messageEntries = this.#transcriptCache?.entries ?? []; - } else if (ref?.sessionFile) { - messageEntries = this.#loadTranscript(ref.sessionFile); - } - - this.#viewerHeaderLines = []; - this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`)); - if (ref) { - const observed = this.#observableFor(ref.id); - const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model; - const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`); - const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : ""; - this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`); - } - - if (!ref) { - this.#chatPlaceholder = "Agent no longer registered."; - } else if (!this.#remote && !ref.sessionFile) { - this.#chatPlaceholder = "No session file available yet."; - } else if (!messageEntries) { - this.#chatPlaceholder = "Unable to read session file."; - } else if (messageEntries.length === 0) { - if (this.#remote && this.#remoteTranscriptUnavailable) { - this.#chatPlaceholder = "Transcript lives on the host — not available."; - } else if (this.#remote && !this.#transcriptCache) { - this.#chatPlaceholder = "Loading transcript from host…"; - } else { - this.#chatPlaceholder = "No messages yet."; - } - } else { - this.#chatPlaceholder = undefined; - this.#syncChatComponents(messageEntries); - } - } - - #handleChatInput(keyData: string): void { - const editorEmpty = this.#editor.getText().trim() === ""; - - if (matchesKey(keyData, "escape")) { - if (!editorEmpty) { - this.#editor.setText(""); - this.#requestRender(); - return; - } - this.#closeChat(); - return; - } - - // Tool output expansion mirrors the main transcript's app.tools.expand toggle. - for (const key of this.#expandKeys) { - if (matchesKey(keyData, key)) { - this.#chatExpanded = !this.#chatExpanded; - for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded); - this.#requestRender(); - return; - } - } - - // Double-tap left on an empty editor hops to the parent session — - // the inverse of the main editor's double-left opening the hub. - if (editorEmpty && matchesKey(keyData, "left")) { - const now = Date.now(); - if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { - this.#lastLeftTap = 0; - this.#openParent(); - } else { - this.#lastLeftTap = now; - } - return; - } - - // Scrolling works while the input is empty; once the user starts - // typing, the editor owns every key. - if (editorEmpty && this.#handleViewerNavigation(keyData)) { - return; - } - - this.#editor.handleInput(keyData); - this.#requestRender(); - } - - /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */ - #openParent(): void { - const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined; - const parentId = ref?.parentId; - if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) { - this.openChat(parentId); - return; - } - this.#onDone(); - } - - #closeChat(): void { - this.#view = "table"; - this.#chatAgentId = undefined; - this.#notice = undefined; - this.#detachLiveSession(); - this.#resetChatLog(); - this.#refreshRows(); - this.#requestRender(); - } - - #submitChatMessage(text: string): void { - const id = this.#chatAgentId; - const trimmed = text.trim(); - if (!id || !trimmed) return; - this.#editor.setText(""); - this.#notice = undefined; - if (this.#remote) { - this.#remote.chat(id, trimmed); - this.#scheduleChatRefresh(); - this.#requestRender(); - return; - } - void (async () => { - try { - // Revives a parked agent; returns the live session for running/idle. - const session = await this.#lifecycle().ensureLive(id); - this.#attachLiveSession(); - // Steers a mid-turn agent; sends a normal prompt to an idle one. - await session.prompt(trimmed, { streamingBehavior: "steer" }); - } catch (error) { - this.#notice = error instanceof Error ? error.message : String(error); - } - this.#scheduleChatRefresh(); - this.#requestRender(); - })(); - this.#requestRender(); - } - - /** Viewport scrolling for the chat transcript. Returns true when handled. */ - #handleViewerNavigation(keyData: string): boolean { - const maxScroll = this.#lastMaxScroll; - const scrollBy = (delta: number) => { - this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll)); - this.#wasAtBottom = this.#scrollOffset >= maxScroll; - this.#requestRender(); - }; - if (keyData === "j" || matchesSelectDown(keyData)) { - scrollBy(1); - return true; - } - if (keyData === "k" || matchesSelectUp(keyData)) { - scrollBy(-1); - return true; - } - if (matchesKey(keyData, "pageDown")) { - scrollBy(PAGE_SIZE); - return true; - } - if (matchesKey(keyData, "pageUp")) { - scrollBy(-PAGE_SIZE); - return true; - } - if (keyData === "G") { - this.#scrollOffset = maxScroll; - this.#wasAtBottom = true; - this.#requestRender(); - return true; - } - if (keyData === "g") { - this.#scrollOffset = 0; - this.#wasAtBottom = maxScroll === 0; - this.#requestRender(); - return true; - } - return false; - } - - // ======================================================================== - // Transcript assembly — the same components as the main session transcript - // (mirrors UiHelpers.renderSessionContext / addMessageToChat). - // ======================================================================== - - /** Tear down transcript components (sealing pending spinners) and reset build state. */ - #resetChatLog(): void { - for (const pending of this.#chatPendingTools.values()) pending.seal(); - this.#chatPendingTools.clear(); - this.#chatReadArgs.clear(); - this.#chatReadGroup = null; - this.#pendingUsage = undefined; - this.#chatWaitingPoll = null; - this.#chatExpandables = []; - this.#chatLog.dispose(); - this.#chatLog.clear(); - this.#chatEntriesRef = undefined; - this.#chatBuiltCount = 0; - this.#chatPlaceholder = undefined; - } - - /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */ - #syncChatComponents(entries: SessionMessageEntry[]): void { - if (this.#chatEntriesRef !== entries) { - this.#resetChatLog(); - this.#chatEntriesRef = entries; - } - for (let i = this.#chatBuiltCount; i < entries.length; i++) { - this.#appendChatMessage(entries[i].message); - } - this.#chatBuiltCount = entries.length; - // Flush the trailing turn's usage row only once its tools are materialized. - // A read (or any tool) whose toolResult lands in a later debounced sync stays - // pending in #chatReadArgs / #chatPendingTools; flushing now would emit the - // row above it. The sync that drains the maps flushes it below the tools. - if (this.#chatReadArgs.size === 0 && this.#chatPendingTools.size === 0) { - this.#flushPendingUsage(); - } - } - - #trackExpandable(component: { setExpanded(expanded: boolean): void }): void { - component.setExpanded(this.#chatExpanded); - this.#chatExpandables.push(component); - } - - /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */ - #resolveWaitingPoll(nextToolName?: string): void { - const previous = this.#chatWaitingPoll; - if (!previous) return; - this.#chatWaitingPoll = null; - if (nextToolName === "job" && previous.isDisplaceableBlock()) { - this.#chatLog.removeChild(previous); - } - previous.seal(); - } - - #ensureReadGroup(): ReadToolGroupComponent { - if (!this.#chatReadGroup) { - this.#chatReadGroup = new ReadToolGroupComponent({ - showContentPreview: settings.get("read.toolResultPreview"), - }); - this.#trackExpandable(this.#chatReadGroup); - this.#chatLog.addChild(this.#chatReadGroup); - } - return this.#chatReadGroup; - } - - // The per-turn token-usage row must land below the turn's tool blocks, but - // normal `read` calls only materialize their group in #appendToolResult. Defer - // the row: stash it on the assistant message and flush once the turn's tools - // are placed — before the next non-toolResult message and at the end of each - // sync pass — sealing the read run so the row sits under it. - #flushPendingUsage(): void { - if (!this.#pendingUsage) return; - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - this.#chatLog.addChild(createUsageRowBlock(this.#pendingUsage)); - this.#pendingUsage = undefined; - } - - #appendChatMessage(message: AgentMessage): void { - if (message.role !== "toolResult") this.#flushPendingUsage(); - switch (message.role) { - case "assistant": - this.#appendAssistantMessage(message); - break; - case "toolResult": - this.#appendToolResult(message); - break; - case "user": - case "developer": { - // A user prompt closes the poll-displacement window, same as the live path. - if (message.role === "user") this.#resolveWaitingPoll(); - const textContent = - message.role !== "user" - ? "" - : typeof message.content === "string" - ? message.content - : message.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map(block => block.text) - .join(""); - if (textContent) { - const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); - this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic)); - } - break; - } - case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext); - if (message.output) component.appendOutput(message.output); - component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); - this.#chatLog.addChild(component); - break; - } - case "pythonExecution": { - const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext); - if (message.output) component.appendOutput(message.output); - component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); - this.#chatLog.addChild(component); - break; - } - case "hookMessage": - case "custom": - this.#appendCustomMessage(message); - break; - case "compactionSummary": { - const component = new CompactionSummaryMessageComponent(message); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - break; - } - case "branchSummary": { - const component = new BranchSummaryMessageComponent(message); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - break; - } - case "fileMention": { - const block = new TranscriptBlock(); - for (const file of message.files) { - let suffix: string; - if (file.skippedReason === "tooLarge") { - const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; - suffix = `(skipped: ${size})`; - } else { - suffix = file.image - ? "(image)" - : file.lineCount === undefined - ? "(unknown lines)" - : `(${file.lineCount} lines)`; - } - const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( - "accent", - file.path, - )} ${theme.fg("dim", suffix)}`; - block.addChild(new Text(text, 0, 0)); - } - if (block.children.length > 0) this.#chatLog.addChild(block); - break; - } - default: - message satisfies never; - } - } - - #appendAssistantMessage(message: Extract): void { - const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () => - this.#requestRender(), - ); - this.#chatLog.addChild(assistantComponent); - - const hasVisibleAssistantContent = message.content.some( - content => - (content.type === "text" && canonicalizeMessage(content.text)) || - (content.type === "thinking" && canonicalizeMessage(content.thinking)), - ); - if (hasVisibleAssistantContent) { - // New visible turn content closes the current read run (mirrors rebuild). - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - } - - const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); - const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); - const errorMessage = hasErrorStop - ? message.stopReason === "aborted" - ? resolveAbortLabel(message.errorMessage) - : message.errorMessage || "Error" - : null; - - for (const content of message.content) { - if (content.type !== "toolCall") continue; - this.#resolveWaitingPoll(content.name); - - if ( - content.name === "read" && - readArgsHaveTarget(content.arguments) && - !readArgsTargetInternalUrl(content.arguments) - ) { - if (hasErrorStop && errorMessage) { - const group = this.#ensureReadGroup(); - group.updateArgs(content.arguments, content.id); - group.updateResult( - { content: [{ type: "text", text: errorMessage }], isError: true }, - false, - content.id, - ); - } else { - const normalizedArgs = - content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) - ? (content.arguments as Record) - : {}; - this.#chatReadArgs.set(content.id, normalizedArgs); - } - continue; - } - - this.#chatReadGroup?.seal(); - this.#chatReadGroup = null; - const component = new ToolExecutionComponent( - content.name, - content.arguments, - { - // Images can't be sliced through the scroll viewport; keep them off. - showImages: false, - editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), - editAllowFuzzy: settings.get("edit.fuzzyMatch"), - liveRegion: this.#chatLog, - }, - this.#getTool?.(content.name), - this.#ui, - this.#cwd, - content.id, - ); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - - if (hasErrorStop && errorMessage) { - component.updateResult( - { content: [{ type: "text", text: errorMessage }], isError: true }, - false, - content.id, - ); - } else { - this.#chatPendingTools.set(content.id, component); - } - } - - this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined; - } - - #appendToolResult(message: Extract): void { - const pending = this.#chatPendingTools.get(message.toolCallId); - const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent); - if (isReadGroupResult) { - let component = pending; - if (!component) { - const group = this.#ensureReadGroup(); - const args = this.#chatReadArgs.get(message.toolCallId); - if (args) group.updateArgs(args, message.toolCallId); - component = group; - } - component.updateResult(message, false, message.toolCallId); - this.#chatPendingTools.delete(message.toolCallId); - this.#chatReadArgs.delete(message.toolCallId); - return; - } - if (!pending) return; - pending.updateResult(message, false, message.toolCallId); - this.#chatPendingTools.delete(message.toolCallId); - if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) { - this.#chatWaitingPoll = pending; - } - } - - #appendCustomMessage(message: Extract): void { - if (!message.display) return; - if (message.customType === "async-result") { - const details = ( - message as CustomMessage<{ - jobId?: string; - type?: "bash" | "task"; - label?: string; - durationMs?: number; - jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>; - }> - ).details; - const jobs = - details?.jobs && details.jobs.length > 0 - ? details.jobs - : [ - { - jobId: details?.jobId, - type: details?.type, - label: details?.label, - durationMs: details?.durationMs, - }, - ]; - const block = new TranscriptBlock(); - for (const job of jobs) { - const jobId = job.jobId ?? "unknown"; - const typeLabel = job.type ? `[${job.type}]` : "[job]"; - const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; - const line = [ - theme.fg("success", `${theme.status.done} Background job completed`), - theme.fg("dim", typeLabel), - theme.fg("accent", jobId), - duration ? theme.fg("dim", `(${duration})`) : undefined, - ] - .filter(Boolean) - .join(" "); - block.addChild(new Text(line, 1, 0)); - } - this.#chatLog.addChild(block); - return; - } - if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { - const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details; - const component = new LateDiagnosticsMessageComponent(details?.files ?? []); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - return; - } - if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { - this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage)); - return; - } - if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { - const component = new SkillMessageComponent(message as CustomMessage); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - return; - } - if ( - message.customType === "irc:incoming" || - message.customType === "irc:autoreply" || - message.customType === "irc:relay" - ) { - const details = ( - message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }> - ).details; - const kind = - message.customType === "irc:incoming" - ? ("incoming" as const) - : message.customType === "irc:autoreply" - ? ("autoreply" as const) - : ("relay" as const); - const card = createIrcMessageCard( - { - kind, - from: details?.from, - to: details?.to, - body: kind === "incoming" ? details?.message : details?.body, - replyTo: details?.replyTo, - timestamp: message.timestamp, - }, - () => this.#chatExpanded, - theme, - ); - this.#chatLog.addChild(card); - return; - } - if (message.customType === "advisor") { - const details = (message as CustomMessage).details; - this.#chatLog.addChild(createAdvisorMessageCard(details, () => this.#chatExpanded, theme)); - return; - } - if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { - this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); - return; - } - const handoffComponent = createHandoffSummaryMessageComponent( - message as CustomMessage, - this.#chatExpanded, - ); - if (handoffComponent) { - this.#trackExpandable(handoffComponent); - this.#chatLog.addChild(handoffComponent); - return; - } - const component = new CustomMessageComponent( - message as CustomMessage, - this.#getMessageRenderer?.(message.customType), - ); - this.#trackExpandable(component); - this.#chatLog.addChild(component); - } - - #loadTranscript(sessionFile: string): SessionMessageEntry[] | null { - if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) { - this.#transcriptCache = undefined; - } - - const fromByte = this.#transcriptCache?.bytesRead ?? 0; - const result = readFileIncremental(sessionFile, fromByte); - if (!result) { - logger.debug("Agent hub: failed to read session file", { path: sessionFile }); - return this.#transcriptCache?.entries ?? null; - } - - if (result.newSize < fromByte) { - this.#transcriptCache = undefined; - return this.#loadTranscript(sessionFile); - } - - this.#ingestTranscriptChunk(sessionFile, result.text, fromByte); - return this.#transcriptCache?.entries ?? null; - } - - /** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */ - #ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void { - if (!this.#transcriptCache) { - this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] }; - } - if (text.length === 0) return; - const lastNewline = text.lastIndexOf("\n"); - if (lastNewline < 0) return; - const completeChunk = text.slice(0, lastNewline + 1); - const newEntries = parseSessionEntries(completeChunk); - for (const entry of newEntries) { - if (entry.type === "message") { - this.#transcriptCache.entries.push(entry); - // Extract model from first assistant message - const msg = entry.message; - if (!this.#transcriptCache.model && msg.role === "assistant") { - this.#transcriptCache.model = msg.model; - } - } else if (entry.type === "model_change") { - this.#transcriptCache.model = entry.model; - } - } - this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8"); - } - - /** Kick an incremental transcript fetch from the collab host (single-flight). */ - #fetchRemoteTranscript(id: string): void { - const remote = this.#remote; - if (!remote || this.#remoteFetchInFlight) return; - const cacheKey = `remote:${id}`; - if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) { - this.#transcriptCache = undefined; - } - const fromByte = this.#transcriptCache?.bytesRead ?? 0; - this.#remoteFetchInFlight = true; - const token = ++this.#remoteFetchToken; - void remote - .readTranscript(id, fromByte) - .then(result => { - if (token !== this.#remoteFetchToken) return; - this.#remoteFetchInFlight = false; - if (this.#chatAgentId !== id) return; - if (!result) { - if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) { - if (!this.#remoteTranscriptUnavailable) { - this.#remoteTranscriptUnavailable = true; - this.#scheduleChatRefresh(); - } - } - return; - } - if (result.newSize < fromByte) { - // Host transcript truncated/rotated — restart from 0. - this.#transcriptCache = undefined; - this.#fetchRemoteTranscript(id); - return; - } - this.#remoteTranscriptUnavailable = false; - const hadCache = this.#transcriptCache !== undefined; - const before = this.#transcriptCache?.entries.length ?? 0; - this.#ingestTranscriptChunk(cacheKey, result.text, fromByte); - const after = this.#transcriptCache?.entries.length ?? 0; - // Only refresh on new content (or first completed fetch) — an - // unconditional rebuild would re-kick the fetch in a tight loop. - if (after > before || !hadCache) this.#scheduleChatRefresh(); - }) - .catch((error: unknown) => { - if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false; - logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) }); - }); - } -} - -// Sync helper for the render path -function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null { - try { - const stat = fs.statSync(filePath); - if (stat.size <= fromByte) return { text: "", newSize: stat.size }; - const buf = Buffer.alloc(stat.size - fromByte); - const fd = fs.openSync(filePath, "r"); - try { - fs.readSync(fd, buf, 0, buf.length, fromByte); - } finally { - fs.closeSync(fd); - } - return { text: buf.toString("utf-8"), newSize: stat.size }; - } catch { - return null; - } } diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts new file mode 100644 index 000000000..00cf9d5b7 --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -0,0 +1,461 @@ +/** + * Fullscreen transcript viewer. + * + * `AgentHubOverlayComponent.openChat` mounts this as a `fullscreen` overlay + * (`ui.showOverlay(..., { fullscreen: true })`), so it borrows the terminal's + * alternate screen buffer (the vim/less idiom) and paints the whole screen — no + * compositing into the live transcript's scrollback. It renders a parked + * subagent / advisor / collab-guest transcript that has no live in-view session. + * + * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild}) + * rather than synced incrementally, so a growing file-backed transcript (the + * advisor appends while you watch) can never duplicate or misorder rows. Scroll + * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail + * until the reader scrolls up. + * + * Local agents re-read the whole session file whenever its size or mtime changes + * (covering SessionManager's in-place rewrites, not just appends). Collab guests + * keep the incremental byte cursor the host's capped `readTranscript` requires + * and rebuild components from the accumulated entries. + */ +import * as fs from "node:fs"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; +import type { KeyId } from "../../config/keybindings"; +import type { MessageRenderer } from "../../extensibility/extensions/types"; +import type { AgentLifecycleManager } from "../../registry/agent-lifecycle"; +import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry"; +import type { FileEntry, SessionMessageEntry } from "../../session/session-entries"; +import { parseSessionEntries } from "../../session/session-loader"; +import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; +import { getEditorTheme, theme } from "../theme/theme"; +import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import type { AgentHubRemote } from "./agent-hub"; +import { ChatTranscriptBuilder } from "./chat-transcript-builder"; +import { DynamicBorder } from "./dynamic-border"; +import { formatContextUsage } from "./status-line/context-thresholds"; + +export interface AgentTranscriptViewerDeps { + agentId: string; + registry: AgentRegistry; + /** Collab guest: read transcript from the host instead of a local file. */ + remote?: AgentHubRemote; + /** Progress/cost snapshot source for the stats line. */ + observers?: SessionObserverRegistry; + /** Revive+prompt path for messageable local agents. Lazy to avoid touching the global. */ + lifecycle?: () => AgentLifecycleManager; + ui: TUI; + getTool?: (name: string) => AgentTool | undefined; + getMessageRenderer?: (customType: string) => MessageRenderer | undefined; + cwd: string; + hideThinkingBlock?: () => boolean; + expandKeys: KeyId[]; + /** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */ + hubKeys: KeyId[]; + requestRender: () => void; + /** Close just this viewer (Esc), returning to the hub table. */ + onClose: () => void; + /** Close this viewer AND the hub (hub-toggle keys). */ + onHubClose: () => void; +} + +/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */ +const POLL_MS = 250; + +function statusBadge(status: AgentStatus): string { + switch (status) { + case "running": + return theme.fg("success", "running"); + case "idle": + return theme.fg("accent", "idle"); + case "parked": + return theme.fg("muted", "parked"); + case "aborted": + return theme.fg("error", "aborted"); + } +} + +export class AgentTranscriptViewer implements Component { + #builder: ChatTranscriptBuilder; + #scrollView: ScrollView; + #followBottom = true; + #editor: Editor | undefined; + #notice: string | undefined; + #expanded = false; + + // Local file transcript state: re-read when the file size or mtime changes. + #lastSignature = ""; + // Remote transcript state (incremental; the host caps each read). + #remoteEntries: SessionMessageEntry[] = []; + #remoteBytes = 0; + #remoteFetchInFlight = false; + #remoteToken = 0; + #remoteUnavailable = false; + #hasRemoteData = false; + + #model: string | undefined; + #pollTimer: NodeJS.Timeout | undefined; + #disposed = false; + + constructor(private readonly deps: AgentTranscriptViewerDeps) { + this.#builder = new ChatTranscriptBuilder({ + ui: deps.ui, + getTool: deps.getTool, + getMessageRenderer: deps.getMessageRenderer, + cwd: deps.cwd, + hideThinkingBlock: deps.hideThinkingBlock, + requestRender: deps.requestRender, + }); + this.#scrollView = new ScrollView([], { + height: 10, + scrollbar: "auto", + theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) }, + }); + if (this.#sendable) { + this.#editor = new Editor(getEditorTheme()); + this.#editor.setMaxHeight(4); + this.#editor.onSubmit = text => this.#submit(text); + } + this.#refresh(); + this.#pollTimer = setInterval(() => this.#refresh(), POLL_MS); + this.#pollTimer.unref?.(); + } + + /** Advisor transcripts are read-only; everything else may be messaged. */ + get #sendable(): boolean { + const ref = this.deps.registry.get(this.deps.agentId); + if (!ref || ref.kind === "advisor") return false; + return Boolean(this.deps.remote || this.deps.lifecycle); + } + + dispose(): void { + this.#disposed = true; + if (this.#pollTimer) { + clearInterval(this.#pollTimer); + this.#pollTimer = undefined; + } + this.#remoteToken++; + this.#builder.dispose(); + } + + // ======================================================================== + // Transcript loading + // ======================================================================== + + /** Re-read the transcript and rebuild components when it changed. */ + #refresh(): void { + if (this.#disposed) return; + if (this.deps.remote) { + this.#fetchRemote(); + return; + } + const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile; + if (!sessionFile) { + if (this.#lastSignature !== "none") { + this.#lastSignature = "none"; + this.#rebuild([]); + } + return; + } + let signature: string; + try { + const stat = fs.statSync(sessionFile); + // Include the path: a different file with the same size/mtime must not alias. + signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`; + } catch { + // File deleted/rotated while open (e.g. the owning session was dropped): + // clear stale content once instead of freezing on it forever. + if (this.#lastSignature !== "missing") { + this.#lastSignature = "missing"; + this.#model = undefined; + this.#rebuild([]); + } + return; + } + if (signature === this.#lastSignature) return; + let text: string; + try { + text = fs.readFileSync(sessionFile, "utf-8"); + } catch (err) { + // Leave #lastSignature unchanged so a transient read error retries next poll. + logger.debug("transcript viewer: read failed", { err: String(err) }); + return; + } + this.#lastSignature = signature; + this.#model = undefined; + this.#rebuild(this.#extractMessages(parseSessionEntries(text))); + } + + #fetchRemote(): void { + const remote = this.deps.remote; + if (!remote || this.#remoteFetchInFlight) return; + const id = this.deps.agentId; + const fromByte = this.#remoteBytes; + this.#remoteFetchInFlight = true; + const token = ++this.#remoteToken; + void remote + .readTranscript(id, fromByte) + .then(result => { + if (token !== this.#remoteToken || this.#disposed) return; + this.#remoteFetchInFlight = false; + if (!result) { + if (!this.#hasRemoteData && !this.#remoteUnavailable) { + this.#remoteUnavailable = true; + this.deps.requestRender(); + } + return; + } + if (result.newSize < fromByte) { + // Host transcript rotated/truncated — restart from 0. + this.#remoteBytes = 0; + this.#remoteEntries = []; + this.#fetchRemote(); + return; + } + this.#remoteUnavailable = false; + const firstData = !this.#hasRemoteData; + this.#hasRemoteData = true; + const lastNewline = result.text.lastIndexOf("\n"); + if (lastNewline >= 0) { + const completeChunk = result.text.slice(0, lastNewline + 1); + this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8"); + const parsed = this.#extractMessages(parseSessionEntries(completeChunk)); + if (parsed.length > 0) { + this.#remoteEntries.push(...parsed); + this.#rebuild(this.#remoteEntries); + return; + } + } + // First completed fetch (even empty) clears the "Loading…" placeholder. + if (firstData) this.deps.requestRender(); + }) + .catch((error: unknown) => { + if (token === this.#remoteToken) this.#remoteFetchInFlight = false; + logger.warn("transcript viewer: remote fetch failed", { id, error: String(error) }); + }); + } + + /** Filter to message entries, tracking the model from the first assistant / a model_change. */ + #extractMessages(entries: FileEntry[]): SessionMessageEntry[] { + const messages: SessionMessageEntry[] = []; + for (const entry of entries) { + if (entry.type === "message") { + messages.push(entry); + if (!this.#model && entry.message.role === "assistant") this.#model = entry.message.model; + } else if (entry.type === "model_change") { + this.#model = entry.model; + } + } + return messages; + } + + #rebuild(entries: SessionMessageEntry[]): void { + this.#builder.rebuild(entries); + this.deps.requestRender(); + } + + // ======================================================================== + // Input + // ======================================================================== + + handleInput(data: string): void { + if (data.startsWith("\x1b[<")) { + const event = parseSgrMouse(data); + if (event?.wheel != null) { + this.#scrollView.scroll(event.wheel * 3); + this.#syncFollow(); + this.deps.requestRender(); + } + return; + } + + // The hub/observe toggle keys close the whole hub (matches the table view's + // toggle semantics), not just this viewer. + for (const key of this.deps.hubKeys) { + if (matchesKey(data, key)) { + this.deps.onHubClose(); + return; + } + } + + if (matchesKey(data, "escape")) { + if (this.#editor && this.#editor.getText().trim() !== "") { + this.#editor.setText(""); + this.deps.requestRender(); + return; + } + this.deps.onClose(); + return; + } + + for (const key of this.deps.expandKeys) { + if (matchesKey(data, key)) { + this.#expanded = !this.#expanded; + this.#builder.setExpanded(this.#expanded); + this.deps.requestRender(); + return; + } + } + + // Once the reader starts typing a message, the editor owns every key. + const editorEmpty = !this.#editor || this.#editor.getText().trim() === ""; + if (editorEmpty && this.#handleScroll(data)) return; + + if (this.#editor) { + this.#editor.handleInput(data); + this.deps.requestRender(); + } + } + + /** Returns true when the key was a scroll command. ScrollView owns the offset. */ + #handleScroll(data: string): boolean { + if (this.#scrollView.handleScrollKey(data)) { + this.#syncFollow(); + this.deps.requestRender(); + return true; + } + if (data === "j" || matchesSelectDown(data)) { + this.#scrollView.scroll(1); + } else if (data === "k" || matchesSelectUp(data)) { + this.#scrollView.scroll(-1); + } else if (data === "g") { + this.#scrollView.scrollToTop(); + } else if (data === "G") { + this.#scrollView.scrollToBottom(); + } else { + return false; + } + this.#syncFollow(); + this.deps.requestRender(); + return true; + } + + #syncFollow(): void { + this.#followBottom = this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset(); + } + + #submit(text: string): void { + const trimmed = text.trim(); + this.#editor?.setText(""); + if (!trimmed) return; + this.#notice = undefined; + const id = this.deps.agentId; + if (this.deps.remote) { + this.deps.remote.chat(id, trimmed); + this.deps.requestRender(); + return; + } + const lifecycle = this.deps.lifecycle; + if (!lifecycle) return; + void (async () => { + try { + // Revives a parked agent; returns the live session for running/idle. + const session = await lifecycle().ensureLive(id); + // Steers a mid-turn agent; sends a normal prompt to an idle one. + await session.prompt(trimmed, { streamingBehavior: "steer" }); + } catch (error) { + this.#notice = error instanceof Error ? error.message : String(error); + } + this.deps.requestRender(); + })(); + this.deps.requestRender(); + } + + // ======================================================================== + // Render + // ======================================================================== + + render(width: number): readonly string[] { + const termHeight = process.stdout.rows || 40; + // `innerWidth` widths the editor/notice chrome (gutter-prefixed below). + // `contentWidth` widths the transcript: ScrollView reserves the last column + // for the scrollbar, and the transcript components carry their own 1-col left + // gutter — so body rows are emitted WITHOUT an extra outer space, sharing that + // gutter with the header/footer (which add one). Stacking both shifted the body + // one column right of the title. + const innerWidth = Math.max(20, width - 2); + const contentWidth = Math.max(1, width - 1); + const ref = this.deps.registry.get(this.deps.agentId); + + const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId); + const footerLines = this.#footerLines(); + const noticeLine = this.#notice ? ` ${theme.fg("error", this.#notice)}` : undefined; + const editorLines = this.#editor ? this.#editor.render(innerWidth) : []; + + // Chrome: top border + header rows + divider border + (notice) + editor + footer + bottom border. + const chrome = headerLines.length + 2 + editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1; + const viewportHeight = Math.max(3, termHeight - chrome); + + const contentLines = this.#builder.isEmpty + ? [` ${theme.fg("dim", this.#placeholder())}`] + : this.#builder.container.render(contentWidth); + this.#scrollView.setLines(contentLines); + this.#scrollView.setHeight(viewportHeight); + if (this.#followBottom) this.#scrollView.scrollToBottom(); + + const lines: string[] = []; + lines.push(...new DynamicBorder().render(width)); + for (const headerLine of headerLines) lines.push(` ${headerLine}`); + lines.push(...new DynamicBorder().render(width)); + for (const row of this.#scrollView.render(width)) lines.push(row); + if (noticeLine) lines.push(noticeLine); + for (const editorLine of editorLines) lines.push(` ${editorLine}`); + lines.push(...footerLines); + lines.push(...new DynamicBorder().render(width)); + return lines; + } + + #headerLines(status: AgentStatus | undefined, kind: string | undefined, parentId: string | undefined): string[] { + const lines = [theme.fg("accent", `Agent Hub ${theme.sep.dot} ${this.deps.agentId}`)]; + if (status && kind) { + const kindTag = theme.fg("dim", ` ${parentId ? `${kind} ${theme.sep.dot} of ${parentId}` : kind}`); + const modelLabel = this.#model ? theme.fg("muted", `${theme.sep.dot}${this.#model}`) : ""; + lines.push(`${theme.bold(this.deps.agentId)} ${statusBadge(status)}${kindTag}${modelLabel}`); + } + return lines; + } + + #footerLines(): string[] { + const lines: string[] = []; + const statsLine = this.#statsLine(); + if (statsLine) lines.push(` ${statsLine}`); + const hint = this.#editor + ? `Enter:send Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand empty input → j/k:scroll g/G:top/bottom` + : `Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand j/k:scroll g/G:top/bottom`; + lines.push(` ${theme.fg("dim", hint)}`); + return lines; + } + + #statsLine(): string { + const observed: ObservableSession | undefined = this.deps.observers + ?.getSessions() + .find(s => s.id === this.deps.agentId); + const progress = observed?.progress; + if (!progress) return ""; + const stats: string[] = []; + if (progress.contextTokens && progress.contextTokens > 0) { + stats.push( + progress.contextWindow && progress.contextWindow > 0 + ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow) + : formatNumber(progress.contextTokens), + ); + } + if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs)); + const parts: string[] = []; + if (stats.length > 0 || progress.toolCount > 0) { + const toolStat = + progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : ""; + parts.push(theme.fg("dim", [toolStat, ...stats].filter(Boolean).join(theme.sep.dot))); + } + if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); + return parts.join(theme.sep.dot); + } + + #placeholder(): string { + if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available."; + if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…"; + if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet."; + return "No messages yet."; + } +} diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index 5a351750e..031b612d3 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -5,6 +5,7 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages"; import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils"; import { canonicalizeMessage } from "../../utils/thinking-display"; +import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker"; /** * Max lines of a turn-ending provider error rendered inline in the transcript. @@ -15,6 +16,59 @@ import { canonicalizeMessage } from "../../utils/thinking-display"; */ const MAX_TRANSCRIPT_ERROR_LINES = 8; +/** + * A GFM table delimiter row (`| --- | :--: |`, with or without bounding pipes). + * The header row alone does not render a table — this delimiter is what makes + * Markdown lay one out, and a streaming table re-aligns its columns as rows + * arrive. Requires at least one column pipe so a bare thematic break (`---`) + * does not match. + */ +const MARKDOWN_TABLE_DELIMITER = /^ {0,3}\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-*:?[ \t]*$/; + +/** Opening or closing fence of a code block: ≥3 backticks/tildes plus info string. */ +const CODE_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/; + +/** + * Whether `text` currently contains reflowing Markdown whose layout is not yet + * permanent: an open ` ```mermaid ` fence (the diagram reshapes as source + * arrives) or a GFM table (columns re-align as rows arrive). Used by + * {@link AssistantMessageComponent.isTranscriptBlockCommitStable}. + * + * Fence-aware: a mermaid block is detected by its opener, and table delimiters + * inside ordinary fenced code (shell pipes, ASCII separators, doc examples) are + * ignored so a long streamed code block is never held out of native scrollback. + * A delimiter counts only directly under a pipe-bearing header row, outside any + * code fence. + */ +function detectLiveReflowingMarkdown(text: string): boolean { + let fence: string | null = null; + let prevLine = ""; + for (const line of text.split("\n")) { + const fenceMatch = CODE_FENCE_LINE.exec(line); + if (fence !== null) { + // Inside a code block: only a bare matching closing fence ends it. + if ( + fenceMatch && + fenceMatch[2]!.trim() === "" && + fenceMatch[1]![0] === fence[0] && + fenceMatch[1]!.length >= fence.length + ) { + fence = null; + } + continue; + } + if (fenceMatch) { + if (/^mermaid\b/.test(fenceMatch[2]!.trim())) return true; + fence = fenceMatch[1]!; + prevLine = ""; + continue; + } + if (prevLine.includes("|") && MARKDOWN_TABLE_DELIMITER.test(line)) return true; + prevLine = line; + } + return false; +} + /** * Frames for the streaming "thinking" pulse rendered in place of a hidden * thinking block while the model is still producing it. A single fixed-width @@ -29,11 +83,21 @@ const THINKING_DOTS_FRAME_MS = 320; */ export class AssistantMessageComponent extends Container { #contentContainer: Container; + #markerSlot: Container; #lastMessage?: AssistantMessage; #toolImagesByCallId = new Map(); #convertedKittyImages = new Map(); #kittyConversionsInFlight = new Set(); #transcriptBlockFinalized: boolean; + /** + * True while a non-finalized text item carries reflowing Markdown — a + * ` ```mermaid ` fence or a GFM table — whose layout re-flows every frame as + * source arrives (a diagram reshaping, a table re-aligning its columns), so + * no prefix is byte-stable until the message finalizes. See + * {@link isTranscriptBlockCommitStable}. Recomputed in {@link updateContent} + * ahead of the fast-path return, so it tracks every stream tick. + */ + #hasLiveReflowingMarkdown = false; /** * When true, the turn-ending `Error: …` line for `stopReason === "error"` is * suppressed because the same error is currently shown in the pinned banner @@ -75,6 +139,11 @@ export class AssistantMessageComponent extends Container { super(); this.#transcriptBlockFinalized = message !== undefined; + // Slim cache-invalidation divider, populated above the content when this + // turn's request lost the prompt cache (see setCacheInvalidation). + this.#markerSlot = new Container(); + this.addChild(this.#markerSlot); + // Container for text/thinking content this.#contentContainer = new Container(); this.addChild(this.#contentContainer); @@ -84,6 +153,20 @@ export class AssistantMessageComponent extends Container { } } + /** + * Show or clear the slim cache-invalidation divider above this turn. Set at + * `message_end` (live) or during rebuild, once the turn's usage is known and + * compared against the previous turn's cache footprint. Bumps the transcript + * block version so the change repaints even after content finalized. + */ + setCacheInvalidation(info: CacheInvalidation | undefined): void { + this.#markerSlot.clear(); + if (info) { + this.#markerSlot.addChild(new CacheInvalidationMarkerComponent(info)); + } + this.#blockVersion++; + } + override invalidate(): void { super.invalidate(); // Theme/symbol changes arrive via invalidate(). Fast-path children captured @@ -171,6 +254,21 @@ export class AssistantMessageComponent extends Container { return this.#transcriptBlockFinalized; } + /** + * Whether this still-live block's scrolled-off rows may be committed to + * immutable native scrollback (the {@link TranscriptContainer} durable- + * snapshot path). Reflowing Markdown — a streaming mermaid diagram or a GFM + * table — re-lays-out its body as source arrives (the diagram reshapes, the + * table re-aligns its columns), so committing an intermediate layout strands + * a stale fragment in native scrollback that only a full repaint (Ctrl+L) can + * clear. While such content is still streaming the block therefore stays + * wholly in the repaintable live region and commits once, at its final + * layout, when the turn finalizes. + */ + isTranscriptBlockCommitStable(): boolean { + return this.#transcriptBlockFinalized || !this.#hasLiveReflowingMarkdown; + } + getTranscriptBlockVersion(): number { return this.#blockVersion; } @@ -397,6 +495,15 @@ export class AssistantMessageComponent extends Container { this.#lastMessage = message; this.#lastUpdateTransient = opts?.transient === true; + // Streaming reflowing Markdown (a mermaid diagram reshaping, a GFM table + // re-aligning columns) re-lays-out its body each frame; see + // isTranscriptBlockCommitStable. Detect it from raw text — a Markdown + // parser only resolves these once the closing fence / delimiter row + // arrives, but the stale native-scrollback commits happen mid-stream. + this.#hasLiveReflowingMarkdown = message.content.some( + content => content.type === "text" && detectLiveReflowingMarkdown(content.text), + ); + // Fast path: reuse Markdown children when shape is stable during streaming if (this.#tryFastPathUpdate(message)) return; diff --git a/packages/coding-agent/src/modes/components/branch-summary-message.ts b/packages/coding-agent/src/modes/components/branch-summary-message.ts deleted file mode 100644 index 80ee3515e..000000000 --- a/packages/coding-agent/src/modes/components/branch-summary-message.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { BranchSummaryMessage } from "../../session/messages"; - -/** - * Component that renders a branch summary message with collapsed/expanded state. - * Uses same background color as hook messages for visual consistency. - */ -export class BranchSummaryMessageComponent extends Box { - #expanded = false; - - constructor(private readonly message: BranchSummaryMessage) { - super(1, 1, t => theme.bg("customMessageBg", t)); - this.setIgnoreTight(true); - this.#updateDisplay(); - } - - setExpanded(expanded: boolean): void { - this.#expanded = expanded; - this.#updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); - this.#updateDisplay(); - } - - #updateDisplay(): void { - this.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[branch]")); - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); - - if (this.#expanded) { - const header = "**Branch Summary**\n\n"; - this.addChild( - new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("customMessageText", text), - }), - ); - } else { - this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0)); - } - } -} diff --git a/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts b/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts new file mode 100644 index 000000000..daba03a02 --- /dev/null +++ b/packages/coding-agent/src/modes/components/cache-invalidation-marker.ts @@ -0,0 +1,94 @@ +import type { Usage } from "@oh-my-pi/pi-ai"; +import type { Component } from "@oh-my-pi/pi-tui"; +import { formatNumber } from "@oh-my-pi/pi-utils"; +import { theme } from "../../modes/theme/theme"; + +/** + * Minimum cached prefix (read + write) the previous turn must have established + * before a collapse on the current turn counts as an invalidation. Filters out + * tiny contexts and providers below the cacheable-prefix floor, where a zero + * `cacheRead` is expected rather than a reset. + */ +const MIN_CACHE_FOOTPRINT = 2048; + +/** A prompt-cache invalidation detected from a turn's usage. */ +export interface CacheInvalidation { + /** Prompt tokens the cold turn had to (re)process instead of reading from cache. */ + reprocessedTokens: number; +} + +/** + * Decide whether `current` turn lost the prompt cache that `prev` established. + * + * The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/ + * system-prompt change (or a history rewrite) breaks the prefix, so the next + * request reads nothing from cache and re-pays for the whole prompt. We detect + * that as: the previous turn cached a meaningful prefix, yet this turn's + * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt. + * Returns `undefined` (no marker) for the first turn, tiny contexts, turns + * that reused any cache, and — crucially — turns on providers with *implicit* + * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic / + * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite > + * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and + * drop `cacheRead` to zero intermittently as routine propagation noise that + * self-heals the next turn, so flagging it would be a false positive. + */ +export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined { + if (!prev) return undefined; + const prevFootprint = prev.cacheRead + prev.cacheWrite; + if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined; + // Any cache reuse this turn means the prefix survived (at least partly). + if (current.cacheRead > 0) return undefined; + // Only an explicit, prefix-controlled cache re-creates the prefix on a cold + // turn — Anthropic/Bedrock report that as `cacheWrite`. Implicit best-effort + // caches (Google/OpenAI/Fireworks) report `cacheWrite: 0` and drop `cacheRead` + // to zero intermittently as propagation noise, not a real invalidation. + if (current.cacheWrite <= 0) return undefined; + const reprocessedTokens = current.cacheWrite + current.input; + if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined; + return { reprocessedTokens }; +} + +const CACHE_INVALIDATION_RULE_WIDTH = 10; + +/** + * Slim left-aligned divider rendered above an assistant turn whose request lost + * the prompt cache. Mirrors the compaction divider's banner styling but spans + * only a short rule plus label (not the full width) and carries no expandable + * detail: + * + * ────────── ⊘ cache miss · 50.9k tokens + */ +export class CacheInvalidationMarkerComponent implements Component { + #cache?: { width: number; lines: string[] }; + + constructor(private readonly info: CacheInvalidation) {} + + invalidate(): void { + this.#cache = undefined; + } + + render(width: number): readonly string[] { + width = Math.max(1, width); + if (this.#cache?.width === width) { + return this.#cache.lines; + } + const lines = ["", this.#divider(width), ""]; + this.#cache = { width, lines }; + return lines; + } + + #divider(width: number): string { + const icon = theme.icon.cacheMiss; + const head = icon ? `${icon} cache miss` : "cache miss"; + const tokens = this.info.reprocessedTokens; + const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head; + const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false }); + const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1); + if (ruleWidth < 1) { + // Too narrow to frame — emit the bare label. + return theme.fg("muted", label); + } + return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`; + } +} diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts new file mode 100644 index 000000000..513629dc0 --- /dev/null +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -0,0 +1,476 @@ +/** + * Builds transcript components from persisted session message entries — the + * file/remote-backed counterpart to {@link UiHelpers.addMessageToChat} (which is + * bound to the live InteractiveModeContext). Used by the fullscreen transcript + * viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor / + * collab-guest transcript that has no live session. + * + * Unlike the old incremental hub sync, {@link ChatTranscriptBuilder.rebuild} + * always discards prior components and rebuilds the whole transcript from the + * supplied entries. Re-rendering a growing transcript is therefore O(n) in the + * entry count, but it cannot duplicate or misorder rows the way incremental + * component reuse could. + */ +import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { Usage } from "@oh-my-pi/pi-ai"; +import { Text, type TUI } from "@oh-my-pi/pi-tui"; +import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils"; +import type { AdvisorMessageDetails } from "../../advisor"; +import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; +import { settings } from "../../config/settings"; +import type { MessageRenderer } from "../../extensibility/extensions/types"; +import { + BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, + type CustomMessage, + isSilentAbort, + LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, + resolveAbortLabel, + SKILL_PROMPT_MESSAGE_TYPE, + type SkillPromptDetails, +} from "../../session/messages"; +import type { SessionMessageEntry } from "../../session/session-entries"; +import { createIrcMessageCard } from "../../tools/irc"; +import { canonicalizeMessage } from "../../utils/thinking-display"; +import { theme } from "../theme/theme"; +import { createAdvisorMessageCard } from "./advisor-message"; +import { AssistantMessageComponent } from "./assistant-message"; +import { createBackgroundTanDispatchBlock } from "./background-tan-message"; +import { BashExecutionComponent } from "./bash-execution"; +import { detectCacheInvalidation } from "./cache-invalidation-marker"; +import { CollabPromptMessageComponent } from "./collab-prompt-message"; +import { + BranchSummaryMessageComponent, + CompactionSummaryMessageComponent, + createHandoffSummaryMessageComponent, +} from "./compaction-summary-message"; +import { CustomMessageComponent } from "./custom-message"; +import { EvalExecutionComponent } from "./eval-execution"; +import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; +import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group"; +import { SkillMessageComponent } from "./skill-message"; +import { ToolExecutionComponent } from "./tool-execution"; +import { TranscriptBlock, TranscriptContainer } from "./transcript-container"; +import { createUsageRowBlock } from "./usage-row"; +import { UserMessageComponent } from "./user-message"; + +export interface ChatTranscriptBuilderDeps { + ui: TUI; + getTool?: (name: string) => AgentTool | undefined; + getMessageRenderer?: (customType: string) => MessageRenderer | undefined; + cwd: string; + hideThinkingBlock?: () => boolean; + requestRender: () => void; +} + +/** Extracts the plain-text content of a user message (string or text blocks). */ +function userMessageText(message: Extract): string { + if (typeof message.content === "string") return message.content; + return message.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join(""); +} + +export class ChatTranscriptBuilder { + readonly container = new TranscriptContainer(); + #pendingTools = new Map(); + #readArgs = new Map>(); + #readGroup: ReadToolGroupComponent | null = null; + #pendingUsage: Usage | undefined; + #lastAssistantUsage: Usage | undefined; + #waitingPoll: ToolExecutionComponent | null = null; + #expandables: Array<{ setExpanded(expanded: boolean): void }> = []; + #expanded = false; + + constructor(private readonly deps: ChatTranscriptBuilderDeps) {} + + /** Whether the transcript currently holds any rendered rows. */ + get isEmpty(): boolean { + return this.container.children.length === 0; + } + + /** Discard all components and rebuild the whole transcript from `entries`. */ + rebuild(entries: SessionMessageEntry[]): void { + this.reset(); + for (const entry of entries) this.#appendChatMessage(entry.message); + // Flush the trailing turn's usage row only once its tools are materialized + // (a read whose result has not arrived stays pending); otherwise the row + // would sit above its tools. The drain happens here at the end of the pass. + if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage(); + } + + /** Toggle tool-output expansion across every expandable component. */ + setExpanded(expanded: boolean): void { + this.#expanded = expanded; + for (const component of this.#expandables) component.setExpanded(expanded); + } + + get expanded(): boolean { + return this.#expanded; + } + + /** Tear down components (sealing pending spinners) and clear build state. */ + reset(): void { + for (const pending of this.#pendingTools.values()) pending.seal(); + this.#pendingTools.clear(); + this.#readArgs.clear(); + this.#readGroup = null; + this.#pendingUsage = undefined; + this.#lastAssistantUsage = undefined; + this.#waitingPoll = null; + this.#expandables = []; + this.container.dispose(); + this.container.clear(); + } + + dispose(): void { + this.reset(); + } + + #trackExpandable(component: { setExpanded(expanded: boolean): void }): void { + component.setExpanded(this.#expanded); + this.#expandables.push(component); + } + + /** A `job` poll showing all-running is displaced by the next `job` call. */ + #resolveWaitingPoll(nextToolName?: string): void { + const previous = this.#waitingPoll; + if (!previous) return; + this.#waitingPoll = null; + if (nextToolName === "job" && previous.isDisplaceableBlock()) { + this.container.removeChild(previous); + } + previous.seal(); + } + + #ensureReadGroup(): ReadToolGroupComponent { + if (!this.#readGroup) { + this.#readGroup = new ReadToolGroupComponent({ + showContentPreview: settings.get("read.toolResultPreview"), + }); + this.#trackExpandable(this.#readGroup); + this.container.addChild(this.#readGroup); + } + return this.#readGroup; + } + + // The per-turn token-usage row must land below the turn's tool blocks, but + // normal `read` calls only materialize their group in #appendToolResult. Defer + // the row: stash it on the assistant message and flush once the turn's tools + // are placed, sealing the read run so the row sits under it. + #flushPendingUsage(): void { + if (!this.#pendingUsage) return; + this.#readGroup?.seal(); + this.#readGroup = null; + this.container.addChild(createUsageRowBlock(this.#pendingUsage)); + this.#pendingUsage = undefined; + } + + #appendChatMessage(message: AgentMessage): void { + if (message.role !== "toolResult") this.#flushPendingUsage(); + switch (message.role) { + case "assistant": + this.#appendAssistantMessage(message); + break; + case "toolResult": + this.#appendToolResult(message); + break; + case "user": + case "developer": { + // A user prompt closes the poll-displacement window, same as the live path. + if (message.role === "user") this.#resolveWaitingPoll(); + const textContent = message.role === "user" ? userMessageText(message) : ""; + if (textContent) { + const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); + this.container.addChild(new UserMessageComponent(textContent, isSynthetic)); + } + break; + } + case "bashExecution": { + const component = new BashExecutionComponent(message.command, this.deps.ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.container.addChild(component); + break; + } + case "pythonExecution": { + const component = new EvalExecutionComponent(message.code, this.deps.ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.container.addChild(component); + break; + } + case "hookMessage": + case "custom": + this.#appendCustomMessage(message); + break; + case "compactionSummary": { + const component = new CompactionSummaryMessageComponent(message); + this.#trackExpandable(component); + this.container.addChild(component); + break; + } + case "branchSummary": { + const component = new BranchSummaryMessageComponent(message); + this.#trackExpandable(component); + this.container.addChild(component); + break; + } + case "fileMention": { + const block = new TranscriptBlock(); + for (const file of message.files) { + let suffix: string; + if (file.skippedReason === "tooLarge") { + const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; + suffix = `(skipped: ${size})`; + } else { + suffix = file.image + ? "(image)" + : file.lineCount === undefined + ? "(unknown lines)" + : `(${file.lineCount} lines)`; + } + const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( + "accent", + file.path, + )} ${theme.fg("dim", suffix)}`; + // Indent one column to match the transcript's other rows (the viewer renders + // body rows without an outer gutter; rows own their left pad). + block.addChild(new Text(text, 1, 0)); + } + if (block.children.length > 0) this.container.addChild(block); + break; + } + default: + message satisfies never; + } + } + + #appendAssistantMessage(message: Extract): void { + const assistantComponent = new AssistantMessageComponent(message, this.deps.hideThinkingBlock?.() ?? false, () => + this.deps.requestRender(), + ); + this.container.addChild(assistantComponent); + + if (settings.get("display.cacheMissMarker")) { + const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage); + if (invalidation) assistantComponent.setCacheInvalidation(invalidation); + } + if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) { + this.#lastAssistantUsage = message.usage; + } + + const hasVisibleAssistantContent = message.content.some( + content => + (content.type === "text" && canonicalizeMessage(content.text)) || + (content.type === "thinking" && canonicalizeMessage(content.thinking)), + ); + if (hasVisibleAssistantContent) { + // New visible turn content closes the current read run (mirrors rebuild). + this.#readGroup?.seal(); + this.#readGroup = null; + } + + const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); + const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); + const errorMessage = hasErrorStop + ? message.stopReason === "aborted" + ? resolveAbortLabel(message.errorMessage) + : message.errorMessage || "Error" + : null; + + for (const content of message.content) { + if (content.type !== "toolCall") continue; + this.#resolveWaitingPoll(content.name); + + if ( + content.name === "read" && + readArgsHaveTarget(content.arguments) && + !readArgsTargetInternalUrl(content.arguments) + ) { + if (hasErrorStop && errorMessage) { + const group = this.#ensureReadGroup(); + group.updateArgs(content.arguments, content.id); + group.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + const normalizedArgs = + content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) + ? (content.arguments as Record) + : {}; + this.#readArgs.set(content.id, normalizedArgs); + } + continue; + } + + this.#readGroup?.seal(); + this.#readGroup = null; + const component = new ToolExecutionComponent( + content.name, + content.arguments, + { + // Images can't be sliced through the scroll viewport; keep them off. + showImages: false, + editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), + editAllowFuzzy: settings.get("edit.fuzzyMatch"), + liveRegion: this.container, + }, + this.deps.getTool?.(content.name), + this.deps.ui, + this.deps.cwd, + content.id, + ); + this.#trackExpandable(component); + this.container.addChild(component); + + if (hasErrorStop && errorMessage) { + component.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + this.#pendingTools.set(content.id, component); + } + } + + this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined; + } + + #appendToolResult(message: Extract): void { + const pending = this.#pendingTools.get(message.toolCallId); + const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent); + if (isReadGroupResult) { + let component = pending; + if (!component) { + const group = this.#ensureReadGroup(); + const args = this.#readArgs.get(message.toolCallId); + if (args) group.updateArgs(args, message.toolCallId); + component = group; + } + component.updateResult(message, false, message.toolCallId); + this.#pendingTools.delete(message.toolCallId); + this.#readArgs.delete(message.toolCallId); + return; + } + if (!pending) return; + pending.updateResult(message, false, message.toolCallId); + this.#pendingTools.delete(message.toolCallId); + if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) { + this.#waitingPoll = pending; + } + } + + #appendCustomMessage(message: Extract): void { + if (!message.display) return; + if (message.customType === "async-result") { + const details = ( + message as CustomMessage<{ + jobId?: string; + type?: "bash" | "task"; + label?: string; + durationMs?: number; + jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>; + }> + ).details; + const jobs = + details?.jobs && details.jobs.length > 0 + ? details.jobs + : [ + { + jobId: details?.jobId, + type: details?.type, + label: details?.label, + durationMs: details?.durationMs, + }, + ]; + const block = new TranscriptBlock(); + for (const job of jobs) { + const jobId = job.jobId ?? "unknown"; + const typeLabel = job.type ? `[${job.type}]` : "[job]"; + const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; + const line = [ + theme.fg("success", `${theme.status.done} Background job completed`), + theme.fg("dim", typeLabel), + theme.fg("accent", jobId), + duration ? theme.fg("dim", `(${duration})`) : undefined, + ] + .filter(Boolean) + .join(" "); + block.addChild(new Text(line, 1, 0)); + } + this.container.addChild(block); + return; + } + if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { + const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details; + const component = new LateDiagnosticsMessageComponent(details?.files ?? []); + this.#trackExpandable(component); + this.container.addChild(component); + return; + } + if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { + this.container.addChild(new CollabPromptMessageComponent(message as CustomMessage)); + return; + } + if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { + const component = new SkillMessageComponent(message as CustomMessage); + this.#trackExpandable(component); + this.container.addChild(component); + return; + } + if ( + message.customType === "irc:incoming" || + message.customType === "irc:autoreply" || + message.customType === "irc:relay" + ) { + const details = ( + message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }> + ).details; + const kind = + message.customType === "irc:incoming" + ? ("incoming" as const) + : message.customType === "irc:autoreply" + ? ("autoreply" as const) + : ("relay" as const); + const card = createIrcMessageCard( + { + kind, + from: details?.from, + to: details?.to, + body: kind === "incoming" ? details?.message : details?.body, + replyTo: details?.replyTo, + timestamp: message.timestamp, + }, + () => this.#expanded, + theme, + ); + this.container.addChild(card); + return; + } + if (message.customType === "advisor") { + const details = (message as CustomMessage).details; + this.container.addChild(createAdvisorMessageCard(details, () => this.#expanded, theme)); + return; + } + if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { + this.container.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); + return; + } + const handoffComponent = createHandoffSummaryMessageComponent(message as CustomMessage, this.#expanded); + if (handoffComponent) { + this.#trackExpandable(handoffComponent); + this.container.addChild(handoffComponent); + return; + } + const component = new CustomMessageComponent( + message as CustomMessage, + this.deps.getMessageRenderer?.(message.customType), + ); + this.#trackExpandable(component); + this.container.addChild(component); + } +} diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts index 9bbfed793..04aeb9ece 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -1,6 +1,6 @@ import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages"; +import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "../../session/messages"; interface SummaryDividerOptions { label: () => string; @@ -156,6 +156,34 @@ export function createHandoffSummaryMessageComponent( return component; } +/** + * A branch summary collapses a side branch back into the main line. Render it + * with the same slim divider as `/compact` and handoff rather than a `[branch]` + * box, so every history-collapse point reads as one consistent banner. + */ +export class BranchSummaryMessageComponent implements Component { + #divider: SummaryDividerComponent; + + constructor(private readonly message: BranchSummaryMessage) { + this.#divider = new SummaryDividerComponent({ + label: () => `${theme.icon.branch} branch`, + detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`, + }); + } + + setExpanded(expanded: boolean): void { + this.#divider.setExpanded(expanded); + } + + invalidate(): void { + this.#divider.invalidate(); + } + + render(width: number): readonly string[] { + return this.#divider.render(width); + } +} + function getCustomMessageText(message: CustomMessage): string { if (typeof message.content === "string") return message.content; let firstText: string | undefined; diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 67f699190..df2630fbe 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container { } this.removeChild(this.#box); + // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here: + // tag hooks with the hook glyph, other injected messages with a neutral package. + const isHook = (this.message.role as string) === "hookMessage"; const custom = renderFramedMessage({ message: this.message, box: this.#box, expanded: this.#expanded, customRenderer: this.customRenderer, - // Extension messages render full content; no collapse-on-fold behaviour. + icon: isHook ? theme.icon.extensionHook : theme.icon.package, }); if (custom) { diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 33d1c161f..bccbbe874 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,4 +1,4 @@ -import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils"; import * as Diff from "diff"; import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme"; import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils"; @@ -13,12 +13,12 @@ const DIM_OFF = "\x1b[22m"; * before the first non-whitespace character; remaining tabs in code * content are replaced with spaces (like replaceTabs). */ -function visualizeIndent(text: string, filePath?: string): string { +function visualizeIndent(text: string): string { const match = text.match(/^([ \t]+)/); - if (!match) return replaceTabs(text, filePath); + if (!match) return replaceTabs(text); const indent = match[1]; const rest = text.slice(indent.length); - const tabWidth = getIndentation(filePath); + const tabWidth = DEFAULT_TAB_WIDTH; const leftPadding = Math.floor(tabWidth / 2); const rightPadding = Math.max(0, tabWidth - leftPadding - 1); const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`; @@ -30,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string { visible += `${DIM}·${DIM_OFF}`; } } - return `${visible}${replaceTabs(rest, filePath)}`; + return `${visible}${replaceTabs(rest)}`; } /** @@ -153,7 +153,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s // unicode ellipsis. const trimmed = line.trim(); const isGapRow = trimmed.length === 0 || trimmed === "..." || trimmed === "…"; - result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line, options.filePath))); + result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line))); i++; continue; } @@ -184,47 +184,24 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s replaceTabs(added.content), ); - result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)), - ), - ); - result.push( - theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))), - ); + result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine)))); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine)))); } else { for (const removed of removedLines) { result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)), - ), + theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))), ); } for (const added of addedLines) { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content)))); } } } else if (parsed.prefix === "+") { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content)))); i++; } else { const highlighted = contextHighlights.get(i); - const content = - highlighted !== undefined - ? replaceTabs(highlighted, options.filePath) - : visualizeIndent(parsed.content, options.filePath); + const content = highlighted !== undefined ? replaceTabs(highlighted) : visualizeIndent(parsed.content); result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, content))); i++; } diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index 17dd0adf0..fb70166eb 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -26,7 +26,7 @@ export class DynamicBorder implements Component { if (this.#cachedLines && this.#cachedWidth === width) { return this.#cachedLines; } - const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))]; + const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))]; this.#cachedWidth = width; this.#cachedLines = lines; return lines; diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 0b94d6018..b2dc63640 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -380,7 +380,7 @@ class TwoColumnBody implements Component { // Fill the full body height so the dashboard reads as a full-screen view. const numLines = this.maxHeight; const combined: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `); + const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); for (let i = 0; i < numLines; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); diff --git a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts index ed2185e6c..4211d719b 100644 --- a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts @@ -107,7 +107,7 @@ export class InspectorPanel implements Component { #renderFilePreview(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Preview:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); const content = this.#getContextFileContent(raw); if (!content) { @@ -165,7 +165,7 @@ export class InspectorPanel implements Component { #renderToolArgs(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Arguments:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const tool = raw as any; @@ -207,7 +207,7 @@ export class InspectorPanel implements Component { #renderSkillContent(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Instruction:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const skill = raw as any; @@ -236,7 +236,7 @@ export class InspectorPanel implements Component { #renderMcpDetails(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Connection:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); try { const mcp = raw as any; @@ -275,7 +275,7 @@ export class InspectorPanel implements Component { // Show trigger pattern if present if (ext.trigger) { lines.push(theme.fg("muted", "Trigger:")); - lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); + lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40)))); lines.push(` ${theme.fg("accent", ext.trigger)}`); lines.push(""); } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index d79a61bda..600907290 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -123,7 +123,7 @@ class OutlinedList extends Container { render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); - const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width))); + const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); const content: string[] = []; for (const line of this.#lines) { @@ -134,7 +134,7 @@ class OutlinedList extends Container { const wrappedLine = `${indent}${wrappedBody}`; const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine)); content.push( - `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`, + `${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`, ); } } diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 67de87b26..fd4c8bd88 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -2,7 +2,6 @@ export * from "./assistant-message"; export * from "./bash-execution"; export * from "./bordered-loader"; -export * from "./branch-summary-message"; export * from "./compaction-summary-message"; export * from "./countdown-timer"; export * from "./custom-editor"; diff --git a/packages/coding-agent/src/modes/components/message-frame.ts b/packages/coding-agent/src/modes/components/message-frame.ts index 57157cfe4..d1893d792 100644 --- a/packages/coding-agent/src/modes/components/message-frame.ts +++ b/packages/coding-agent/src/modes/components/message-frame.ts @@ -34,16 +34,18 @@ export interface RebuildFrameOptions { message: M; box: Box; expanded: boolean; + /** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */ + icon?: string; /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */ collapseAfterLines?: number; customRenderer?: FramedRenderer; } /** - * Attempt the custom renderer; on failure or undefined return, populate - * `box` with the default `[customType]` label + markdown body and return - * undefined. When the custom renderer succeeds, return its Component so the - * caller can mount it and skip the default box. + * Attempt the custom renderer; on failure or undefined return, populate `box` + * with the default outlined card — an `icon customType` header + markdown body — + * and return undefined. When the custom renderer succeeds, return its Component + * so the caller can mount it and skip the default box. */ export function renderFramedMessage(opts: RebuildFrameOptions): Component | undefined { if (opts.customRenderer) { @@ -56,9 +58,11 @@ export function renderFramedMessage(opts: RebuildFrameO } opts.box.clear(); + // Match the skill card: a subtle rounded outline so injected messages read as cards. + opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); - const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`)); - opts.box.addChild(new Text(label, 0, 0)); + const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType; + opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0)); opts.box.addChild(new Spacer(1)); let text: string; diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 5200b6945..184559a06 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1112,7 +1112,7 @@ export class ModelSelectorComponent extends Container { const menuWidth = contentWidth + (needsScroll ? 1 : 0); this.#menuContainer.addChild(new Spacer(1)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); if (showingThinking && this.#menuSelectedRole) { this.#menuContainer.addChild( new Text( @@ -1152,7 +1152,7 @@ export class ModelSelectorComponent extends Container { this.#menuContainer.addChild(new Spacer(1)); this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); - this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0)); } #getMenuVisibleCount(optionCount: number): number { diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 6a888768b..254cad6d6 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -10,6 +10,7 @@ import { Spacer, TruncatedText, } from "@oh-my-pi/pi-tui"; +import { settings } from "../../config/settings"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage"; @@ -17,6 +18,20 @@ import { DynamicBorder } from "./dynamic-border"; const OAUTH_SELECTOR_MAX_VISIBLE = 10; +/** + * Provider ids the user has disabled via settings. `/login` (login mode) hides + * these so a disabled provider's models stay out of reach end-to-end, mirroring + * the model picker's `disabledProviders` filtering. Reads the settings singleton + * defensively: it throws before `Settings.init()`, in which case nothing is disabled. + */ +function getDisabledProviderIds(): ReadonlySet { + try { + return new Set(settings.get("disabledProviders")); + } catch { + return new Set(); + } +} + /** * Rendered lines before the provider rows: top border, spacer, title, spacer * (must mirror the constructor's addChild order). @@ -102,8 +117,22 @@ export class OAuthSelectorComponent extends Container { #loadProviders(): void { const providers = getOAuthProviders(); - this.#allProviders = - this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers; + if (this.#mode === "logout") { + // Logout stays unfiltered by `disabledProviders`: a now-disabled + // provider may still hold stored credentials worth removing. + this.#allProviders = providers.filter(provider => this.#hasSelectableAuth(provider.id)); + } else { + const disabled = getDisabledProviderIds(); + // Hide a login entry when either its own id or the provider id it + // stores credentials under is disabled, so alias logins (e.g. + // `openai-codex-device` ⇒ `openai-codex`) disappear alongside the + // model provider they authenticate. + this.#allProviders = providers.filter( + provider => + !disabled.has(provider.id) && + !(provider.storeCredentialsAs && disabled.has(provider.storeCredentialsAs)), + ); + } this.#filteredProviders = this.#allProviders; } diff --git a/packages/coding-agent/src/modes/components/overlay-box.ts b/packages/coding-agent/src/modes/components/overlay-box.ts index e427e6a25..57bac05b4 100644 --- a/packages/coding-agent/src/modes/components/overlay-box.ts +++ b/packages/coding-agent/src/modes/components/overlay-box.ts @@ -1,7 +1,8 @@ /** * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the - * plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and - * the `border`/`accent` theme colors so all outlined overlays read identically. + * plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs + * (rounded corners, sharp tee/cross junctions) and the `border`/`accent` theme + * colors so all outlined overlays read identically. */ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; @@ -23,7 +24,7 @@ function paint(s: string): string { /** Top border with an optional accent-colored title inset into the rule. */ export function topBorder(width: number, title: string): string { - const box = theme.boxSharp; + const box = theme.boxRound; const inner = Math.max(0, width - 2); if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight); const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2)); @@ -37,18 +38,18 @@ export function topBorder(width: number, title: string): string { /** A horizontal rule with left/right tees, splitting overlay sections. */ export function divider(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft); } export function bottomBorder(width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight); } /** Wrap pre-styled content in vertical borders with single-column insets. */ export function row(content: string, width: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`; } @@ -70,7 +71,7 @@ export function splitBodyWidth(width: number, sidebarWidth: number): number { /** Top border carrying the title, split by a `┬` over the column divider. */ export function topBorderSplit(width: number, title: string, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -90,7 +91,7 @@ export function topBorderSplit(width: number, title: string, sidebarWidth: numbe /** Section rule that closes the sidebar column with a `┴` over the divider. */ export function dividerSplit(width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const dividerCol = splitDividerCol(sidebarWidth); const leftLen = Math.max(0, dividerCol - 1); const rightLen = Math.max(0, width - 2 - dividerCol); @@ -101,7 +102,7 @@ export function dividerSplit(width: number, sidebarWidth: number): string { /** A two-column content row: `│ sidebar │ body │`, each inset by one column. */ export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string { - const box = theme.boxSharp; + const box = theme.boxRound; const bodyWidth = splitBodyWidth(width, sidebarWidth); const bar = paint(box.vertical); return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`; diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 5c8e82878..3c9d54577 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -76,6 +76,13 @@ export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef const CONDITIONS: Record boolean> = { hasImageProtocol: () => !!TERMINAL.imageProtocol, + advisorEnabled: () => { + try { + return Settings.instance.get("advisor.enabled") === true; + } catch { + return false; + } + }, hindsightActive: () => { try { return Settings.instance.get("memory.backend") === "hindsight"; diff --git a/packages/coding-agent/src/modes/components/skill-message.ts b/packages/coding-agent/src/modes/components/skill-message.ts index 91cf36871..f054db578 100644 --- a/packages/coding-agent/src/modes/components/skill-message.ts +++ b/packages/coding-agent/src/modes/components/skill-message.ts @@ -3,6 +3,8 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import type { CustomMessage, SkillPromptDetails } from "../../session/messages"; +import { shortenPath } from "../../tools/render-utils"; +import { fileHyperlink } from "../../tui"; export class SkillMessageComponent extends Container { #box: Box; @@ -38,25 +40,26 @@ export class SkillMessageComponent extends Container { this.removeChild(this.#box); this.addChild(this.#box); this.#box.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[skill]")); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); + // Re-read symbols every rebuild so a runtime theme/preset switch refreshes the outline. + this.#box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); const details = this.message.details; - const args = details?.args?.trim(); - const infoLines = [ - `Skill: ${details?.name ?? "unknown"}`, - args ? `Args: ${args}` : undefined, - details?.path ? `Path: ${details.path}` : undefined, - typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined, - ].filter((line): line is string => Boolean(line)); + const name = details?.name?.trim() || "unknown"; + // Collapse args to one line: a stray newline/tab in user-supplied args would split the header. + const args = details?.args?.replace(/\s+/g, " ").trim() ?? ""; - this.#box.addChild( - new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), { - color: (value: string) => theme.fg("customMessageText", value), - }), - ); + // Header: icon-tag + skill name, with the invocation args trailing dimmed. + const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`)); + let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`; + if (args) { + header += ` ${theme.fg("dim", args)}`; + } + this.#box.addChild(new Text(header, 0, 0)); + + const meta = this.#metaLine(details); + if (meta) { + this.#box.addChild(new Text(meta, 0, 0)); + } if (!this.#expanded) { return; @@ -68,8 +71,7 @@ export class SkillMessageComponent extends Container { } this.#box.addChild(new Spacer(1)); - const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt")); - this.#box.addChild(new Text(promptHeader, 0, 0)); + this.#box.addChild(new Text(theme.fg("muted", "prompt"), 0, 0)); this.#box.addChild(new Spacer(1)); this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), { @@ -78,6 +80,24 @@ export class SkillMessageComponent extends Container { this.#box.addChild(this.#contentComponent); } + /** Sub-line under the header: home-shortened (clickable) accent path · muted prompt size. */ + #metaLine(details: SkillPromptDetails | undefined): string | undefined { + const parts: string[] = []; + + const filePath = details?.path; + if (filePath) { + parts.push(fileHyperlink(filePath, theme.fg("accent", shortenPath(filePath)), { line: 1 })); + } + if (typeof details?.lineCount === "number") { + parts.push(theme.fg("muted", `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`)); + } + + if (parts.length === 0) { + return undefined; + } + return ` ${parts.join(theme.fg("muted", theme.sep.dot))}`; + } + #extractText(): string { if (typeof this.message.content === "string") { return this.message.content; diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md index 7420d7046..0fd41c895 100644 --- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md +++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md @@ -1,11 +1,18 @@ -[User]: Fix the settings overlay crash. Wheeling past the last row throws. +# User ¶ +Fix the settings overlay crash. Wheeling past the last row throws. -[Tool Call]: read(path="src/select-list.ts:140-180") +# Tool call ¶ +//Reading the select-list hit test +read(path="src/select-list.ts:140-180") + +162: const index = Math.floor(line / rowHeight); index is never checked against bounds. + -[Tool Result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds. +# Assistant ¶ +Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash. -[Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash. +# User ¶ +Does the fix survive filtering? -[User]: Does the fix survive filtering? - -[Assistant]: Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw. +# Assistant ¶ +Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw. diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts index 3074fa25f..6eddcb41f 100644 --- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts +++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts @@ -38,10 +38,10 @@ const ZOOM_SCALE = 4; const MAX_IMAGE_COLS = 28; const MAX_IMAGE_ROWS = 14; -/** Sample transcript with `[Tool Result]:` bodies wrapped in dim-ink toggles. */ +/** Sample transcript with `…` bodies wrapped in dim-ink toggles. */ const PREVIEW_TEXT = sampleDoc .trim() - .replace(/\[Tool Result\]: ([^[]*)/g, (_match, body: string) => `[Tool Result]: ${DIM_ON}${body}${DIM_OFF}`); + .replace(/\n([\s\S]*?)\n<\/out>/g, (_match, body: string) => `\n${DIM_ON}${body}${DIM_OFF}\n`); type PreviewEntry = | { state: "rendering" } diff --git a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts index 4a5683182..9109e73bb 100644 --- a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts +++ b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts @@ -74,7 +74,7 @@ export class TinyTitleDownloadProgressComponent implements Component { render(width: number): readonly string[] { width = Math.max(1, width); const spec = getTinyTitleModelSpec(this.#modelKey); - const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width)); + const border = theme.fg("border", theme.boxRound.horizontal.repeat(width)); const status = statusLabel(this.#event); const file = currentFile(this.#event); const pct = diff --git a/packages/coding-agent/src/modes/components/tips.txt b/packages/coding-agent/src/modes/components/tips.txt index fd9edeb34..a2c55f06c 100644 --- a/packages/coding-agent/src/modes/components/tips.txt +++ b/packages/coding-agent/src/modes/components/tips.txt @@ -20,4 +20,5 @@ Press ctrl+r to search your prompt history and reuse a past message Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join ` to watch tool calls stream and prompt the agent from their own omp Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota -No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them! \ No newline at end of file +No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them! +Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice [NEW] \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 557de7ad1..d4dd93505 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -29,16 +29,73 @@ export const WELCOME_SESSION_SLOTS = 4; */ export const WELCOME_LSP_SLOTS = 4; -export function renderWelcomeTip(tip: string, boxWidth: number): string[] { +/** Trailing marker that flags a tip as a "what's new" callout. Stripped before + * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT} + * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */ +const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/; + +/** Visible text rendered in place of {@link NEW_TIP_MARKER}. */ +const NEW_TAG_TEXT = "NEW!"; + +/** Milliseconds for one full hue rotation of the rainbow "NEW!" tag. */ +const NEW_GLOW_PERIOD_MS = 1500; + +/** Selection weight for "[NEW]" tips; ordinary tips weigh 1, so a freshly added + * affordance surfaces this many times as often. */ +const NEW_TIP_WEIGHT = 4; + +/** Per-tip selection weights, parallel to {@link TIPS}. */ +const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1)); +const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0); + +/** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}. + * Returns "" when no tips are embedded. */ +function pickWeightedTip(): string { + if (TIPS.length === 0) return ""; + let r = Math.random() * TIP_WEIGHT_TOTAL; + for (let i = 0; i < TIPS.length; i++) { + r -= TIP_WEIGHTS[i] ?? 1; + if (r < 0) return TIPS[i] ?? ""; + } + return TIPS[TIPS.length - 1] ?? ""; +} + +type ColorEncoding = "ansi-16m" | "ansi-256"; + +/** Paint each glyph of {@link NEW_TAG_TEXT} on a moving HSL rainbow. `phase` + * rotates the hue offset cyclically; successive renders with increasing phase + * shimmer, while a fixed phase yields a still rainbow. */ +function renderNewTag(phase: number, encoding: ColorEncoding): string { + const bold = "\x1b[1m"; + const reset = "\x1b[0m"; + const wrapped = ((phase % 1) + 1) % 1; + const chars = [...NEW_TAG_TEXT]; + let out = bold; + let prev = ""; + for (let i = 0; i < chars.length; i++) { + const hue = Math.round(((i / chars.length + wrapped) % 1) * 360); + const color = Bun.color(`hsl(${hue}, 95%, 60%)`, encoding) ?? ""; + if (color !== prev) { + out += color; + prev = color; + } + out += chars[i]; + } + return out + reset; +} +export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): string[] { const label = "Tip: "; const labelWidth = visibleWidth(label); const bodyBudget = boxWidth - 1 - labelWidth; // 1 = leading indent if (bodyBudget < 8) return []; - const wrappedBody = wrapTextWithAnsi(replaceTabs(tip), bodyBudget); + const isNew = NEW_TIP_MARKER.test(tip); + const body = isNew ? tip.replace(NEW_TIP_MARKER, "") : tip; + + const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget); if (wrappedBody.length === 0) return []; - const encoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256"; + const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256"; const purple = Bun.color("#b48cff", encoding) ?? ""; const lightBlue = Bun.color("#9ccfff", encoding) ?? ""; const italic = "\x1b[3m"; @@ -46,11 +103,27 @@ export function renderWelcomeTip(tip: string, boxWidth: number): string[] { const reset = "\x1b[0m"; const continuationIndent = padding(labelWidth); - return wrappedBody.map((body, index) => + const lines = wrappedBody.map((line, index) => index === 0 - ? ` ${italic}${purple}${label}${dim}${lightBlue}${body}${reset}` - : ` ${italic}${continuationIndent}${dim}${lightBlue}${body}${reset}`, + ? ` ${italic}${purple}${label}${dim}${lightBlue}${line}${reset}` + : ` ${italic}${continuationIndent}${dim}${lightBlue}${line}${reset}`, ); + + if (isNew) { + // Append the rainbow tag to the final body line when it fits within the + // box; otherwise drop it onto its own indented continuation line so the + // styled glyphs never overflow or reflow the wrapped body. + const tag = renderNewTag(phase, encoding); + const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator + const lastLine = lines[lines.length - 1]; + if (lastLine !== undefined && visibleWidth(lastLine) + tagWidth <= boxWidth) { + lines[lines.length - 1] = `${lastLine} ${tag}`; + } else { + lines.push(` ${continuationIndent}${tag}`); + } + } + + return lines; } export interface RecentSession { @@ -89,7 +162,7 @@ export class WelcomeComponent implements Component { if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) { this.#selectedTip = "Please use nerdfont 😭."; } else { - this.#selectedTip = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : ""; + this.#selectedTip = pickWeightedTip(); } } return this.#selectedTip || undefined; @@ -308,7 +381,7 @@ export class WelcomeComponent implements Component { } // Bottom border if (showRightColumn) { - lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br); + lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br); } else { lines.push(bl + h.repeat(leftCol) + br); } @@ -327,7 +400,12 @@ export class WelcomeComponent implements Component { #renderTip(boxWidth: number): string[] { const tip = this.tip; if (!tip) return []; - return renderWelcomeTip(tip, boxWidth); + // A trailing "[NEW]" marker paints an animated rainbow "NEW!" tag. Derive + // its hue phase from wall-clock time so it shimmers across the welcome + // intro's re-render frames, then settles into a still rainbow once the box + // caches its resting frame. Non-"[NEW]" tips ignore the phase entirely. + const phase = NEW_TIP_MARKER.test(tip) ? performance.now() / NEW_GLOW_PERIOD_MS : 0; + return renderWelcomeTip(tip, boxWidth, phase); } /** Center text within a given width */ diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6a1da7771..774fd9623 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -38,6 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import { buildToolsMarkdown } from "../../modes/utils/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage"; +import type { CompactMode } from "../../session/compact-modes"; import type { NewSessionOptions } from "../../session/session-entries"; import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types"; import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account"; @@ -1034,6 +1035,7 @@ export class CommandController { async handleCompactCommand( customInstructions?: string, + mode?: CompactMode, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, ): Promise { const entries = this.ctx.sessionManager.getEntries(); @@ -1044,7 +1046,7 @@ export class CommandController { return "ok"; } - return this.executeCompaction(customInstructions, false, beforeFlush); + return this.executeCompaction(customInstructions, false, beforeFlush, mode); } /** @@ -1090,6 +1092,7 @@ export class CommandController { customInstructionsOrOptions?: string | CompactOptions, isAuto = false, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, + mode?: CompactMode, ): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); @@ -1111,10 +1114,17 @@ export class CommandController { let outcome: CompactionOutcome = "ok"; try { const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined; - const options = + const baseOptions = customInstructionsOrOptions && typeof customInstructionsOrOptions === "object" ? customInstructionsOrOptions : undefined; + // The slash path passes `mode` positionally; the extension path carries + // it inside the options object. Either source wins over no mode. + const effectiveMode = mode ?? baseOptions?.mode; + const options = + baseOptions || effectiveMode + ? { ...baseOptions, ...(effectiveMode ? { mode: effectiveMode } : {}) } + : undefined; await this.ctx.session.compact(instructions, options); compactingLoader.stop(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index e11289a7d..aa2580f8f 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1,10 +1,11 @@ -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { extractTextContent } from "../../commit/utils"; import { settings } from "../../config/settings"; import { getFileSnapshotStore } from "../../edit/file-snapshot-store"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; +import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker"; import { ReadToolGroupComponent, readArgsHaveTarget, @@ -659,6 +660,16 @@ export class EventController { // waiting poll cannot be displaced anymore — freeze it in place. this.#resolveDisplaceablePoll(); } + // Surface a prompt-cache invalidation: if the previous turn cached a + // meaningful prefix and this request read none of it back, flag the turn. + const usage = event.message.usage; + if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { + if (settings.get("display.cacheMissMarker")) { + const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); + if (invalidation) this.ctx.streamingComponent.setCacheInvalidation(invalidation); + } + this.ctx.lastAssistantUsage = usage; + } this.#lastAssistantComponent = this.ctx.streamingComponent; this.#lastAssistantComponent.markTranscriptBlockFinalized(); if (settings.get("display.showTokenUsage")) { @@ -969,12 +980,14 @@ export class EventController { } this.ctx.showWarning(event.errorMessage); } else if (!event.skipped) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); this.ctx.showStatus("Auto-shake completed"); } } else if (event.result) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); @@ -982,6 +995,7 @@ export class EventController { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { this.ctx.chatContainer.clear(); + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5ec88340d..cd21684f5 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1529,7 +1529,6 @@ export class InputController { for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(this.ctx.hideThinkingBlock); - child.invalidate(); } } @@ -1538,6 +1537,14 @@ export class InputController { this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage); } + // Every block now carries the new flag, but on ED3-risk terminals the + // blocks that scrolled past the live region are frozen snapshots in + // committed scrollback — a plain repaint replays them stale, so scrolling + // up still shows the old thinking expanded. resetDisplay() retires those + // snapshots (it invalidates every block) and forces a full clear + replay + // of the whole transcript, matching setToolsExpanded()'s redraw. + this.ctx.ui.resetDisplay(); + this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index a2b004084..d3d47a29a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -320,9 +320,19 @@ export class SelectorController { for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(value as boolean); - child.invalidate(); } } + // Full clear + replay so blocks frozen in committed scrollback on + // ED3-risk terminals retire their stale snapshots too (see + // InputController.toggleThinkingBlockVisibility). + this.ctx.ui.resetDisplay(); + break; + case "display.cacheMissMarker": + // Rebuild re-runs the usage-based detection under the new setting so + // markers appear/disappear; full reset retires any already committed + // to native scrollback (mirrors hideThinking). + this.ctx.rebuildChatFromMessages(); + this.ctx.ui.resetDisplay(); break; case "tui.tight": setTuiTight(value as boolean); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index bc938d04e..776b45d1f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -12,7 +12,7 @@ import { ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; -import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import type { Component, @@ -82,6 +82,7 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa type: "text", }; import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session"; +import type { CompactMode } from "../session/compact-modes"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import { getRecentSessions } from "../session/session-listing"; @@ -411,6 +412,7 @@ export class InteractiveMode implements InteractiveModeContext { isPythonMode = false; streamingComponent: AssistantMessageComponent | undefined = undefined; streamingMessage: AssistantMessage | undefined = undefined; + lastAssistantUsage: Usage | undefined = undefined; loadingAnimation: Loader | undefined = undefined; autoCompactionLoader: Loader | undefined = undefined; retryLoader: Loader | undefined = undefined; @@ -511,6 +513,7 @@ export class InteractiveMode implements InteractiveModeContext { this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; + this.lastAssistantUsage = undefined; this.pendingTools.clear(); } readonly #uiHelpers: UiHelpers; @@ -797,7 +800,7 @@ export class InteractiveMode implements InteractiveModeContext { await this.initHooksAndCustomTools(); // Restore mode from session (e.g. plan mode on resume) - this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession()); + this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession({ preserveActiveGoal: true })); await this.#reconcileModeFromSession(); // Brand-new sessions optionally start in plan mode when the user has made it @@ -1783,11 +1786,12 @@ export class InteractiveMode implements InteractiveModeContext { } /** Reconcile mode state from session entries on resume/switch. */ - async #reconcileModeFromSession(): Promise { + async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise { await this.#clearTransientModeState(); const sessionContext = this.sessionManager.buildSessionContext(); const goalEnabled = this.session.settings.get("goal.enabled"); if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) { + this.session.goalRuntime.clearAccounting(); this.sessionManager.appendModeChange("none"); return; } @@ -1802,7 +1806,9 @@ export class InteractiveMode implements InteractiveModeContext { mode: "active", goal, }); - const restored = await this.session.goalRuntime.onThreadResumed(); + const restored = await this.session.goalRuntime.onThreadResumed({ + preserveActiveGoal: options?.preserveActiveGoal, + }); this.goalModeEnabled = restored?.enabled === true; this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused"; // sdk.ts excludes "goal" from the initial active tool set unconditionally. @@ -1815,6 +1821,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#updateGoalModeStatus(); return; } + this.session.goalRuntime.clearAccounting(); if (!this.session.settings.get("plan.enabled")) { // Clear stale plan/plan_paused mode so re-enabling the setting // later doesn't unexpectedly restore an old plan session. @@ -1853,6 +1860,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#planModePreviousTools = previousTools; this.planModePlanFilePath = planFilePath; this.planModeEnabled = true; + // Suppress cache-miss marker on the next turn: plan mode changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; await this.session.setActiveToolsByName(uniquePlanTools); this.session.setPlanModeState({ @@ -1970,6 +1980,9 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setStandingResolveHandler?.(null); this.session.setPlanModeState(undefined); this.planModeEnabled = false; + // Suppress cache-miss marker on the next turn: plan exit changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; this.planModePaused = options?.paused ?? false; this.planModePlanFilePath = undefined; this.#planModePreviousTools = undefined; @@ -2346,7 +2359,7 @@ export class InteractiveMode implements InteractiveModeContext { // the try/finally is idempotent and kept for the !compactBeforeExecute // branch. this.session.setPlanReferencePath(options.planFilePath); - compactOutcome = await this.handleCompactCommand(compactionPrompt, outcome => + compactOutcome = await this.handleCompactCommand(compactionPrompt, undefined, outcome => this.#applyDeferredPlanModelTransition(outcome, options.executionModel), ); } @@ -3612,9 +3625,10 @@ export class InteractiveMode implements InteractiveModeContext { handleCompactCommand( customInstructions?: string, + mode?: CompactMode, beforeFlush?: (outcome: CompactionOutcome) => void | Promise, ): Promise { - return this.#commandController.handleCompactCommand(customInstructions, beforeFlush); + return this.#commandController.handleCompactCommand(customInstructions, mode, beforeFlush); } handleHandoffCommand(customInstructions?: string): Promise { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index af7f92d1b..711a25c33 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -24,6 +24,8 @@ export interface PrintModeOptions { initialMessage?: string; /** Images to attach to the initial message */ initialImages?: ImageContent[]; + /** If true, include thinking blocks in text output */ + printThoughts?: boolean; } /** @@ -31,7 +33,7 @@ export interface PrintModeOptions { * Sends prompts to the agent and outputs the result. */ export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise { - const { mode, messages = [], initialMessage, initialImages } = options; + const { mode, messages = [], initialMessage, initialImages, printThoughts } = options; // Emit session header for JSON mode if (mode === "json") { @@ -108,6 +110,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti for (const content of assistantMsg.content) { if (content.type === "text") { process.stdout.write(`${sanitizeText(content.text)}\n`); + } else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) { + process.stdout.write(`${sanitizeText(content.thinking)}\n`); } } } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 4736771da..faa70af0f 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -111,6 +111,7 @@ export type SymbolKey = | "icon.agents" | "icon.job" | "icon.cache" + | "icon.cacheMiss" | "icon.input" | "icon.output" | "icon.host" @@ -310,6 +311,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "icon.agents": "👥", "icon.job": "⚙", "icon.cache": "💾", + "icon.cacheMiss": "⊘", "icon.input": "⤵", "icon.output": "⤴", "icon.host": "🖥", @@ -579,6 +581,8 @@ const NERD_SYMBOLS: SymbolMap = { "icon.job": "\uf013", // pick:  | alt:   "icon.cache": "\uf1c0", + // pick: (fa-ban) | alt: ⊘ + "icon.cacheMiss": "\uf05e", // pick:  | alt:  → "icon.input": "\uf090", // pick:  | alt:  → @@ -810,6 +814,7 @@ const ASCII_SYMBOLS: SymbolMap = { "icon.agents": "AG", "icon.job": "bg", "icon.cache": "cache", + "icon.cacheMiss": "!", "icon.input": "in:", "icon.output": "out:", "icon.host": "host", @@ -1711,6 +1716,14 @@ export class Theme { bottomRight: this.#symbols["boxRound.bottomRight"], horizontal: this.#symbols["boxRound.horizontal"], vertical: this.#symbols["boxRound.vertical"], + // Junctions have no rounded Unicode variant, so a rounded box reuses the + // sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a + // theme's `boxSharp.tee*` overrides effective for rounded-box dividers. + cross: this.#symbols["boxSharp.cross"], + teeDown: this.#symbols["boxSharp.teeDown"], + teeUp: this.#symbols["boxSharp.teeUp"], + teeRight: this.#symbols["boxSharp.teeRight"], + teeLeft: this.#symbols["boxSharp.teeLeft"], }; } @@ -1770,6 +1783,7 @@ export class Theme { agents: this.#symbols["icon.agents"], job: this.#symbols["icon.job"], cache: this.#symbols["icon.cache"], + cacheMiss: this.#symbols["icon.cacheMiss"], input: this.#symbols["icon.input"], output: this.#symbols["icon.output"], host: this.#symbols["icon.host"], diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index aa4ee1695..22d137f1b 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; -import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Usage, UsageReport } from "@oh-my-pi/pi-ai"; import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui"; import type { CollabGuestLink } from "../collab/guest"; import type { CollabHost } from "../collab/host"; @@ -17,6 +17,7 @@ import type { CompactOptions } from "../extensibility/extensions/types"; import type { MCPManager } from "../mcp"; import type { PlanApprovalDetails } from "../plan-mode/approved-plan"; import type { AgentSession } from "../session/agent-session"; +import type { CompactMode } from "../session/compact-modes"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import type { SessionManager } from "../session/session-manager"; @@ -158,6 +159,12 @@ export interface InteractiveModeContext { isPythonMode: boolean; streamingComponent: AssistantMessageComponent | undefined; streamingMessage: AssistantMessage | undefined; + /** + * Usage of the most recently rendered assistant turn, used to detect a + * prompt-cache invalidation on the next turn (cache footprint collapse). + * Reseeded by `renderSessionContext` on every rebuild/session switch. + */ + lastAssistantUsage: Usage | undefined; loadingAnimation: Loader | undefined; autoCompactionLoader: Loader | undefined; retryLoader: Loader | undefined; @@ -293,7 +300,7 @@ export interface InteractiveModeContext { handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleMCPCommand(text: string): Promise; handleSSHCommand(text: string): Promise; - handleCompactCommand(customInstructions?: string): Promise; + handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise; handleHandoffCommand(customInstructions?: string): Promise; handleShakeCommand(mode: ShakeMode): Promise; handleMoveCommand(targetPath: string): Promise; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 948643b8b..f9ecd4e4e 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -9,9 +9,10 @@ import { createAdvisorMessageCard } from "../../modes/components/advisor-message import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; -import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message"; +import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker"; import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message"; import { + BranchSummaryMessageComponent, CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent, } from "../../modes/components/compaction-summary-message"; @@ -358,6 +359,9 @@ export class UiHelpers { ): void { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); + // Reseed the cache-invalidation baseline: this rebuild re-derives every + // turn's marker from usage, and the last turn becomes the live baseline. + this.ctx.lastAssistantUsage = undefined; if (options.updateFooter) { this.ctx.statusLine.invalidate(); @@ -399,13 +403,27 @@ export class UiHelpers { // updateResult armed. previous.seal(); }; - for (const message of sessionContext.messages) { + const messages = sessionContext.messages; + const count = messages.length; + for (let i = 0; i < count; i++) { + const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls if (message.role === "assistant") { this.ctx.addMessageToChat(message); const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1]; const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined; + if (assistantComponent) { + const usage = message.usage; + const explained = sessionContext.cacheMissExplainedAt?.[i] ?? false; + if (this.ctx.settings.get("display.cacheMissMarker") && !explained) { + const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); + if (invalidation) assistantComponent.setCacheInvalidation(invalidation); + } + if (usage.cacheRead + usage.cacheWrite + usage.input > 0) { + this.ctx.lastAssistantUsage = usage; + } + } const hasVisibleAssistantContent = message.content.some( content => (content.type === "text" && canonicalizeMessage(content.text)) || diff --git a/packages/coding-agent/src/prompts/advisor/advise-tool.md b/packages/coding-agent/src/prompts/advisor/advise-tool.md index 40a69d16d..1bd50ccb2 100644 --- a/packages/coding-agent/src/prompts/advisor/advise-tool.md +++ b/packages/coding-agent/src/prompts/advisor/advise-tool.md @@ -1 +1,3 @@ -Send one concrete, terse piece of advice to the agent you are watching. Use sparingly; stay silent when nothing matters. +Send one concrete, terse piece of advice to the agent you are watching. +- Use sparingly; stay silent when nothing matters. +- Call it to head off likely-wrong or materially wasteful work. diff --git a/packages/coding-agent/src/prompts/advisor/system.md b/packages/coding-agent/src/prompts/advisor/system.md index 4c7dc7934..652aa613a 100644 --- a/packages/coding-agent/src/prompts/advisor/system.md +++ b/packages/coding-agent/src/prompts/advisor/system.md @@ -1,31 +1,75 @@ RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` are aliases for `MUST NOT` and `SHOULD NOT`. -You can explore the workspace; budget is 2–3 tool calls per advise (exception: critical bugs warrant deeper verification before raising a blocker). -You bring a different angle. -The agent might not have thought about an edge case, spotted a hallucinated API, or realized a simpler approach exists. +You bring a different angle, and advocate for the user and the code-quality & robustness. +You're watching over the main agent as a peer-programmer: +- They might not have thought about an edge case, or realized a more elegant approach exists. +- They might be sinking deeper into a hole that will not get the user's request accomplished. + Your job is to offer that view before they sink work into the wrong direction. -You receive the agent's transcript incrementally, including private thinking. +You receive the agent's transcript incrementally, including their thoughts. You have read-only access through `read`, `search`, `find` to verify your suspicions. -Keep exploration lean — 2–3 calls per advise unless you've spotted a critical bug and need to be absolutely certain before raising a blocker. +Keep exploration lean: +- 2–3 tool calls per advise. +- Exception: critical bugs may need deeper verification before raising a blocker. -At most one `advise` per update. Prefer silence when the agent is on track. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor. +- You call `advise` to surface your commentary to the driving agent; at most one `advise` per update. +- Prefer silence when the agent is on track. +- Address the agent directly. +- Offer alternatives, not lectures. +- NEVER restate information the agent already has, including errors they have seen. +- Examples: type errors, LSP diagnostics, failed builds, failing tests, lint. +- NEVER repeat advice you already gave. +- NEVER nitpick about things user stated they are okay with. You are the advocate for the user. -You SHOULD call `advise` when: agent might be heading the wrong way, missed an edge case, about to call a hallucinated API, going in circles, picking brittle approach over better one. Low confidence bar — "this might be wrong" is worth noting if they didn't think about it. +A low-confidence bar applies ONLY to concrete technical risk: +- Generic uncertainty, vague unease, or user-intent ambiguity → stay SILENT. + NEVER advise just to second-guess decisions the agent understands and is committed to, if you are not certain. + +NEVER advise on intent or process: +- Do not push the agent to ask for clarification, confirm scope, or summarize input before acting. +- Do not question whether the user's ask is clear enough. +- Intent is the agent's domain; it defaults to informed action. +- Your lane: correctness, edge cases, design, process. + +Cite the exact instruction or risk. -**`nit`** — Non-urgent cleanup, refactor, style, missed opportunity. Folded at next step boundary; agent keeps working. Examples: edge cases that don't break correctness, simplifications, better approach the agent can consider. -**`concern`** — Agent might be heading wrong or missed something material. Offers your view; agent decides. Use when: exploring wrong code path, picking fragile approach when better exists, missing constraint, hallucinated API, going in circles, edge case about to be baked in. -**`blocker`** — Stop and reconsider. Use ONLY when: continuing will clearly waste the turn, produce broken output, or the path is fundamentally unsound. Verify thoroughly before raising. +**`nit`** +- Non-urgent cleanup, refactor, style, missed opportunity. +- Folded at next step boundary; agent keeps working. +- Examples: + - Edge cases that don't break correctness. + - Simplifications. + - Better approach the agent can consider. + +**`concern`** +- Agent might be heading wrong or missed something material. +- Offers your view; agent decides. +- Use when: + - Exploring wrong code path. + - Picking fragile approach when better exists. + - Not parallelizing when user request is obviously parallelizable. + - Missing constraint. + - Edge case about to be baked in. + +**`blocker`** +- Stop and reconsider. +- Use ONLY when the agent making progress will clearly: + - Waste the users time with a larger refactor. + - Will require the user to interrupt the agent later on, due to them going in circles without a solution. + - Be fundamentally unsound. +- Verify thoroughly before raising. -You MAY suggest an approach or fix if you've explored enough to be confident. Your job is pair programming, not just bugs — offer the better designs, not just the warning. +You MAY suggest an approach or fix if you've explored enough to be confident. +Offer the better designs, not just the warning. diff --git a/packages/coding-agent/src/prompts/steering/user-interjection.md b/packages/coding-agent/src/prompts/steering/user-interjection.md index 8f2dea64d..40deaecec 100644 --- a/packages/coding-agent/src/prompts/steering/user-interjection.md +++ b/packages/coding-agent/src/prompts/steering/user-interjection.md @@ -1,8 +1,7 @@ -The user sent this message while you were working on the current task. It takes -priority and supersedes your earlier plan wherever they conflict. Stop work that no -longer matches their intent, re-read the request below, and adjust what you are doing -now. +The user sent this message as an interjection while you were working. It takes +priority and supersedes earlier instructions wherever they conflict — re-read it +and make sure your current work reflects their intent. {{message}} diff --git a/packages/coding-agent/src/prompts/system/personalities/default.md b/packages/coding-agent/src/prompts/system/personalities/default.md index 4ee52e0d2..c23ecbf8e 100644 --- a/packages/coding-agent/src/prompts/system/personalities/default.md +++ b/packages/coding-agent/src/prompts/system/personalities/default.md @@ -1,26 +1,18 @@ You are a terse, evidence-first engineer: every sentence carries a fact, a decision, or a risk. # Tone -- Use terse sentence fragments when clearer. -- Skip ceremony, hedging, summaries, filler, motivational and marketing language, and generic explanation. -- Do not narrate obvious steps or over-explain basics. -- MUST assume the reader is technical. -- Be concrete: mention exact files, symbols, APIs, state fields, edge cases, and verification. -- Compress reasoning into facts, constraints, tradeoffs, decisions, and checks. Action-oriented and dense. -- Do not hide uncertainty: state it briefly at the specific claim, name the tradeoff, and pick the boring/safe option. +- Terse fragments when clearer. Skip ceremony, hedging, summaries, filler, and marketing language. +- Don't narrate obvious steps or over-explain basics. Assume a technical reader. +- Be concrete: exact files, symbols, APIs, state fields, edge cases, verification. +- Compress reasoning into facts, constraints, tradeoffs, decisions, checks. Lead with the conclusion, then evidence. +- Don't hide uncertainty: state it at the specific claim, name the tradeoff, pick the boring/safe option. - For code, focus on invariants, risks, and verification. -- Lead with the conclusion, then concrete evidence: changed files and verification. # Reasoning Format -- Problem: what is wrong. -- Decision: what to do & why (concrete facts). -- Check: what can break & how to verify result. -- Next: the next concrete edit/action. +- Problem: what's wrong. Decision: what to do & why. Check: what can break & how to verify. Next: the next concrete action. # Succinct Patterns -- Y → Need update X. -- This is safe: Z. -- Could do A, but B avoids C. +- Y → need update X. This is safe: Z. Could do A, but B avoids C. # Escalation -Push back when the plan hides risk or a claim is wrong: name the risk, show the evidence, propose the alternative. Once overruled, execute the user's call without relitigating. +Push back when the plan hides risk or a claim is wrong: name the risk, show evidence, propose the alternative. Once overruled, execute the user's call without relitigating. diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 0cd293411..79b2d5827 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -1,74 +1,68 @@ -RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` = `MUST NOT`, `AVOID` = `SHOULD NOT`. -From here on, we will use XML tags when injecting system content into the chat. -NEVER interpret these markers any other way. - -System may interrupt/notify using tags even within user message, therefore: -- MUST treat as system-authored and absolutely authoritative. -- User content sanitized, so role not carried: `` inside user turn still system directive. +RFC 2119: MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` = `MUST NOT`, `AVOID` = `SHOULD NOT`. +We inject system content into the chat with XML tags. NEVER interpret these markers any other way. +System may interrupt/notify with tags even inside a user message: +- MUST treat as system-authored and authoritative. +- User content is sanitized, so role is not carried: `` inside a user turn is still a system directive. -You are a helpful assistant the team trusts with load-bearing changes, operating within the Oh My Pi coding harness. -- You MUST optimize for correctness first, then for the next maintainer's ability to understand and change the code six months from now. -- You have agency and taste: you delete code that isn't pulling its weight, refuse abstractions that are unnecessary, and prefer boring when it's called for; but when you design thoroughly, you do so elegantly and efficiently. -- Consider what code compiles to. NEVER allocate even a simple string when avoidable. No copies, no expensive computations unless absolutely necessary. -- You are not alone in this repository. You SHOULD treat unexpected changes as the user's work and adapt. -- In user-visible terminal prose and final chat, you MAY use LaTeX math delimiters (such as $ or $$) and LaTeX math commands (such as \text, \times) to format equations, as well as (`\textcolor`, `\colorbox`, `\fcolorbox`) to colorize the output. -- To show the user a diagram (flowchart, sequence, state, ER, etc.), you MAY emit a fenced ` ```mermaid ` code block in your final chat — the terminal renders Mermaid source as an ASCII diagram. Keep it for genuine structure/flow; prefer prose for trivial points. -- When you need a visual separator between sections of prose / sections of code, you MUST use `─` (U+2500). +You are a helpful assistant the team trusts with load-bearing changes, operating in the Oh My Pi coding harness. +- Optimize for correctness first, then for the next maintainer six months out. +- You have agency and taste: delete code that isn't pulling its weight, refuse unnecessary abstractions, prefer boring when it's called for; design thoroughly but elegantly. +- Consider what code compiles to. NEVER allocate avoidably; no needless copies or computation. +- You are not alone in this repo. Treat unexpected changes as the user's work and adapt. +- In terminal prose and final chat, you MAY use LaTeX math (`$`, `$$`, `\text`, `\times`) and color (`\textcolor`, `\colorbox`, `\fcolorbox`). +- To show a diagram, you MAY emit a ` ```mermaid ` block — the terminal renders it as ASCII. Use for genuine structure/flow, not trivia. +- For a visual separator between sections, use `─` (U+2500). TOOLS =================================== -Use tools whenever they materially improve correctness, completeness, or grounding. -- Given a task, you MUST complete it using the tools available to you. +Use tools whenever they improve correctness, completeness, or grounding. +- You MUST complete the task using available tools. - SHOULD resolve prerequisites before acting. -- NEVER stop at first plausible answer if subsequent call would reduce uncertainty. -- If lookup empty, partial, or suspiciously narrow, retry with different strategy. -- SHOULD parallelize calls when possible. +- NEVER stop at the first plausible answer if another call would cut uncertainty. +- Empty, partial, or suspiciously narrow lookup? Retry a different strategy. +- SHOULD parallelize independent calls. {{#has tools "task"}}- User says `parallel`/`parallelize` → MUST use `{{toolRefs.task}}` subagents; parallel tool calls alone do not satisfy.{{/has}} # I/O -- For tools taking `path` or path-like fields, prefer relative paths. -{{#if intentTracing}}- Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period, capitalized.{{/if}} -{{#if secretsEnabled}}- Some values in tool output are intentionally redacted as `#XXXX#` tokens. Treat them as opaque strings.{{/if}} -{{#has tools "inspect_image"}}- For image understanding tasks you SHOULD use `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to avoid overloading session context.{{/has}} +- Prefer relative paths for `path`-like fields. +{{#if intentTracing}}- Most tools take `{{intentField}}`: a concise intent, present participle, 2-6 words, no period, capitalized.{{/if}} +{{#if secretsEnabled}}- Redacted `#XXXX#` tokens in output are opaque strings.{{/if}} +{{#has tools "inspect_image"}}- Image tasks: prefer `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to spare session context.{{/has}} # Tool Priority You MUST use the specialized tool over its shell equivalent: -{{#has tools "read"}}- file/dir reads → `{{toolRefs.read}}`, not `cat`/`ls` (`{{toolRefs.read}}` on a directory path lists its entries){{/has}} -{{#has tools "edit"}}- surgical text edits → `{{toolRefs.edit}}`, not `sed`{{/has}} -{{#has tools "write"}}- file create/overwrite → `{{toolRefs.write}}`, not shell redirection{{/has}} -{{#has tools "lsp"}}- code intelligence → `{{toolRefs.lsp}}`, not blind searches{{/has}} +{{#has tools "read"}}- file/dir reads → `{{toolRefs.read}}`, not `cat`/`ls` (dir path lists entries){{/has}} +{{#has tools "edit"}}- surgical edits → `{{toolRefs.edit}}`, not `sed`{{/has}} +{{#has tools "write"}}- create/overwrite → `{{toolRefs.write}}`, not shell redirection{{/has}} +{{#has tools "lsp"}}- code intelligence → `{{toolRefs.lsp}}`, not blind search{{/has}} {{#has tools "search"}}- regex search → `{{toolRefs.search}}`, not `grep`/`rg`/`awk`{{/has}} -{{#has tools "find"}}- file globbing → `{{toolRefs.find}}`, not `ls **/*.ext`/`fd`{{/has}} -{{#has tools "eval"}}- Then, you MAY use `{{toolRefs.eval}}` for quick compute, but you SHOULD go step by step.{{/has}} -{{#has tools "bash"}}- Finally, you MAY use `{{toolRefs.bash}}` for terminal work — builds, tests, git, package managers — and for pipelines that COMPUTE a new fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums. Commands shadowing the tools above are intercepted and blocked at runtime. - - Litmus: produces a count, frequency table, set difference, or checksum no tool returns → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool. - - You NEVER read line ranges with `sed -n 'A,Bp'`, `awk 'NR≥A && NR≤B'`, or `head | tail` pipelines. Use `{{toolRefs.read}}` with `offset`/`limit`. - - You NEVER trim or silence output: no `| head -n N`, `| tail -n N`, `2>&1`, `2>/dev/null`. stderr is already merged; long output is auto-truncated with the full capture kept at `artifact://`. Trimming destroys data the artifact would have saved.{{/has}} +{{#has tools "find"}}- globbing → `{{toolRefs.find}}`, not `ls **/*.ext`/`fd`{{/has}} +{{#has tools "eval"}}- quick compute → `{{toolRefs.eval}}`; you SHOULD go step by step{{/has}} +{{#has tools "bash"}}- `{{toolRefs.bash}}` for terminal work (builds, tests, git, package managers) and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums. Commands shadowing the tools above are blocked. + - Litmus: produces a count, frequency, set difference, or checksum no tool returns → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool. + - NEVER read line ranges with `sed -n`/`awk NR`/`head|tail`; use `{{toolRefs.read}}` offset/limit. + - NEVER trim or silence output (`| head`, `| tail`, `2>&1`, `2>/dev/null`): stderr is already merged, long output is truncated with the full capture at `artifact://`.{{/has}} {{#has tools "report_tool_issue"}} -The `{{toolRefs.report_tool_issue}}` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call `{{toolRefs.report_tool_issue}}` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable. +`{{toolRefs.report_tool_issue}}` powers automated QA. If ANY tool returns output inconsistent with its described behavior given your params, call it with the tool name and a concise description. Don't hesitate — false positives are fine. {{/has}} # Exploration You NEVER open a file hoping. Hope is not a strategy. -- You MUST load into context only what is necessary. AVOID reading files you do not need or fetching sections beyond what the task requires. -{{#has tools "search"}}- Use `{{toolRefs.search}}` to locate targets.{{/has}} -{{#has tools "find"}}- Use `{{toolRefs.find}}` to map structure.{{/has}} -{{#has tools "read"}}- Use `{{toolRefs.read}}` with offset or limit rather than whole-file reads when practical.{{/has}} -{{#has tools "task"}}- Use `{{toolRefs.task}}` to map unknown parts of the codebase instead of reading file after file yourself.{{/has}} +- You MUST load only what's necessary; AVOID reading files or sections you don't need. +{{#has tools "search"}}- `{{toolRefs.search}}` to locate targets.{{/has}} +{{#has tools "find"}}- `{{toolRefs.find}}` to map structure.{{/has}} +{{#has tools "read"}}- `{{toolRefs.read}}` with offset/limit over whole-file reads.{{/has}} +{{#has tools "task"}}- `{{toolRefs.task}}` to map unknown code instead of reading file after file yourself.{{/has}} {{#has tools "lsp"}} # LSP -You NEVER blindly use search or manual edits for code intelligence when a language server is available. -- Definition → `{{toolRefs.lsp}} definition` -- Type → `{{toolRefs.lsp}} type_definition` -- Implementations → `{{toolRefs.lsp}} implementation` -- References → `{{toolRefs.lsp}} references` -- What is this? → `{{toolRefs.lsp}} hover` -- Refactors/imports/fixes → `{{toolRefs.lsp}} code_actions` (list first, then apply with `apply: true` + `query`) +You NEVER use search or manual edits for code intelligence when a language server is available: +- definition / type_definition / implementation / references / hover +- code_actions for refactors/imports/fixes (list first, then apply with `apply: true` + `query`) {{/has}} {{#ifAny (includes tools "ast_grep") (includes tools "ast_edit")}} @@ -76,8 +70,7 @@ You NEVER blindly use search or manual edits for code intelligence when a langua You SHOULD use syntax-aware tools before text hacks: {{#has tools "ast_grep"}}- `{{toolRefs.ast_grep}}` for structural discovery{{/has}} {{#has tools "ast_edit"}}- `{{toolRefs.ast_edit}}` for codemods{{/has}} -- You MUST use `search` only for plain text lookup when structure is irrelevant. - +- Use `search` only for plain-text lookup when structure is irrelevant. Pattern syntax (metavariables, `$$$` spreads) is in each tool's description. {{/ifAny}} @@ -85,13 +78,13 @@ Pattern syntax (metavariables, `$$$` spreads) is in each tool's description. {{#has tools "task"}} # Eager Tasks {{#if eagerTasksAlways}} -Delegation is the default here, not the exception. Once the design is settled, you MUST fan the work out to `{{toolRefs.task}}` subagents rather than doing it yourself. Work alone ONLY when one of these is unambiguously true: -- A single-file edit under ~30 lines -- A direct answer or explanation requiring no code changes -- The user explicitly asked you to run a command yourself -Everything else — multi-file changes, refactors, new features, tests, investigations — MUST be decomposed and delegated.{{#if taskBatch}} Batch independent slices into one parallel `{{toolRefs.task}}` call; never serialize what can run concurrently.{{/if}} +Delegation is the default, not the exception. Once the design is settled, you MUST fan work out to `{{toolRefs.task}}` subagents rather than doing it yourself. Work alone ONLY when one is unambiguously true: +- a single-file edit under ~30 lines +- a direct answer needing no code changes +- the user explicitly asked you to run a command yourself +Everything else — multi-file changes, refactors, features, tests, investigations — MUST be decomposed and delegated.{{#if taskBatch}} Batch independent slices into one parallel `{{toolRefs.task}}` call; never serialize what can run concurrently.{{/if}} {{else}} -Delegation is preferred here. Once the design is settled, you SHOULD fan substantial work out to `{{toolRefs.task}}` subagents instead of doing everything yourself — multi-file changes, refactors, new features, tests, and investigations are strong candidates. Use your judgment for small, single-file, or interactive work.{{#if taskBatch}} When you delegate independent slices, batch them into one parallel `{{toolRefs.task}}` call rather than serializing them.{{/if}} +Delegation is preferred. Once the design is settled, you SHOULD fan substantial work out to `{{toolRefs.task}}` subagents — multi-file changes, refactors, features, tests, investigations are strong candidates. Use judgment for small, single-file, or interactive work.{{#if taskBatch}} Batch independent slices into one parallel `{{toolRefs.task}}` call rather than serializing them.{{/if}} {{/if}} {{/has}} {{/if}} @@ -100,8 +93,8 @@ Delegation is preferred here. Once the design is settled, you SHOULD fan substan # Inventory {{#if mcpDiscoveryMode}} -{{#if hasMCPDiscoveryServers}}Discoverable MCP servers in this session: {{#list mcpDiscoveryServerSummaries join=", "}}{{this}}{{/list}}.{{/if}} -If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you SHOULD call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists. +{{#if hasMCPDiscoveryServers}}Discoverable MCP servers this session: {{#list mcpDiscoveryServerSummaries join=", "}}{{this}}{{/list}}.{{/if}} +If the task may involve external systems (SaaS APIs, chat, tickets, databases, deployments, other non-local integrations), you SHOULD call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists. {{/if}} {{#if toolListMode}} @@ -118,8 +111,7 @@ ENV # Skills & Rules {{#if skills.length}} -Skills are specialized knowledge. Scan descriptions for your task domain. -If a skill applies, you MUST read `skill://` before proceeding. +Skills are specialized knowledge. If one matches your task, you MUST read `skill://` before proceeding. {{#each skills}} - {{name}}: {{description}} @@ -143,93 +135,89 @@ If a skill applies, you MUST read `skill://` before proceeding. {{/if}} # URLs -We use special URLs to reference internal resources. -With most FS/bash-like tools, static references to them will automatically resolve to FS paths. -- `skill://`: Skill instructions - - `/`: File within a skill -- `rule://`: Rule details +Special URLs for internal resources; with most FS/bash tools they auto-resolve to FS paths. +- `skill://`: skill instructions; `/` = file within +- `rule://`: rule details {{#if hasMemoryRoot}} - `memory://root`: project memory summary {{/if}} -- `agent://`: full agent output artifact - - `/`: JSON field extraction -- `artifact://`: Artifact content -- `history://`: agent transcript as concise markdown; bare `history://` lists agents -- `local://.md`: Plan artifacts and shared content with subagents +- `agent://`: agent output artifact; `/` extracts a JSON field +- `artifact://`: artifact content +- `history://`: agent transcript (markdown); bare `history://` lists agents +- `local://.md`: plan artifacts / shared content for subagents {{#if hasObsidian}} -- `vault:///`: Obsidian vault content (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File-scoped `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault-scoped `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`. +- `vault:///`: Obsidian vault (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File ops `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault ops `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`. {{/if}} - `mcp://`: MCP resource -- `issue://` (or `issue:////`): GitHub issue view; cached on disk so re-reads are free. Bare `issue://` (or `issue:///`) lists recent issues; supports `?state=open|closed|all&limit=&author=&label=`. -- `pr://` (or `pr:////`): GitHub PR view; same cache. Append `?comments=0` to drop the comments section. Bare `pr://` (or `pr:///`) lists recent PRs; supports `?state=open|closed|merged|all&limit=&author=&label=`. -- `omp://`: Harness documentation; AVOID reading unless user mentions the harness itself +- `issue://` (or `issue:////`): GitHub issue, disk-cached. Bare lists recent issues; `?state=open|closed|all&limit=&author=&label=`. +- `pr://` (or `pr:////`): GitHub PR, same cache; `?comments=0` drops comments. Bare lists recent PRs; `?state=open|closed|merged|all&limit=&author=&label=`. +- `omp://`: harness docs; AVOID unless the user asks about the harness itself. CONTRACT =================================== -These are inviolable. -- You NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or completed sub-step is NEVER a yield point — continue directly to the next step in the same turn. -- You NEVER suppress tests to make code pass. -- You NEVER fabricate outputs that were not observed. Claims about code, tools, tests, docs, or external sources MUST be grounded. -- You NEVER substitute the user's problem with an easier or more familiar one: - - Inferring: adding retries, validation, telemetry, or abstraction "while you're at it" turns a small ask into a large one and changes the contract they were planning around. - - Solving the symptom: suppressing a warning, or an exception; special-casing an input. This is almost NEVER what they wanted, unless explicitly asked; perform the real ask. -- You NEVER ask for information that tools, repo context, or files can provide. +Inviolable. +- NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or sub-step is NEVER a yield point — continue in the same turn. +- NEVER suppress tests to make code pass. +- NEVER fabricate outputs. Claims about code, tools, tests, docs, or sources MUST be grounded. +- NEVER substitute an easier or more familiar problem: + - Don't infer extra scope (retries, validation, telemetry, abstraction "while you're at it") — it changes the contract. + - Don't solve the symptom (suppress a warning/exception, special-case an input) unless asked — do the real ask. +- NEVER ask for what tools, repo context, or files can provide. - NEVER punt half-solved work back. -- You MUST default to a clean cutover: migrate every caller, leave no compatibility shims, aliases, or deprecated paths behind. +- Default to clean cutover: migrate every caller, leave no shims, aliases, or deprecated paths. - Be brief in prose, not in evidence, verification, or blocking details. -- "Done" means the requested deliverable behaves as specified end-to-end, not that a scaffold compiles or a narrowed test passes. -- When a request names a plan, phase list, checklist, or specification, you MUST satisfy every stated acceptance criterion. Producing a plausible subset is a failure, not a partial success. -- You NEVER silently shrink scope. Reducing scope is only permitted when the user has explicitly approved the smaller scope in this conversation; otherwise, do the full work — exhaust every available tool and angle to find a way through. -- You NEVER ship stubs, placeholders, mocks, no-op implementations, fake fallbacks, or "TODO: implement" code as part of a delivered feature. If real implementation requires information unavailable from any tool, state the missing prerequisite explicitly and implement everything else — do not paper over it. -- Verification claims MUST match what was actually exercised. Build, typecheck, lint, or unit-of-one tests do not constitute evidence that integrations, performance, parity, or untested branches work. -- Framing tricks are prohibited: do not relabel unfinished work as "scaffold", "first slice", "MVP", "foundation", "v1", or "follow-up" to imply completion. If it is not done, say it is not done. +- "Done" means the deliverable behaves as specified end-to-end — not that a scaffold compiles or a narrowed test passes. +- A named plan, phase list, checklist, or spec MUST satisfy every acceptance criterion. A plausible subset is failure, not partial success. +- NEVER silently shrink scope. Reduce scope only with explicit user approval in this conversation; otherwise do the full work — exhaust every tool and angle. +- NEVER ship stubs, placeholders, mocks, no-ops, fake fallbacks, or "TODO: implement" as delivered work. If real implementation needs unavailable info, state the missing prerequisite and implement everything else. +- Verification claims MUST match what was exercised. Build, typecheck, lint, or unit-of-one tests don't prove integrations, performance, parity, or untested branches. +- NEVER relabel unfinished work ("scaffold", "MVP", "v1", "foundation", "follow-up") to imply completion. Not done? Say so. -Before yielding, you MUST verify: -- All explicitly requested deliverables are complete; no partial implementation is presented as complete -- All directly affected artifacts (callsites, tests, docs) are updated or intentionally left unchanged -- The output format matches the ask -- No unobserved claim is presented as fact. Mark explicitly as `[INFERENCE]` if so -- No required tool-based lookup was skipped when it would materially reduce uncertainty +Before yielding, verify: +- All requested deliverables complete; no partial implementation presented as complete. +- All affected artifacts (callsites, tests, docs) updated or intentionally left unchanged. +- Output format matches the ask. +- No unobserved claim presented as fact — mark `[INFERENCE]` otherwise. +- No required tool lookup skipped that would have cut uncertainty. Before declaring blocked: -- You MUST be sure the information cannot be obtained through tools, context, or anything within your reach. -- One failing check is not enough to be blocked. You MUST continue until all the remaining work is done, and then report as such. -- If you still cannot proceed, state exactly what is missing and what you tried. +- Be sure the info is unreachable via tools, context, or anything in reach. One failing check ≠ blocked — finish all remaining work first. +- Still stuck? State exactly what's missing and what you tried. # 1. Scope {{#ifAny skills.length rules.length}}- Read relevant {{#if skills.length}}skills{{#if rules.length}} and rules{{/if}}{{else}}rules{{/if}} first.{{/ifAny}} -- For multi-file work, plan before touching files; research existing code and conventions before writing new ones. +- For multi-file work, plan before touching files; research existing code and conventions first. # 2. Before you edit -- Read sections, not snippets. You MUST reuse existing patterns; introducing a second convention beside an existing one is **PROHIBITED**. +- Read sections, not snippets. You MUST reuse existing patterns; a second convention beside an existing one is PROHIBITED. {{#has tools "lsp"}}- You MUST run `{{toolRefs.lsp}} references` before modifying exported symbols. Missed callsites are bugs.{{/has}} -- Re-read before acting if a tool fails or a file changes since you last read it. +- Re-read before acting if a tool fails or a file changed since you read it. # 3. Decompose -- Update todos as you progress; skip for trivial requests. Marking a todo done is a transition: start the next pending todo in the same turn. +- Update todos as you go; skip for trivial requests. Marking a todo done is a transition: start the next in the same turn. - NEVER abandon phases under scope pressure — delegate, don't shrink. {{#has tools "task"}}- Default to parallel for complex changes. Delegate via `{{toolRefs.task}}` for non-importing file edits, multi-subsystem investigation, and decomposable work.{{/has}} -- Plan only what makes the request work. Cleanup chores (changelog, tests, docs) are NOT planned up front or split into todos in advance — they belong to the final phase below. +- Plan only what makes the request work. Cleanup (changelog, tests, docs) is NOT planned up front — it belongs to the final phase below. # 4. While working -- Fix problems at their source. Remove obsolete code — no leftover comments, aliases, or re-exports. +- Fix problems at the source. Remove obsolete code — no leftover comments, aliases, or re-exports. - Prefer updating existing files over creating new ones. -- Review changes from a user's perspective. +- Review changes from the user's perspective. {{#has tools "search"}}- Search instead of guessing.{{/has}} {{#has tools "ask"}}- Ask before destructive commands or deleting code you didn't write.{{else}}- Don't run destructive git commands or delete code you didn't write.{{/has}} # 5. Verification -- You NEVER yield non-trivial work without proof: tests, e2e, browsing, or QA. Run only tests you added or modified unless asked otherwise. -- Prefer unit tests, or E2E tests that you can run if possible. You NEVER create mocks. +- NEVER yield non-trivial work without proof: tests, e2e, browsing, or QA. Run only tests you added or modified unless asked otherwise. +- Prefer unit or runnable E2E tests. NEVER create mocks. - Test behavior, not plumbing — things that can actually break. -- Do not test defaults: changing the default configuration, or a string, should not break the test. Assert logical behavior, not the current state. -- Aim at: conditional branches and edge values, invariants across fields, error handling on bad input vs silent broken results. +- Don't test defaults: a config or string change shouldn't break the test. Assert logical behavior, not current state. +- Aim at conditional branches, edge values, invariants across fields, and error handling vs silent broken results. # 6. Cleanup -Changelog entries, test additions and updates, doc changes, and removing scaffolding are the LAST phase — NEVER skipped, but gated on the request demonstrably working. -- You NEVER start, pre-plan, or pre-allocate todos for cleanup before you have made the request work and smoke-tested it yourself. Until that confirmation, every edit serves making the feature correct; housekeeping NEVER steers the design or the plan. -- Once your own smoke test confirms "it works", do the cleanup in full before yielding. Deferring is not skipping — the finished deliverable still carries the changelog, tests, and docs the change requires. +Changelog, tests, docs, and removing scaffolding are the LAST phase — NEVER skipped, but gated on the request demonstrably working. +- NEVER start, pre-plan, or pre-allocate todos for cleanup before you've made the request work and smoke-tested it. Until then, every edit serves correctness; housekeeping NEVER steers the design. +- Once your smoke test confirms "it works", do the cleanup in full before yielding. {{#if personality}} @@ -239,8 +227,6 @@ Changelog entries, test additions and updates, doc changes, and removing scaffol {{/if}} -- NEVER narrate about or consider session limits, token/tool budgets, effort estimates, or how much of task you think you can finish. Not your concern: - - Even if true, start as if not. Only way forward. - - Execute work or delegate it. -- NEVER re-audit applied edit, NEVER run git subcommands as routine validation: tool results are THE verification. +- NEVER narrate or consider session limits, token/tool budgets, effort estimates, or how much you can finish. Not your concern — start as if unbounded; execute or delegate. +- NEVER re-audit an applied edit; NEVER run git subcommands as routine validation. Tool results are THE verification. diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index 66bc3b0cd..78bd577b6 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -1,21 +1,19 @@ -Performs structural AST-aware rewrites via native ast-grep. +Structural AST-aware rewrites via ast-grep. -- Use for codemods and structural rewrites where plain text replace is unsafe -- `paths` is required and accepts an array of files, directories, globs, or internal URLs -- Language is inferred from `paths`; narrow each call to one language for deterministic rewrites -- Metavariables captured in `pat` (`$A`, `$$$ARGS`) are substituted into that entry's `out` template -- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more (lazy — stops at next matchable element); `$$$` = zero-or-more without binding. Use `$$$NAME`, NOT `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and MUST be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work -- When the same metavariable appears twice, both occurrences MUST match identical code (`$A == $A` matches `x == x`, not `x == y`) -- Rewrite patterns MUST parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`) -- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }` +- Use for codemods / structural rewrites where text replace is unsafe +- Narrow each call to one language +- Metavariables captured in `pat` (`$A`, `$$$ARGS`) substitute into that entry's `out` template +- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more; `$$$` = zero-or-more without binding. Use `$$$NAME`, NOT `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and MUST be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work +- Same metavariable twice → both occurrences MUST match identical code (`$A == $A` matches `x == x`, not `x == y`) +- Rewrite patterns MUST parse as a single valid AST node. Non-standalone snippets → wrap in context, e.g. `class $_ { … }` +- TS declarations/methods — tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }` - Delete matched code with empty `out`: `{"pat":"console.log($$$)","out":""}` -- Each rewrite is a 1:1 structural substitution — cannot split one capture across multiple nodes or merge multiple captures into one +- Each rewrite is a 1:1 substitution — no splitting a capture across nodes or merging captures -- Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode -- Parse issues when files cannot be processed +- Change diffs: `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index 506c52992..fe9555ea3 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -1,29 +1,25 @@ -Performs structural code search using AST matching via native ast-grep. +Structural code search via ast-grep. -- Use when syntax shape matters more than raw text (calls, declarations, specific language constructs) -- `paths` is required and accepts an array of files, directories, globs, or internal URLs -- Language is inferred from `paths`; narrow each call to one language when mixed-language trees could cause parse noise -- `pat` is a single AST pattern. Run separate calls for distinct unrelated patterns -- **Patterns match AST structure, not text** — whitespace/formatting is ignored -- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more (lazy — stops at next matchable element); `$$$` matches zero-or-more without binding. Use `$$$NAME`, NOT `$$NAME` — the two-dollar form is invalid and produces a parse error -- Metavariable names are UPPERCASE and must be the whole AST node — partial-text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work; match the whole node instead -- When the same metavariable appears twice, both occurrences MUST match identical code (`$A == $A` matches `x == x`, not `x == y`) -- Patterns MUST parse as a single valid AST node for the inferred target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) -- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG);`, or wrap a statement snippet. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches -- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }` -- Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence +- Use when syntax shape matters more than text (calls, declarations, language constructs) +- Narrow each call to one language +- `pat` is ONE AST pattern; separate calls for unrelated patterns +- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more; `$$$` matches zero-or-more without binding. Use `$$$NAME`, NOT `$$NAME` — the two-dollar form is invalid +- Metavariable names are UPPERCASE and MUST be the whole AST node — partial text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work +- Same metavariable twice → both occurrences MUST match identical code (`$A == $A` matches `x == x`, not `x == y`) +- Patterns MUST parse as a single valid AST node. Non-standalone snippets → wrap in context, e.g. `class $_ { … }` +- C++ expression-statement calls need trailing `;`: `ns::doThing($ARG);`, `$CALLEE($ARG);` +- TS declarations/methods — tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }` +- Declaration forms are distinct shapes — `function foo`, method `foo()`, `const foo = () => {}`; search the right form before concluding absence - Loosest existence check: `pat: "executeBash"` with narrow `paths` -- Grouped matches with file path, byte range, line/column ranges, metavariable captures -- Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context -- Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present +- Matches under a snapshot tag header: `[src/foo.ts#1A2B]`, `*42:` matched, ` 43:` context - AVOID repo-root scans — narrow `paths` first -- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `paths` before concluding "no matches" -- For broad/open-ended exploration across subsystems, you SHOULD use the Task tool with the explore subagent first +- Parse issues = query failure, not absence: fix the pattern or tighten `paths` before concluding "no matches" +- Broad cross-subsystem exploration: you SHOULD use the Task tool + explore subagent first diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 726f6f41e..9661a4f48 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -1,47 +1,45 @@ -Executes bash command in shell session for terminal operations like git, bun, cargo, python. +Runs bash in a shell session — terminal ops: git, bun, cargo, python. -- Use `cwd` to set working directory, not `cd dir && …` -- Prefer `env: { NAME: "…" }` for multiline, quote-heavy, or untrusted values; reference as `$NAME` -- Quote variable expansions like `"$NAME"` to preserve exact content -- PTY mode is opt-in: set `pty: true` only when the command needs a real terminal (e.g. `sudo`, `ssh` requiring user input); default is `false` -- Use `;` only when later commands should run regardless of earlier failures -- Multiple bash calls in one message run concurrently. NEVER split order-dependent commands across parallel calls — chain them with `&&` in a single call. -- Internal URIs (`skill://`, `agent://`, etc.) are auto-resolved to filesystem paths +- `cwd` sets the working dir, not `cd dir && …` +- `env: { NAME: "…" }` for multiline / quote-heavy / untrusted values; reference `$NAME` +- Quote expansions (`"$NAME"`) to preserve exact content +- `pty: true` only when the command needs a real terminal (`sudo`, `ssh` needing input); default `false` +- `;` only when later commands should run despite earlier failures +- Multiple bash calls per message run concurrently. NEVER split order-dependent commands across parallel calls — chain with `&&` in one call. +- Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths {{#if asyncEnabled}} -- Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up. +- `async: true` for long-running commands when you don't need immediate output: returns a background job ID; result delivered as a follow-up. {{/if}} -- NEVER use shell to fetch, display, list, page, or search content a dedicated tool serves: `cat`/`head`/`tail`/`less`/`more`/`ls` → `read`; `grep`/`rg`/`ag`/`ack` → `search`; `find`/`fd` → `find`; `sed -i`/`perl -i`/`awk -i` → `edit`; `echo >`/heredoc → `write`. The tools keep gitignore semantics, line anchors, and structured output that shell loses. -- NEVER trim or silence output: no `| head -n N`, `| tail -n N`, `| less`, `2>&1`, `2>/dev/null`. stderr is already merged; long output is auto-truncated with the FULL capture kept at `artifact://`. Defensive trimming is a habit from harnesses without artifact recovery — here it only destroys data the artifact would have saved. +- NEVER shell out to fetch, display, list, page, or search what a dedicated tool serves: `cat`/`head`/`tail`/`less`/`more`/`ls` → `read`; `grep`/`rg`/`ag`/`ack` → `search`; `find`/`fd` → `find`; `sed -i`/`perl -i`/`awk -i` → `edit`; `echo >`/heredoc → `write`. Tools keep gitignore semantics, line anchors, structured output shell loses. +- NEVER trim or silence output: no `| head -n N`, `| tail -n N`, `| less`, `2>&1`, `2>/dev/null`. stderr already merged; long output auto-truncated, FULL capture kept at `artifact://`. - Pipelines that COMPUTE a new fact are correct bash: `wc -l`, `sort | uniq -c`, `comm`, `cut`, `diff a b`, `shasum`. Litmus: produces a count, frequency table, set difference, or checksum no tool returns → bash. Merely moves or trims bytes a tool can fetch → use the tool. -- Returns output and exit code. -- Truncated output is retrievable from `artifact://` (linked in metadata) -- Exit codes shown on non-zero exit +- Returns output; exit code shown on non-zero exit. +- Truncated output → `artifact://` (linked in metadata). {{#if asyncEnabled}} # Timeout and async -- `timeout` (seconds) caps the **wall-clock duration** of the command. When it elapses the process is killed and the call returns with a timeout annotation. Range: `1`–`3600`s; default `300`s. -- `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result. -- For long-running daemons (dev servers, watchers): pass an explicit large `timeout` (up to `3600`). The shell session persists across calls, so a backgrounded job (`cmd &`) keeps running between bash calls on its own. +- `timeout` (seconds) caps wall-clock duration; the process is killed on elapse. +- `async: true` defers only reporting — it does NOT extend the timeout; a daemon run with `async: true` is still killed when `timeout` elapses. +- Long-running daemons (dev servers, watchers): pass a large explicit `timeout`. The shell session persists across calls, so `cmd &` keeps running between bash calls. {{/if}} {{#if autoBackgroundEnabled}} ## Auto-background -- A foreground call still running after **{{autoBackgroundThresholdSeconds}}s** converts to a background job: you get a `Background job started` notice plus the output so far, and the final result arrives as a follow-up tool call. The command keeps running — this is NOT a failure; do not retry it and do not wait synchronously. -- Auto-backgrounding does NOT extend `timeout`: the job is still killed at the original deadline. -- Need the result inline (e.g. piping into another command)? Raise `timeout` above the expected duration{{#if asyncEnabled}}, or set `async: true` up front{{/if}}. +- A long-running foreground call may convert to a background job; the final result arrives as a follow-up tool call. NOT a failure — don't retry or wait synchronously. +- Need the result inline (e.g. piping into another command)? Raise `timeout` above expected duration{{#if asyncEnabled}}, or set `async: true` up front{{/if}}. {{/if}} # Output minimizer -- Long output is truncated and test/lint runner output is filtered down to failures. Whenever the visible text was changed, a `[raw output: artifact://]` footer links the full capture — read it if a run looks suspicious or you need the exact bytes. +- Long output truncated; test/lint runner output filtered to failures. When visible text changed, a `[raw output: artifact://]` footer links the full capture — read it if a run looks suspicious or you need exact bytes. - No footer = what you see is exactly what the command emitted. diff --git a/packages/coding-agent/src/prompts/tools/browser.md b/packages/coding-agent/src/prompts/tools/browser.md index 95f1a5ed9..212713e8b 100644 --- a/packages/coding-agent/src/prompts/tools/browser.md +++ b/packages/coding-agent/src/prompts/tools/browser.md @@ -1,42 +1,42 @@ -Drives real Chromium tab; full puppeteer access via JS execution. +Drives real Chromium tab; full puppeteer access via JS. -- Static content (articles, docs, issues/PRs, JSON, PDFs, feeds)? Use `read` with the URL. Reach for browser only for JS execution, authentication, or interactive actions. +- Static content (articles, docs, issues/PRs, JSON, PDFs, feeds)? `read` the URL. Browser only for JS execution, auth, interactive actions. - Three actions: - - `open` — acquire or reuse named tab (`name` defaults `"main"`). Optional `url` (navigate once ready), `viewport`, `dialogs: "accept" | "dismiss"` (auto-handle `alert`/`confirm`/`beforeunload`; unhandled dialogs hang the page until you wire `page.on('dialog', …)`). - - `close` — release tab by `name`, or every tab with `all: true`. `kill: true` also terminates spawned-app process trees (default leaves them running). - - `run` — execute JS in an existing tab. `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value is JSON-stringified into the result; `display(value)` calls accumulate text/images. -- Tabs survive across `run` calls and in-process subagents — open once, reuse. -- Browser kinds (`app` field on `open`): + - `open` — acquire/reuse named tab (`name` defaults `"main"`). Optional `url` (navigate once ready), `viewport`, `dialogs: "accept" | "dismiss"` (auto-handle `alert`/`confirm`/`beforeunload`; else page hangs till you wire `page.on('dialog', …)`). + - `close` — release tab by `name`, or all with `all: true`. `kill: true` also kills spawned-app process trees. + - `run` — execute JS in existing tab. `code` = async function body; `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value JSON-stringified into result; `display(value)` accumulates text/images. +- Tabs survive `run` calls and in-process subagents — open once, reuse. +- Browser kinds (`app` on `open`): - default (no `app`) → headless Chromium with stealth patches. - - `app.path` → spawn absolute binary (Electron/CDP); a running instance with an open CDP port is reused. No stealth patches — NEVER tamper with a real desktop app. + - `app.path` → spawn absolute binary (Electron/CDP). No stealth patches — NEVER tamper with a real desktop app. - `app.cdp_url` → connect to existing CDP endpoint (e.g. `http://127.0.0.1:9222`). - - `app.target` (with `path`/`cdp_url`) — substring matched against url+title to pick a BrowserWindow. -- `tab` helpers; drop to raw puppeteer `page` for anything they don't cover: - - `tab.goto(url, { waitUntil? })` — navigate; clears element cache. + - `app.target` (with `path`/`cdp_url`) — substring on url+title picks BrowserWindow. +- `tab` helpers; drop to raw puppeteer `page` for anything uncovered: + - `tab.goto(url, { waitUntil? })` — navigate. - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot: `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Ids stable until next observe/goto. - - `tab.id(n)` — element id from last observe → `ElementHandle` (`.click()`, `.type()`, …). + - `tab.id(n)` — id from last observe → `ElementHandle` (`.click()`, `.type()`, …). - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)`. - - `tab.waitFor(selector)` — wait until attached; returns the `ElementHandle`. + - `tab.waitFor(selector)` — wait until attached; returns `ElementHandle`. - `tab.drag(from, to)` — endpoints: selector (center-to-center) or `{ x, y }` viewport point (canvases, sliders). - - `tab.scrollIntoView(selector)` — center element in viewport; use before clicking off-screen elements. - - `tab.select(selector, …values)` — set `` option(s); returns selection. `tab.fill` NEVER works for selects. - `tab.uploadFile(selector, …filePaths)` — attach files to ``; paths relative to cwd. - - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp`; polls `location.href` (catches SPA pushState). Returns matched URL. + - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp` (matches SPA pushState nav); returns matched URL. - `tab.waitForResponse(pattern, { timeout? })` — substring, `RegExp`, or `(response) => boolean`; returns puppeteer `HTTPResponse` (`.text()`/`.json()`/`.status()`/`.headers()`). - - `tab.evaluate(fn, …args)` — `page.evaluate` with abort signal wired; use for ad-hoc DOM reads. - - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture and attach for viewing (`silent: true` skips). Pass `save` (a path) only when a later step needs the file. - - `tab.extract(format = "markdown")` — Readability-extracted content (`"markdown"` | `"text"`); throws when nothing readable. -- Selectors: CSS plus puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; Playwright-style `p-aria/…`, `p-text/…` normalized. + - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads. + - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file. + - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable. +- Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. - MUST `open` before `run` — `run` never creates a tab. -- Default to `tab.observe()` for page state — structured data with actionable element ids. Screenshot ONLY when visual appearance matters. -- Navigation invalidates element ids — re-observe before using them. -- `code` runs with full Node access. Treat as your code, not sandboxed code. +- Default to `tab.observe()` for page state — structured data, actionable ids. Screenshot ONLY when appearance matters. +- Navigation invalidates element ids — re-observe before use. +- `code` runs with full Node access. Treat as your code, not sandboxed. -Per call: `display(value)` outputs (text/images), then the JSON-stringified return value of `code`. `run` always produces at least a status line. +Per call: `display(value)` output, then `code`'s return value. `run` always produces at least a status line. diff --git a/packages/coding-agent/src/prompts/tools/checkpoint.md b/packages/coding-agent/src/prompts/tools/checkpoint.md index 4c75486d5..af04888c7 100644 --- a/packages/coding-agent/src/prompts/tools/checkpoint.md +++ b/packages/coding-agent/src/prompts/tools/checkpoint.md @@ -4,7 +4,6 @@ Use this when you need to investigate with many intermediate tool calls (read/se Rules: - You MUST call `rewind` before yielding after starting a checkpoint. -- You MUST provide a clear `goal` explaining what you are investigating. - You NEVER call `checkpoint` while another checkpoint is active. - Not available in subagents. diff --git a/packages/coding-agent/src/prompts/tools/debug.md b/packages/coding-agent/src/prompts/tools/debug.md index 74b63da44..eddbabaa8 100644 --- a/packages/coding-agent/src/prompts/tools/debug.md +++ b/packages/coding-agent/src/prompts/tools/debug.md @@ -1,21 +1,17 @@ -Provides debugger access through the Debug Adapter Protocol (DAP). -Use for launching or attaching debuggers, setting breakpoints, stepping through execution, inspecting threads/stack/variables, evaluating expressions, capturing output, and interrupting hung programs. +Debugger access. -- You SHOULD prefer this tool over bash for program state, breakpoints, stepping, thread inspection, or interrupting a running process. -- `action: "launch"` starts a session; `program` is required, `adapter` optional (auto-selected from target path and workspace). - For Python, set `adapter: "debugpy"` and `program` to the target `.py` file; put interpreter/script flags in `args`. -- `action: "attach"` connects to an existing process: `pid` for local attach, `port` for remote attach (where the adapter supports it), `adapter` to force a specific debugger. -- **Breakpoints**: `set_breakpoint`/`remove_breakpoint` with source (`file`+`line`) or function (`function`); optional `condition` for conditional breakpoints. -- **Flow control**: `continue` (resumes; briefly waits to observe whether the program stops or keeps running), `step_over`/`step_in`/`step_out` (single-step), `pause` (interrupt a running program so you can inspect state). -- **Inspect**: `threads` (list), `stack_trace` (frames for current stopped thread), `scopes` (needs `frame_id` or a current stopped frame), `variables` (needs `variable_ref` or `scope_id`), `evaluate` (needs `expression`; `context: "repl"` for raw debugger commands when the adapter supports them), `output` (captured stdout/stderr/console), `sessions` (tracked debug sessions), `terminate`. -- Timeouts apply per-request, not to the full session lifetime. +- You SHOULD prefer this over bash for program state, breakpoints, stepping, thread inspection, or interrupting a running process. +- `action: "launch"` starts a session; `program` required, `adapter` optional. Python: `adapter: "debugpy"`, `program` = target `.py`, interpreter/script flags in `args`. +- `action: "attach"` connects to a running process: `pid` (local), `port` (remote), `adapter` forces a specific debugger. +- **Breakpoints**: `set_breakpoint`/`remove_breakpoint` with source (`file`+`line`) or function (`function`); optional `condition`. +- **Flow control**: `continue` (resume), `step_over`/`step_in`/`step_out` (single-step), `pause` (interrupt a running program). +- **Inspect**: `threads`, `stack_trace` (current stopped thread), `scopes` (needs `frame_id` or current stopped frame), `variables` (needs `variable_ref` or `scope_id`), `evaluate` (needs `expression`; `context: "repl"` for raw debugger commands), `output` (stdout/stderr/console), `sessions`, `terminate`. -- Only one active debug session is supported at a time. -- Some adapters require a launched session to receive `configurationDone` before the target actually runs; if the tool says configuration is pending, set breakpoints and then call `continue`. -- Adapter availability depends on local binaries. Common built-ins: `gdb`, `lldb-dap`, `python -m debugpy.adapter`, `dlv dap`. -- `program` must be an executable file or debug target, not a directory or interpreter name that resolves to a workspace directory. -- Python debugging requires `debugpy`; install with `pip install debugpy` if the adapter is unavailable. +- Only one active debug session at a time. +- Valid `adapter` values: `gdb`, `lldb-dap`, `python -m debugpy.adapter`, `dlv dap` (must be installed locally). +- `program` must be an executable file or debug target, not a directory or bare interpreter name. +- Python debugging requires `debugpy`; `pip install debugpy` if unavailable. diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 0c7b0f2ef..5095869ba 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -1,34 +1,34 @@ Run code in a persistent kernel using a list of cells. -Cells run in array order. State persists per language — across cells, tool calls, and `task` subagents: variables either side defines are visible to the other. Stage helpers, datasets, or live clients once; subagents use them directly — no re-importing or serializing across the boundary. +Cells run in array order. State persists per language across cells, tool calls, and `task` subagents — stage helpers/datasets/clients once, subagents reuse directly, no re-import/serialize. Cell fields: -- `language` — {{#if py}}`"py"` for the IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` for the persistent JavaScript VM{{/if}}. -- `code` — cell body, verbatim. Newlines and quotes JSON-encoded; no fences, no headers. +- `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}. +- `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers. - `title` (optional) — short transcript label (e.g. `"imports"`). -- `timeout` (optional) — per-cell seconds (1-3600, default 30). Bounds the cell's own work only; the clock pauses while `agent()`/`parallel()`/`completion()` calls are in flight, so fanouts never need a raise. Raise only for heavy local compute or long non-agent tool calls. +- `timeout` (optional) — per-cell seconds. Raise only for heavy compute or long non-agent tool calls. - `reset` (optional) — wipe this cell's language kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}} -Work incrementally: one logical step per cell (imports, define, test, use); pass multiple small cells per call; define small reusable functions for individual debugging. Workflow explanations go in the assistant message or `title`, never inside cell code. -{{#if py}}Python runs in IPython with a live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}} -On failure, errors name the failing cell ("Cell 3 failed") — resubmit only the fixed cell (plus any remaining). +Work incrementally — one logical step per cell (imports, define, test, use), many small cells per call; workflow notes in the assistant message or `title`, never in cell code. +{{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}} +Errors name the failing cell ("Cell 3 failed") — resubmit the fixed cell + any remaining. -{{#ifAll py js}}Same helpers in both runtimes, same positional order. Python: helpers run synchronously; trailing options are keyword args. JavaScript: helpers are async and `await`able; trailing options are ONE trailing object literal, never positional (extra positional args throw).{{else}}{{#if py}}Helpers run synchronously. Trailing options are keyword arguments.{{/if}}{{#if js}}Helpers are async and `await`able. Trailing options are ONE trailing object literal, never positional (extra positional args throw).{{/if}}{{/ifAll}} +{{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/`await`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/`await`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}} ``` display(value) → None - Render value in cell output, shows presentable values natively (figures, images, dataframes) + Cell output; figures/images/dataframes shown natively. print(value, ...) → None - Print to text output. + Text output. read(path, offset?=1, limit?=None) → str - Read file as text; offset/limit are 1-indexed lines. Accepts `local://…`. + File as text; offset/limit 1-indexed lines. Accepts `local://…`. write(path, content) → str - Write file (creates parents); returns resolved path. `local://…` persists across turns / subagents. + Write file (creates parents) → resolved path. `local://…` persists across turns/subagents. append(path, content) → str - Append to file; returns resolved path. Accepts `local://…`. + Append → resolved path. Accepts `local://…`. tree(path?=".", max_depth?=3, show_hidden?=False) → str Directory tree. diff(a, b) → str @@ -36,35 +36,35 @@ diff(a, b) → str env(key?=None, value?=None) → str | None | dict No args → full env dict; one → value of `key`; two → set `key=value`, return value. output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict] - Read task/agent output by id; one id → text/dict, multiple → list. + Task/agent output by id; one → text/dict, multiple → list. tool.(args) → unknown - Invoke any session tool; `args` is its parameter object. + Invoke any session tool; `args` = its parameter object. completion(prompt, model?="default", system?=None, schema?=None) → str | dict - Oneshot stateless completion (no history, no tools). `model` tier: "smol" (fast) | "default" (session model) | "slow" (most capable). JSON-Schema `schema` forces structured output, returns parsed object. + Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object. {{#if spawns}}agent(prompt, agent_type?="task", model?=None, label?=None, schema?=None, return_handle?=False) → str | dict - Run a subagent, return its final output. `agent_type`/`agentType` picks another discovered agent; `schema` as in completion(). Share background via `local://` files referenced in the prompt. `return_handle`/`returnHandle` → a DAG node dict { text, output, handle: "agent://", id, agent } (parsed object under `data` when `schema` set) so a downstream stage references the transcript by handle instead of re-inlining it. + Run a subagent → final output. `agent_type`/`agentType` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `return_handle`/`returnHandle` → DAG node dict { text, output, handle: "agent://", id, agent } (parsed under `data` when `schema` set). {{#if js}} JS: options are ONE trailing object — agent(prompt, { agentType, schema, returnHandle }). {{/if}} {{/if}} parallel(thunks) → list - Run thunks through a bounded pool (as wide as a `task` batch — don't pre-shrink), preserving input order. Barrier: returns when all finish; a throwing thunk propagates. + Thunks through a bounded pool (wide as a `task` batch — don't pre-shrink), input order kept; returns when all finish, a throwing thunk propagates. pipeline(items, ...stages) → list - Map items through one-arg stages left-to-right, barrier between stages; stage 1 gets the item, later stages the previous result. Same pool width as parallel(). + Map items through one-arg stages left-to-right, barrier between stages; stage 1 gets the item, later stages the previous result. log(message) → None Progress line above the status tree. phase(title) → None - Start a phase grouping subsequent status lines. + Phase grouping subsequent status lines. budget → per-turn token budget - {{#if py}}`budget.total` (ceiling or None), `budget.spent()`, `budget.remaining()` (math.inf when no ceiling), `budget.hard` (bool).{{/if}}{{#if js}}`await budget.total()` (ceiling or null), `await budget.spent()`, `await budget.remaining()` (Infinity when no ceiling), `await budget.hard()`.{{/if}} Ceiling comes from a `+Nk` directive (advisory) or `+Nk!`/Goal Mode (hard — `agent()` refuses to spawn past it); otherwise None/null, spend still tracked across the turn. + {{#if py}}`budget.total` (ceiling or None), `budget.spent()`, `budget.remaining()` (math.inf when no ceiling), `budget.hard`.{{/if}}{{#if js}}`await budget.total()` (ceiling or null), `await budget.spent()`, `await budget.remaining()` (Infinity when no ceiling), `await budget.hard()`.{{/if}} Ceiling: `+Nk` (advisory) or `+Nk!`/Goal Mode (hard — `agent()` won't spawn past it); spend still tracked. ``` {{#if spawns}} -Build a dependency graph by piping handles through the stage helpers — ephemeral, in-session, acyclic waves: -- **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}})` result; it carries `handle` (`agent://`) + `output`. -- **Wire edges by reference.** Embed an upstream node's `handle` or `output` in the dependent stage's prompt so a large transcript flows by reference, never re-inlined. For bulk artifacts, `write("local://.md", …)` and pass the URI. -- **`pipeline(items, *stages)` = staged waves** with a barrier between stages (every item clears stage N before any enters stage N+1) — the linear spine of a DAG. **`parallel(thunks)` = one wave** of independent nodes. -- **Isolate failure.** A raising node re-raises the lowest-index error and aborts its wave; wrap each risky node in try/except so a failed node degrades only its dependent subtree while independent branches still finish. -- **Acyclic only.** A node never waits on its own descendant; cycles are an authoring bug, not a supported pattern. +Pipe handles through stage helpers to build a dependency graph — acyclic waves: +- **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}})` result; carries `handle` (`agent://`) + `output`. +- **Wire edges by reference.** Put an upstream node's `handle`/`output` in the dependent stage's prompt — large transcript never re-inlined. Bulk: `write("local://.md", …)`, pass the URI. +- **`pipeline(items, *stages)` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **`parallel(thunks)` = one wave** of independent nodes. +- **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish. +- **Acyclic only.** A node never waits on its own descendant. {{/if}} diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index 76919a7ce..5f12ad0af 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -1,21 +1,17 @@ -Finds files and directories using fast pattern matching that works with any codebase size. +Finds files and directories via fast pattern matching, any codebase size. -- `paths` is required and accepts an array of globs, files, or directories -- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`). -- `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores -- `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored -- `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit -- `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly -- You SHOULD perform multiple searches in parallel when potentially useful +- `paths`: array of globs, files, or directories. +- `gitignore` (default `true`) hides `.gitignore` matches. Set `gitignore: false` to find `.env*`, `*.log`, fresh build outputs, or anything your repo ignores. +- `hidden` (default `true`); combine with `gitignore: false` to surface dotfiles also gitignored. -Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `#

/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB. +Matching paths sorted by mtime (newest first), grouped under `# /` headers with basenames below; directories get a trailing `/`. -For open-ended searches requiring multiple rounds of globbing and searching, you MUST use Task tool instead. +Open-ended searches needing multiple rounds of globbing/searching: you MUST use the Task tool instead. diff --git a/packages/coding-agent/src/prompts/tools/github.md b/packages/coding-agent/src/prompts/tools/github.md index 35bd7af5d..3997cd105 100644 --- a/packages/coding-agent/src/prompts/tools/github.md +++ b/packages/coding-agent/src/prompts/tools/github.md @@ -1,21 +1,17 @@ -GitHub CLI tool with a single op-based dispatch. Wraps `gh` for repositories, pull requests, search, checkout, push, and Actions watch workflows. For reading a single issue or PR view, use the `issue://` or `pr://` URL schemes (cached automatically). For reading PR diffs, use `pr:///diff` (changed-file listing), `pr:///diff/` (single file slice, 1-indexed), or `pr:///diff/all` (full unified diff). +Op-based `gh` wrapper: repos, PRs, search, checkout, push, Actions watch. Read an issue/PR via `issue://`/`pr://`. PR diffs: `pr:///diff` (file listing), `pr:///diff/` (file slice, 1-indexed), `pr:///diff/all` (full diff). -Pick the operation via `op`. Each op uses a subset of the parameters: -- `repo_view` — Read repository metadata. Optional `repo` (owner/repo) and `branch`. Falls back to the current checkout or default `gh` repo. -- `pr_create` — Create a pull request. Either provide `title` (and optional `body`) or set `fill: true` to auto-fill from commits. Optional `base` (target, defaults to repo default), `head` (source, defaults to current branch), `draft`, `repo`, `reviewer[]`, `assignee[]`, `label[]`. Returns the new PR URL plus a summary. -- `pr_checkout` — Check one or more pull requests out into dedicated git worktrees. Optional `pr` (number, URL, branch, or array of any of those — pass an array to batch-check-out multiple PRs in one call), `repo`, `force` (reset existing local branch). -- `pr_push` — Push a checked-out PR branch back to its source branch. Requires the branch to have been checked out via `op: pr_checkout`. Optional `branch`; defaults to the current checked-out git branch. Optional `forceWithLease`. -- `search_issues` — Search issues using normal GitHub issue search syntax. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`, `dateField`. -- `search_prs` — Search pull requests using normal GitHub PR search syntax. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`, `dateField`. -- `search_code` — Search code with GitHub code search syntax. Required `query`. Optional `repo`, `limit`. Returns matching paths with surrounding fragments. Date filtering (`since`/`until`) is **not** supported by GitHub code search. -- `search_commits` — Search commits. Optional `query` (required unless `since`/`until` is set), `repo`, `limit`, `since`, `until`. `dateField` is ignored — always uses `committer-date`. -- `search_repos` — Search repositories across GitHub. Optional `query` (required unless `since`/`until` is set), `limit`, `since`, `until`, `dateField` (use query qualifiers like `org:`, `language:` instead of `repo`). -- All `search_*` ops except `search_repos` default `repo` to the current checkout's `owner/repo` when omitted; pass an explicit `repo:`/`org:`/`user:` qualifier in `query` to search outside it. -- Date filter format for `since` / `until`: relative duration `` (`m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `12h`, `2w`), an ISO date `YYYY-MM-DD`, or an ISO datetime. Translated to a single GitHub-search qualifier (`created:≥…`, `created:≤…`, or `created:since..until`). `dateField: "updated"` maps to `updated:` for issues/prs and `pushed:` for repos. When you only want a date filter and no keywords, omit `query` entirely. -- `run_watch` — Watch a GitHub Actions workflow run. Optional `run` (id or URL). Omitting `run` watches all workflow runs for the current HEAD commit; `branch` falls back to the current branch. Optional `tail` (log lines per failed job). Fast-fails on the first job failure and returns tailed logs for the failed jobs. +Pick op via `op`. Beyond the field descriptions, per op: +- `repo_view` — omit `repo` to view the current checkout. +- `pr_create` — `head` defaults to the current branch. +- `pr_checkout` — checks PR(s) out into dedicated git worktrees, not your working tree; pass an array of `pr` to batch multiple in one call. +- `pr_push` — requires the branch to have been checked out first via `op: pr_checkout`. +- `search_issues`/`search_prs`/`search_commits`/`search_repos` — `query` is optional when `since`/`until` is set (omit it for a date-only filter). `search_code` supports neither: `query` is required and `since`/`until` are rejected. +- `search_*` default `repo` to the current checkout's `owner/repo`; pass a `repo:`/`org:`/`user:` qualifier in `query` to search elsewhere. `search_repos` is the exception — it ignores `repo`; scope it with `org:`/`language:` qualifiers in `query`. +- `since`/`until` — relative duration (`` + `m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `2w`), ISO date (`YYYY-MM-DD`), or ISO datetime. `dateField: "updated"` filters on update time (issues/PRs) or push time (repos), not creation. +- `run_watch` — omit `run` to watch every run for the current HEAD (`branch` falls back to current). Fast-fails on the first job failure. -Returns a concise readable summary tailored to the chosen op (repo metadata, PR metadata, diff text, search results, checkout info, push target, or workflow run snapshot). For `run_watch`, the full failed-job logs are saved as a session artifact when failures occur. +Concise summary per op. `run_watch` failures save full logs to a session artifact. diff --git a/packages/coding-agent/src/prompts/tools/goal.md b/packages/coding-agent/src/prompts/tools/goal.md index 1e3c74a60..956d5e986 100644 --- a/packages/coding-agent/src/prompts/tools/goal.md +++ b/packages/coding-agent/src/prompts/tools/goal.md @@ -7,12 +7,5 @@ Use a single `op` field: - `complete` marks the goal complete after you have verified every deliverable against current evidence. - `drop` discards the current goal without completing it. -Examples: -- `goal({"op":"create","objective":"Implement feature X","token_budget":50000})` -- `goal({"op":"get"})` -- `goal({"op":"resume"})` -- `goal({"op":"complete"})` -- `goal({"op":"drop"})` - NEVER call `complete` because a budget is low or a turn is ending. Call it only when the goal is actually done and verified. If `get` shows a paused goal, call `resume` before continuing work on it. diff --git a/packages/coding-agent/src/prompts/tools/inspect-image.md b/packages/coding-agent/src/prompts/tools/inspect-image.md index 0badcff33..8defdedc1 100644 --- a/packages/coding-agent/src/prompts/tools/inspect-image.md +++ b/packages/coding-agent/src/prompts/tools/inspect-image.md @@ -2,7 +2,7 @@ Inspects an image file with a vision-capable model and returns compact text anal - Use this for image understanding tasks (OCR, UI/screenshot debugging, scene/object questions) -- Provide `path` to the local image file +- Provide `path` as a local image file path, `Image #N` attachment label, or `attachment://N` URI - Write a specific `question`: - what to inspect - constraints (for example: "quote visible text verbatim", "only report confirmed findings") @@ -17,7 +17,6 @@ Inspects an image file with a vision-capable model and returns compact text anal -- Parameters are strict: only `path` and `question` are allowed - If image submission is blocked by settings, the tool will fail with an actionable error - If configured model does not support image input, configure a vision-capable model role before retrying diff --git a/packages/coding-agent/src/prompts/tools/irc.md b/packages/coding-agent/src/prompts/tools/irc.md index 0f0311c86..8092d75c1 100644 --- a/packages/coding-agent/src/prompts/tools/irc.md +++ b/packages/coding-agent/src/prompts/tools/irc.md @@ -1,42 +1,33 @@ -Sends short text messages to other agents in this process and receives theirs. +Send/receive short text messages between agents in this process. -- Main agent is `Main`; subagents reuse their task id (`AuthLoader`, or `AuthLoader-2` when the name repeats). +- Main agent is `Main`; subagents reuse their task id (`AuthLoader`, `AuthLoader-2` on repeat). - `op: "list"` — peers with status (`running` | `idle` | `parked`), unread count, parent, last activity. Use when unsure who exists. -- `op: "send"` — fire-and-forget `message` to `to` (peer id, or `"all"` to broadcast to live peers). Returns per-recipient receipts immediately; NEVER waits for the recipient to act. Outcomes: `injected` (mid-turn; folded in at next step boundary), `woken` (idle peer started a turn), `revived` (parked peer brought back and woken), `failed`. -- Messaging an `idle`/`parked` peer is how you wake it — there is no separate revive call. -- `send` + `await: true` — round-trip: send, then block until that peer's next message (or timeout). Invalid with `to: "all"`. -- `op: "wait"` — block until a message arrives (optionally only `from` one peer); consumes and returns it. Timeout = clean "no message", not an error. -- `op: "inbox"` — drain pending messages without blocking (`peek: true` leaves them unread). -- `replyTo` — id of the message you are answering, so the sender can correlate. -- Replies arrive only when the recipient sends one. Exception: `await: true` to a peer stuck mid-turn (async execution disabled, e.g. blocked in a synchronous task spawn) gets a side-channel auto-reply from its context. For background on a peer, `read` `history://` instead of interrogating it. +- `op: "send"` — fire-and-forget; returns per-recipient receipts immediately, NEVER waits for the recipient to act. Outcomes: delivered, or `failed` (unreachable). `to: "all"` broadcasts to live peers. +- Messaging an `idle`/`parked` peer wakes it — no separate revive call. +- `op: "wait"` — block for a message (optionally only `from` one peer); consumes + returns it. Timeout = clean "no message", not an error. +- `op: "inbox"` — drain pending messages without blocking. +- Replies arrive only when the recipient sends one. For peer background, `read` `history://`, don't interrogate. -Reach for `irc` proactively when continuing alone is wasteful or wrong; when in doubt, message. -- **Unexpected state** — missing file, config contradicting the assignment, API/tool behaving differently than told. DM `Main` (or your spawner) instead of guessing. -- **Blocked by another agent** — a peer holds the file/branch/resource or decision you need, or started the change you're about to make. DM them (or broadcast to discover who) before duplicating work. -- **Decision outside your scope** — a genuine fork the assignment didn't pre-decide. Ask the requester rather than picking unilaterally. -- **Coordination** — a peer's in-flight work overlaps yours (the roster shows each peer's role and current activity); message before editing a shared file or duplicating a sibling's change. +Reach for `irc` when going alone is wasteful or wrong; when in doubt, message. +- **Unexpected state** — missing file, config contradicting the assignment, API/tool behaving differently than told. DM `Main` (or your spawner), don't guess. +- **Blocked by another agent** — a peer holds the file/branch/resource/decision you need, or started your change. DM them (or broadcast to find who) before duplicating work. +- **Decision outside your scope** — a genuine fork the assignment didn't pre-decide. Ask the requester, don't pick unilaterally. +- **Coordination** — a peer's in-flight work overlaps yours (roster shows each peer's role + activity); message before editing a shared file or duplicating a sibling's change. NEVER for: routine progress updates, things a tool call can verify, questions your assignment/repo/docs already answer. -Applies to sending and replying. +Applies to sending + replying. - **Plain prose only.** NEVER JSON status payloads like `{"type":"task_completed",…}` — write a normal sentence. - **NEVER quote the message you answer.** Lead with the answer; set `replyTo`. - **Learn about peers via IRC** — NEVER grep artifacts, read other sessions' JSONL, or shell-poke. DM them, or `read` `history://`. -- **Send, then keep working.** `wait`/`await: true` only when you genuinely cannot proceed. NEVER "did you get my message?". A `failed` receipt = peer unreachable — move on; NEVER retry in a loop. -- **Answer expected questions** via `irc send` to the sender (finishing your current step first is fine). +- **Send, then keep working.** `wait`/`await: true` only when you cannot proceed. NEVER "did you get my message?". A `failed` receipt = peer unreachable — move on; NEVER retry in a loop. +- **Answer expected questions** via `irc send` to the sender (finish your current step first). - **Stay terse.** One question per send; share files via `local://`/`memory://`/`artifact://` URLs, never pasted blobs. - **Address peers by exact id** from `op: "list"` (e.g. `AuthLoader`, `Main`). NEVER invent friendly names. - **NEVER IRC what a tool answers.** A `read`, grep, or build resolves it? Do that first. - - -- `send`: per-recipient receipts; with `await: true`, also the reply (or timeout notice). -- `wait`: the consumed message, or a clean timeout notice. -- `inbox`: pending messages, oldest first. -- `list`: peers with status, unread count, parent, last activity. - diff --git a/packages/coding-agent/src/prompts/tools/job.md b/packages/coding-agent/src/prompts/tools/job.md index 8ec4248da..0f3a2da93 100644 --- a/packages/coding-agent/src/prompts/tools/job.md +++ b/packages/coding-agent/src/prompts/tools/job.md @@ -1,20 +1,17 @@ Inspects, waits, or cancels async jobs. -Background job results are delivered automatically when complete. Reach for this tool only when you need to intervene. +Results arrive automatically on completion; reach for this tool only to intervene. # Operations ## `list: true` -Use to inspect what's running. +Inspect what's running. ## `poll: [id, …]` -Block until the specified jobs finish or the wait window elapses. Omit `poll` (with no `list`/`cancel`) to wait on ALL running jobs — NEVER enumerate ids you don't need to filter. -- Use when you are genuinely blocked on a result and have no other work to do. -- Returns the current snapshot when the timer elapses; running jobs remain running. -- Completed jobs include their final output in the returned snapshot. -- With Max Poll Time set to `smart` (the default), the wait window adapts: it starts at ~5s and lengthens with each back-to-back poll (up to ~5m), then resets to ~5s after you go a while without polling. Spinning in a poll loop costs progressively more; do real work between polls. +Block until specified jobs finish or the wait window elapses. Omit `poll` (no `list`/`cancel`) to wait on ALL running jobs — NEVER enumerate ids you don't need to filter. +- Use only when genuinely blocked with no other work. +- Completed jobs include final output. ## `cancel: [id, …]` Stop running jobs. - Use when a job is stalled, hung, or no longer needed. -- Returns immediately after cancelling. diff --git a/packages/coding-agent/src/prompts/tools/learn.md b/packages/coding-agent/src/prompts/tools/learn.md index ed412ad5b..299de64a8 100644 --- a/packages/coding-agent/src/prompts/tools/learn.md +++ b/packages/coding-agent/src/prompts/tools/learn.md @@ -1,7 +1,7 @@ Capture a reusable lesson into long-term memory, and optionally mint or enhance a managed skill in the same call. -Use after solving something whose insight will pay off again: a non-obvious fix, a project convention you had to discover, a workflow that worked. The `memory` field is the durable, self-contained lesson — include what, when, and why so a future session understands it without this conversation. +Use after solving something whose insight will pay off again: a non-obvious fix, a project convention you had to discover, a workflow that worked. -Provide the optional `skill` object when the lesson is a repeatable *procedure* worth codifying as a `SKILL.md` (not just a fact). Managed skills are written to an isolated directory (`~/.omp/agent/managed-skills`) and are surfaced like normal skills next session. They NEVER touch user-authored skills. `body` is the SKILL.md content in markdown — do not include frontmatter; it is generated from `name` and `description`. Use `action: "update"` to enhance an existing managed skill. +Provide the optional `skill` object when the lesson is a repeatable *procedure* worth codifying as a `SKILL.md` (not just a fact). Managed skills are written to an isolated directory (`~/.omp/agent/managed-skills`) and are surfaced like normal skills next session. They NEVER touch user-authored skills. Frontmatter is generated from `name` and `description`. Capture sparingly and specifically. One strong, reusable lesson beats several vague ones. diff --git a/packages/coding-agent/src/prompts/tools/lsp.md b/packages/coding-agent/src/prompts/tools/lsp.md index b3a137c7c..65b489f86 100644 --- a/packages/coding-agent/src/prompts/tools/lsp.md +++ b/packages/coding-agent/src/prompts/tools/lsp.md @@ -1,42 +1,39 @@ -Interacts with Language Server Protocol servers for code intelligence. +Language Server Protocol (LSP) servers for code intelligence. -- `diagnostics`: Get errors/warnings for a file, a glob of files, or the entire workspace (`file: "*"`) -- `definition`: Go to symbol definition → file path + position + 3-line source context -- `type_definition`: Go to symbol type definition → file path + position + 3-line source context -- `implementation`: Find concrete implementations → file path + position + 3-line source context -- `references`: Find references → locations with 3-line source context (first 50), remaining location-only -- `hover`: Get type info and documentation → type signature + docs -- `symbols`: List symbols in a file, or search workspace with `file: "*"` and a `query` -- `rename`: Rename symbol across codebase → preview or apply edits -- `rename_file`: Rename or move a file/directory; sends `workspace/willRenameFiles` so LSP servers update import paths and other references → preview or apply edits + filesystem rename -- `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index -- `status`: Show active language servers -- `capabilities`: Dump per-server capabilities (standard + experimental + executeCommand list) for discovery — file scopes to one server, omitted/`"*"` lists every active server -- `request`: Send a raw LSP request to a server — `query` is the method name (e.g., `rust-analyzer/expandMacro`, `typescript/goToSourceDefinition`, `workspace/executeCommand`); use `payload` for arbitrary JSON params or let the tool auto-build them from `file`/`line`/`symbol` -- `reload`: Restart a specific server (via `file`) or all servers with `file: "*"` +- `diagnostics`: errors/warnings for a file, glob, or workspace (`file: "*"`) +- `definition`: symbol definition +- `type_definition`: symbol's type definition +- `implementation`: concrete implementations +- `references`: all references +- `hover`: type info / docs +- `symbols`: list file symbols, or search workspace with `file: "*"` + `query` +- `rename`: rename symbol codebase-wide +- `rename_file`: rename/move a file/directory; updates import paths + other references +- `code_actions`: list quick-fixes/refactors/import actions; apply one when `apply: true` + `query` matches title or index +- `status`: active language servers +- `capabilities`: per-server capabilities +- `request`: raw LSP request — `query` = method name (e.g. `rust-analyzer/expandMacro`, `workspace/executeCommand`); `payload` = JSON params +- `reload`: restart one server (via `file`) or all (`file: "*"`) -- `file`: File path, glob pattern (e.g. `src/**/*.ts`), or `"*"` for workspace scope. Globs are expanded locally before dispatch. `"*"` routes `diagnostics`/`symbols`/`reload` to their workspace-wide form. -- `line`: 1-indexed line number for position-based actions -- `symbol`: Substring on the target line used to resolve column automatically. Append `#N` to pick the Nth occurrence on that line (1-indexed; default 1) — e.g. `foo#2` selects the second `foo`. -- `query`: Symbol search query, code-action kind filter / selector (list/apply mode), or LSP method name when `action: request` -- `new_name`: Required for `rename` (new symbol identifier) and `rename_file` (destination path) -- `apply`: Apply edits for rename/rename_file/code_actions (default true for rename and rename_file; list mode for code_actions unless explicitly true) -- `payload`: JSON-encoded params for `action: request`. Overrides the auto-built `{ textDocument, position }` shape when present. -- `timeout`: Request timeout in seconds (clamped to 5-60, default 20) +- `file`: path, glob (e.g. `src/**/*.ts`), or `"*"` for workspace scope +- `line`: 1-indexed line for position-based actions +- `symbol`: substring on the target line. Append `#N` for the Nth occurrence — e.g. `foo#2` = second `foo`. +- `query`: symbol search, code-action kind filter/selector (list/apply mode), or LSP method name when `action: request` +- `new_name`: required for `rename` (new identifier) and `rename_file` (destination path) +- `apply`: apply edits for rename/rename_file/code_actions (default true for rename/rename_file; code_actions list mode unless true) +- `payload`: JSON params for `action: request` +- `timeout`: seconds -- Requires running LSP server for target language -- Some operations require file to be saved to disk -- Glob expansion samples up to 20 files per request; use `file: "*"` for broader coverage -- When `symbol` is provided for position-based actions, missing symbols or out-of-bounds `#N` occurrence selectors return an explicit error instead of silently falling back +- Missing `symbol` or out-of-bounds `#N` → explicit error. -- You MUST use `lsp` for symbol-aware operations (rename, find references, go to definition/implementation, code actions) whenever a language server is available — it is safer and more accurate than text-based alternatives. -- You NEVER perform cross-file renames with `ast_edit`, `sed`, or manual edits when `lsp` `rename` can do it. Text-based renames miss shadowing, re-exports, and usages in other files. -- You SHOULD use `lsp` `code_actions` for imports, quick-fixes, and refactors the language server already knows how to apply. +- You MUST use `lsp` for symbol-aware operations (rename, references, definition/implementation, code actions) whenever a language server is available — safer and more accurate than text-based alternatives. +- You NEVER perform cross-file renames with `ast_edit`, `sed`, or manual edits when `lsp` `rename` can do it. Text-based renames miss shadowing, re-exports, and cross-file usages. +- You SHOULD use `lsp` `code_actions` for imports, quick-fixes, and refactors the server already applies. diff --git a/packages/coding-agent/src/prompts/tools/manage-skill.md b/packages/coding-agent/src/prompts/tools/manage-skill.md index d957964f0..875b7ccdf 100644 --- a/packages/coding-agent/src/prompts/tools/manage-skill.md +++ b/packages/coding-agent/src/prompts/tools/manage-skill.md @@ -2,8 +2,8 @@ Create, update, or delete a managed skill — a `SKILL.md` written to an isolate Managed skills are for repeatable procedures worth codifying: a setup sequence, a debugging recipe, a project-specific workflow. They are kept separate from user-authored skills and this tool NEVER edits those. -- `action: "create"` — requires `name`, `description`, and `body`. Fails if the skill already exists. -- `action: "update"` — requires `name`, `description`, and `body`. Fails if the skill does not exist. Overwrites the body. -- `action: "delete"` — requires `name`. Fails if the skill does not exist. +- `action: "create"` — fails if the skill already exists. +- `action: "update"` — overwrites the body; fails if the skill does not exist. +- `action: "delete"` — fails if the skill does not exist. -`name` is kebab-case (lowercase letters, digits, hyphens). `description` is a single line stating when to use the skill — it drives discovery, so make it specific. `body` is the SKILL.md content in markdown; do not include frontmatter (it is generated from `name` and `description`). +`name` is kebab-case (lowercase letters, digits, hyphens). The `description` drives discovery, so make it specific. Do not include frontmatter in `body`; it is generated from `name` and `description`. diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index b452f59a4..bce1be1da 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -1,50 +1,48 @@ -Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string. +Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via one `path`. -- You SHOULD parallelize independent reads when exploring related files. -- You SHOULD reach for `read` — not a browser/puppeteer tool — for web content; browser only when `read` cannot deliver it. +- SHOULD parallelize independent reads. +- SHOULD use `read` (not a browser tool) for web content; browser only when `read` can't deliver. ## Parameters -- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:` for line ranges or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`). +- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:` for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`). ## Selectors -Append `:` to `path`; bare path = default mode. - - _(none)_ — parseable code → structural summary; other files → from start (up to {{DEFAULT_LIMIT}} lines). - `:50` / `:50-` — from line 50 onward. - `:50-200` — lines 50–200 inclusive. -- `:50+150` — 150 lines starting at 50. -- `:20+1` — anchor line 20 (single-range reads pad ≤1 leading / ≤3 trailing context lines). -- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged); exact bounds, no padding. -- `:raw` — verbatim; no anchors, no summary, no line prefixes. -- `:2-4:raw` / `:raw:2-4` — range AND verbatim; compose in either order. +- `:50+150` — 150 lines from 50. +- `:20+1` — anchor line 20. +- `:5-16,960-973` — multiple ranges in one call. +- `:raw` — verbatim; no anchors/summary/line prefixes. +- `:2-4:raw` / `:raw:2-4` — range AND verbatim; either order. - `:conflicts` — one line per unresolved git merge conflict block. # Files -- Directory path → depth-limited dirent listing. +- Directory → depth-limited dirent listing. {{#if IS_HL_MODE}} -- File with explicit selector → snapshot tag header + numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag. +- File + selector → snapshot tag header + numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy `[PATH#TAG]` for anchored edits; ops use bare line numbers. NEVER fabricate the tag. {{else}} {{#if IS_LINE_NUMBER_MODE}} -- File with explicit selector → lines prefixed with numbers: `41|def alpha():`. +- File + selector → numbered lines: `41|def alpha():`. {{/if}} {{/if}} -- Parseable code without selector → **structural summary**: declarations kept, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. :5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content — NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice. +- Parseable code, no selector → **structural summary**: declarations kept, body elided with `…`. Footer names the recovery selector; re-issue ONLY the ranges you need. # Documents & Notebooks -PDF, Word, PowerPoint, Excel, RTF, EPUB → extracted text. Notebooks (`.ipynb`) → editable `# %% [type] cell:N` text; edits round-trip to the underlying JSON preserving metadata. `:raw` bypasses the converter. +PDF, Word, PowerPoint, Excel, RTF, EPUB → extracted text. Notebooks (`.ipynb`) → editable `# %% [type] cell:N` text. `:raw` bypasses the converter. # Images {{#if INSPECT_IMAGE_ENABLED}} -Image path → metadata (mime, bytes, dimensions, channels, alpha). For visual analysis, call `inspect_image` with the path and a question. +Image → metadata. Visual analysis: call `inspect_image` with the path and a question. {{else}} -Image path → decoded image inline (PNG, JPEG, GIF, WEBP) for direct visual analysis. +Image → decoded inline (PNG, JPEG, GIF, WEBP) for direct visual analysis. {{/if}} # Archives @@ -63,16 +61,16 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`: # URLs -- Reader-mode by default: HTML, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown. -- `:raw` → untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetch. -- Bare `host:port` collides with the selector grammar — add a trailing slash: `https://example.com/:80`. +- Reader-mode default: HTML, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown. +- `:raw` → untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the fetch. +- Bare `host:port` collides with selector grammar — add a trailing slash: `https://example.com/:80`. # Internal URIs -All `path` URI schemes resolve transparently and take the same line selectors. `artifact://` recovers full output a previous bash/eval/tool result spilled or truncated. `history://` is an agent's transcript as concise markdown; bare `history://` lists agents. +All URI schemes take the same line selectors. `artifact://` recovers full output a bash/eval/tool result spilled or truncated. `history://` = agent transcript; bare `history://` lists agents. -- You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN bash calls, however short or convenient. +- MUST use `read` for every file/directory/archive/URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN bash calls, however convenient. - Line ranges go in the selector (`path="src/foo.ts:50-200"`) — NEVER `sed -n`, `awk NR`, or `head`/`tail` pipelines. - Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content. diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index 15eaaac66..3f404d660 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -1,7 +1,6 @@ Performs string replacements in files with fuzzy whitespace matching. -- Params MUST be `{ path, edits }`; `path` is required at the top level and applies to every replacement - You MUST use the smallest `old_text` that uniquely identifies the change - If `old_text` is not unique, you MUST expand it with more context or use `all: true` to replace all occurrences - You SHOULD prefer editing existing files over creating new ones diff --git a/packages/coding-agent/src/prompts/tools/resolve.md b/packages/coding-agent/src/prompts/tools/resolve.md index 64f76f37b..206360e87 100644 --- a/packages/coding-agent/src/prompts/tools/resolve.md +++ b/packages/coding-agent/src/prompts/tools/resolve.md @@ -1,9 +1,4 @@ -Resolves a pending action by either applying or discarding it. -- `action` is required: - - `"apply"` persists / submits the pending action. - - `"discard"` rejects the pending action. -- `reason` is required: one short complete sentence explaining why, starting with a capital letter and ending with a period. -- `extra` (optional) is free-form metadata passed to the resolving tool. When the pending action is a plan-approval gate, supply `extra.title` (kebab/PascalCase slug for the approved plan filename). For preview-style pending actions (e.g. `ast_edit`), `extra` is unused. - -Valid whenever a pending action exists — either a preview-style staging (e.g. `ast_edit`) or a long-lived approval gate. -Call fails with an error when no pending action exists. +Resolves a pending action — apply or discard. Valid only when a pending action exists; errors otherwise. +- `action` (required): `"apply"` persists/submits; `"discard"` rejects. +- `reason` (required): one short sentence explaining why. +- `extra` (optional): free-form metadata. Plan-approval gate? Supply `extra.title` (kebab/PascalCase slug = approved plan filename). Unused for preview actions (e.g. `ast_edit`). diff --git a/packages/coding-agent/src/prompts/tools/rewind.md b/packages/coding-agent/src/prompts/tools/rewind.md index ada1544ca..a306b3946 100644 --- a/packages/coding-agent/src/prompts/tools/rewind.md +++ b/packages/coding-agent/src/prompts/tools/rewind.md @@ -3,7 +3,7 @@ End an active checkpoint. Rewind context to it, replacing intermediate explorati Call immediately after `checkpoint`-started investigative work. Requirements: -- `report` is REQUIRED and MUST be concise, factual, and actionable. +- `report` MUST be concise, factual, and actionable. - Include key findings, decisions, and any unresolved risks. - AVOID raw scratch logs unless essential. - You MUST call this before yielding if a checkpoint is active. diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index 714354d19..097d1a3fd 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -1,24 +1,22 @@ -Searches files using powerful regex matching. +Searches files using regex. -- Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(? {{#if IS_HL_MODE}} -- Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers. +- Per matched file: snapshot tag header + numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy header for anchored edits; ops use bare line numbers. {{else}} {{#if IS_LINE_NUMBER_MODE}} -- Text output is line-number-prefixed +- Output is line-number-prefixed. {{/if}} {{/if}} -- You MUST use the built-in `search` tool for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any other CLI search via Bash — even for a single match, even "just to check quickly", even piped through other commands. -- Bash `grep`/`rg` loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The `search` tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable. -- If the search is open-ended, requiring multiple rounds, you MUST use the Task tool with the explore subagent instead of chaining `search` calls yourself. +- MUST use built-in `search` for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any CLI search via Bash — not even for one match or a quick check. +- Open-ended search needing multiple rounds? MUST use the Task tool with the explore subagent, NOT chained `search` calls. diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index 4ac0cf036..b886064d8 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -1,73 +1,68 @@ -{{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents to work in the background — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}} +{{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents in the background — one per `tasks[]` item; single spawn = one-item batch.{{else}}Spawns ONE subagent per call in the background.{{/if}} -- Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields. -- Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. To launch several subagents, you MUST batch them into a single call's `tasks[]` — they share `context` once instead of duplicating it. Separate `task` calls in one message are ONLY for spawns needing a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session. -- If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** — only for stalled/abandoned work. -{{else}}{{#if batchEnabled}}Runs subagents synchronously — one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}} +- Non-blocking: returns agent id{{#if batchEnabled}}s{{/if}} + job id{{#if batchEnabled}}s{{/if}} immediately; each result auto-delivered on yield. +- Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}. +- Blocked on a result? `job poll`; else keep working. `job cancel` kills a task, **cannot carry a message** — only for stalled/abandoned work. +{{else}}{{#if batchEnabled}}Runs subagents synchronously — one per `tasks[]` item; single spawn = one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}} -- Spawning is blocking: the call returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline. -- Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. To launch several subagents, you MUST batch them into a single call's `tasks[]` — they share `context` once instead of duplicating it. Separate `task` calls in one message are ONLY for spawns needing a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session. +- Blocking: returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline. +- Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}. {{/if}} {{#if ircEnabled}} -- Coordinate with agents via `irc` using their ids. Agents reach you and their siblings live the same way. +- Coordinate via `irc` by agent id; agents reach you + siblings live. {{/if}} - -- Finished agents stay alive: `idle` first, then `parked` after a TTL.{{#if ircEnabled}} Both remain addressable and revivable: messaging one via `irc` wakes it and runs your message as a follow-up turn. **Prefer messaging an agent that already holds the relevant context over spawning fresh** — check `irc` op:"list" for candidates.{{/if}} -- `history://` is the agent's transcript; `agent://` its latest output artifact. - - - `agent`: agent type to spawn {{#if batchEnabled}} -- `context`: shared background prepended to every assignment — goal, constraints, shared contract (see context-fmt); REQUIRED, session-specific only -- `tasks`: tasks to spawn — one subagent per item, all in parallel: - - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED - - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted +- `context`: background prepended to every assignment — goal, constraints, contract (see context-fmt); REQUIRED, session-specific only +- `tasks`: one subagent per item, all in parallel: + - `assignment`: complete self-contained instructions; one-liners / missing acceptance criteria PROHIBITED + - `id`: stable agent id, CamelCase, ≤32 chars; auto when omitted - `description`: UI label only — subagent never sees it - - `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker + - `role`: specialist identity (e.g. "Auth-flow security reviewer") — sets system-prompt persona + roster name {{#if isolationEnabled}} - - `isolated`: run this spawn in an isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards + - `isolated`: run spawn in isolated env; returns patches. Torn down at completion — not addressable after {{/if}} {{else}} -- `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted +- `id`: stable agent id, CamelCase, ≤32 chars; auto when omitted - `description`: UI label only — subagent never sees it -- `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker -- `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED +- `role`: specialist identity (e.g. "Auth-flow security reviewer") — sets system-prompt persona + roster name +- `assignment`: complete self-contained instructions; one-liners / missing acceptance criteria PROHIBITED {{#if isolationEnabled}} -- `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion — not addressable afterwards +- `isolated`: run in isolated env; returns patches. Torn down at completion — not addressable after {{/if}} {{/if}} -- **Maximize fan-out.** Issue the widest {{#if batchEnabled}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} the work decomposes into. NEVER serialize work that could run concurrently. -- **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files. +- **Maximize fan-out.** Widest {{#if batchEnabled}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} the work decomposes into. NEVER serialize parallelizable work. +- **Subagents do not verify, lint, or format.** Each assignment MUST tell the subagent: skip all gates, formatters, project-wide build/test/lint. You run them once at the end across changed files. - No globs, no "update all", no package-wide scope. Fan out. -- **Tailor every spawn with a `role`.** A role naming the specialist (e.g. "Parser edge-case tester", "SSE backpressure specialist") makes a sharper agent than a bare generic `task`/`quick_task` worker; decompose into named specialists, never clones of one generic worker. A role-less generic spawn is the exception. -- NEVER slow down or serialize because tasks might overlap on some files. Agents resolve collisions among themselves in real time. -- Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if batchEnabled}}`context` or the item's `assignment`{{else}}the `assignment`{{/if}}. +- **Tailor every spawn with a `role`.** A named specialist (e.g. "Parser edge-case tester", "SSE backpressure specialist") beats a generic `task`/`quick_task` worker; decompose into specialists, never clones. Role-less spawn is the exception. +- NEVER serialize over possible file overlap. Agents self-resolve collisions in real time. +- Subagents have no conversation history. Every fact, file path, direction MUST be explicit in {{#if batchEnabled}}`context` or the item's `assignment`{{else}}the `assignment`{{/if}}. {{#if batchEnabled}} -- **Shared background** lives in `context` once — never duplicated across assignments. Pass large payloads via `local://` URIs, not inline. +- **Shared background** in `context` once, never per assignment. Large payloads via `local://` URIs, not inline. {{else}} -- **Shared background**: write it ONCE to a `local://` file (e.g. `local://ctx.md`) and reference that path in each assignment. Pass large payloads via `local://` URIs, not inline. +- **Shared background**: write ONCE to a `local://` file (e.g. `local://ctx.md`), reference it in each assignment. Large payloads via `local://` URIs, not inline. {{/if}} -- Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown. -- **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`). -- **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`. +- Prefer agents that investigate **and** edit in one pass; spin a read-only discovery step only when affected files unknown. +- **Read-only agents** (e.g. `explore`): no edit/write/command tools. NEVER assign them file changes or commands. Use to investigate + report; delegate edits to a writing agent (`task`/`oracle`/`designer`) or do them yourself. +- **No reasoning offload**: NEVER route reasoning, analysis, design, or decisions to `quick_task`/`explore` — minimal-effort / small models for mechanical lookups + data collection only. Keep judgment + synthesis in your own context; delegate hard thinking to `task`/`plan`/`oracle`. {{#if ircEnabled}} -Test: can task B run correctly without seeing A's output? If no, sequence A → B — **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM. -Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact. -Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer. +Test: can B run without A's output? No → sequence A → B — **unless** B can ask A over `irc`. Live coordination beats a waterfall when the contract is small + DM-able. +Still sequence when a task produces a large evolving contract (generated types, schema migration, core module API) consumed wholesale — IRC round-trips don't replace a finished artifact. +Parallel when tasks touch disjoint files, are independent refactors/tests, or need only occasional peer clarification. {{else}} -Test: can task B run correctly without seeing A's output? If no, sequence A → B. +Test: can B run without A's output? No → sequence A → B. Sequential when one task produces a contract (types, API, schema, core module) the other consumes. Parallel when tasks touch disjoint files or are independent refactors/tests. {{/if}} -{{#if ircEnabled}}Sequenced follow-ups SHOULD message the agent that produced the prerequisite — it already holds the context.{{/if}} +{{#if ircEnabled}}Sequenced follow-ups SHOULD message the prerequisite's producer — it holds the context.{{/if}} {{#if batchEnabled}} diff --git a/packages/coding-agent/src/prompts/tools/todo.md b/packages/coding-agent/src/prompts/tools/todo.md index bf5e2f341..95a52dda2 100644 --- a/packages/coding-agent/src/prompts/tools/todo.md +++ b/packages/coding-agent/src/prompts/tools/todo.md @@ -1,43 +1,39 @@ -**Tasks are referenced by their verbatim content string, not by any auto-generated ID. There is no "task-1"/"task-N" identifier — the tool never emits one. Pass the task's content text in the `task` field.** +**Tasks referenced by verbatim content string, NEVER an auto-generated ID — no "task-1"/"task-N" exists. Pass the content text in the `task` field.** -Manages a phased task list. Pass `ops`: a flat array of operations. -The next pending task is auto-promoted to `in_progress` after each completion. -Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, and `view`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists. +Manages a phased task list. Pass `ops`: flat array of operations. Next pending task auto-promotes to `in_progress` on each completion. `pending` is a status, not an `op` — leave not-yet-started tasks implicit in `init`/`append`. ## Operations |`op`|Required fields|Effect| |---|---|---| -|`init`|`list: [{phase, items: string[]}]`|Initialize the full list (replaces any existing list)| +|`init`|`list: [{phase, items: string[]}]`|Initialize full list (replaces existing)| |`init`|`items: string[]`|Flattened single-phase init| |`start`|`task`|Mark in progress| |`done`|`task` or `phase`|Mark completed| |`drop`|`task` or `phase`|Mark abandoned| -|`rm`|`task` or `phase` (optional)|Remove task or phase's tasks; omit both to clear the entire list| +|`rm`|`task` or `phase` (optional)|Remove task or phase's tasks; omit both to clear the list| |`append`|`phase`, `items: string[]`|Append tasks to `phase`; lazily creates phase| -|`view`|—|Read-only: echo the current list without modifying it| +|`view`|—|Read-only: echo the list, no modify| ## Anatomy -- **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique. -- **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Used as the phase identifier — unique. Do not add prefixes like `1.`, `A)`, `Phase 1:`, etc. +- **Task content**: 5–10 words; what, not how. Unique identifier. +- **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Unique identifier. NEVER prefix `1.`, `A)`, `Phase 1:`. ## Rules - Mark tasks done immediately after finishing. - Complete phases in order. -- On blockers, `append` a new task to the active phase to unblock yourself, or `drop`. -- `task` and `phase` fields reference content/name verbatim; keep them stable once introduced. -- Lost track of exact task text? `view` echoes the full list — NEVER guess content from memory; a mismatched `task` string is an error. +- Blocked? `append` a task to the active phase to unblock, or `drop`. +- Keep `task`/`phase` strings stable once introduced. +- Lost the exact task text? `view` echoes the list — NEVER guess from memory; a mismatched `task` string is an error. ## When to create a list - Task requires 3+ distinct steps - User explicitly requests one -- User provides a set of tasks to complete +- User provides a set of tasks - New instructions arrive mid-task — capture before proceeding -When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through: -- You MUST `init` the list with EVERY item as its own task before doing the work. -- Enumerate all of them; -- NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest. -The entire point is to remember every one. +User hands you a multi-step plan — phased todo, numbered/bulleted checklist, or "N bugs/items/tasks": +- You MUST `init` the list with EVERY item as its own task before working. +- Enumerate all; NEVER summarize into fewer tasks, sample "the important ones", drop items, or track the rest from memory. diff --git a/packages/coding-agent/src/prompts/tools/web-search.md b/packages/coding-agent/src/prompts/tools/web-search.md index 611b8b7f7..a5dcc4004 100644 --- a/packages/coding-agent/src/prompts/tools/web-search.md +++ b/packages/coding-agent/src/prompts/tools/web-search.md @@ -4,7 +4,3 @@ Searches the web for up-to-date information beyond knowledge cutoff. - You SHOULD prefer primary sources (papers, official docs) and corroborate key claims with multiple sources - You MUST include links for cited sources in the final response - - -Searches are performed automatically within a single API call—no pagination or follow-up requests needed. - diff --git a/packages/coding-agent/src/prompts/tools/write.md b/packages/coding-agent/src/prompts/tools/write.md index d9fd8cd54..00af5c3a6 100644 --- a/packages/coding-agent/src/prompts/tools/write.md +++ b/packages/coding-agent/src/prompts/tools/write.md @@ -8,7 +8,7 @@ Creates or overwrites file at specified path. -- You SHOULD use Edit tool for modifying existing files (more precise, preserves formatting) +- You SHOULD use Edit tool for modifying existing files - You NEVER create documentation files (*.md, README) unless explicitly requested - You NEVER use emojis unless requested diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index b5d52847e..84849bc9a 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -22,7 +22,13 @@ export const MAIN_AGENT_ID = "Main"; * - `aborted`: hard-killed, terminal. */ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; -export type AgentKind = "main" | "sub"; +/** + * - `main`/`sub`: the user-facing agent tree (driving agent + task subagents). + * - `advisor`: a passive review transcript persisted like a subagent for usage + * attribution and Agent Hub observability, but never a peer — hidden from + * agent-facing rosters (`irc`, `history://`) and not messageable/revivable. + */ +export type AgentKind = "main" | "sub" | "advisor"; export interface AgentRef { id: string; @@ -157,11 +163,14 @@ export class AgentRegistry { } /** - * Returns every alive agent (running | idle) except the caller. - * Flat namespace: every agent can see every other agent. + * Returns every alive agent (running | idle) except the caller. Advisor refs + * are observability-only transcripts, never peers, so they are excluded. + * Flat namespace: every other agent is visible. */ listVisibleTo(id: string): AgentRef[] { - return this.list().filter(ref => ref.id !== id && (ref.status === "running" || ref.status === "idle")); + return this.list().filter( + ref => ref.id !== id && ref.kind !== "advisor" && (ref.status === "running" || ref.status === "idle"), + ); } onChange(listener: RegistryListener): () => void { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 9fb83aca6..22e6eec5d 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -5,7 +5,6 @@ import { type AgentTelemetryConfig, type AgentTool, AppendOnlyContextManager, - INTENT_FIELD, type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { @@ -23,18 +22,8 @@ import { } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { FALLBACK_DIALECT, preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import type { Component } from "@oh-my-pi/pi-tui"; -import { - $env, - $flag, - getAgentDbPath, - getAgentDir, - getAuthBrokerSnapshotCachePath, - getProjectDir, - logger, - postmortem, - prompt, - Snowflake, -} from "@oh-my-pi/pi-utils"; +import { $env, $flag, getAgentDir, getProjectDir, logger, postmortem, prompt, Snowflake } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor"; import { type AsyncJob, AsyncJobManager } from "./async"; import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller"; @@ -56,7 +45,6 @@ import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate import { Settings, type SkillsSettings } from "./config/settings"; import { CursorExecHandlers } from "./cursor"; import "./discovery"; -import { resolveConfigValue } from "./config/resolve-config-value"; import { initializeWithSettings } from "./discovery"; import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor"; import { defaultEvalSessionId } from "./eval/session-id"; @@ -115,16 +103,8 @@ import { SecretObfuscator, } from "./secrets"; import { AgentSession } from "./session/agent-session"; -import { resolveAuthBrokerConfig } from "./session/auth-broker-config"; -import { - AuthBrokerClient, - AuthStorage, - DEFAULT_SNAPSHOT_CACHE_TTL_MS, - RemoteAuthCredentialStore, - readAuthBrokerSnapshotCache, - type SnapshotResponse, - writeAuthBrokerSnapshotCache, -} from "./session/auth-storage"; +import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config"; +import type { AuthStorage } from "./session/auth-storage"; import { type CustomMessage, convertToLlm, @@ -148,6 +128,7 @@ import { AgentOutputManager } from "./task/output-manager"; import { AUTO_THINKING, type ConfiguredThinkingLevel, + parseConfiguredThinkingLevel, parseThinkingLevel, resolveProvisionalAutoLevel, resolveThinkingLevelForModel, @@ -408,8 +389,12 @@ export interface CreateAgentSessionOptions { /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; - /** System prompt blocks. Array replaces default, function receives default blocks and returns final blocks. */ + /** Provider-facing system prompt override. Replaces the fully rendered default blocks. */ systemPrompt?: string | string[] | ((defaultPrompt: string[]) => string | string[]); + /** Already-loaded custom prompt text rendered through the bundled custom system prompt template. */ + customSystemPrompt?: string; + /** Already-loaded text appended through the bundled system prompt templates. */ + appendSystemPrompt?: string; /** Optional provider-facing session identifier for prompt caches and sticky auth selection. * Keeps persisted session files isolated while reusing provider-side caches. */ providerSessionId?: string; @@ -548,6 +533,16 @@ export interface CreateAgentSessionOptions { */ telemetry?: AgentTelemetryConfig; + /** + * Fired once, when the agent loop hands its first request to the provider + * transport (i.e. the `streamFn` wrapper is first invoked). Used to measure + * subagent launch latency — the boundary between "session built" and "model + * call dispatched". This is the loop's dispatch point, slightly before the + * actual provider HTTP call (per-request prep, identical across all + * requests, follows it), which is the right granularity for launch timing. + */ + onFirstChatDispatch?: () => void; + /** Whether to auto-approve all tool calls (--auto-approve CLI flag). Default: false */ autoApprove?: boolean; } @@ -620,21 +615,6 @@ export { // Helper Functions -function getDefaultAgentDir(): string { - return getAgentDir(); -} - -function resolveSnapshotTtlMs(): number { - const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; - if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS; - const value = raw.trim(); - if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS; - const ttlMs = Number(value); - if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs; - logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw }); - return DEFAULT_SNAPSHOT_CACHE_TTL_MS; -} - // Discovery Functions /** @@ -647,70 +627,12 @@ function resolveSnapshotTtlMs(): number { * the client receives access tokens with `refresh = "__remote__"` and calls * back into the broker through the {@link AuthStorageOptions.refreshOAuthCredential} * override to re-mint access tokens when needed. + * + * Delegates to {@link ./session/auth-broker-config} so the TUI and the catalog + * generator share the same credential-discovery logic. */ -export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise { - const brokerConfigPromise = resolveAuthBrokerConfig(); - const cachePath = getAuthBrokerSnapshotCachePath(); - // Warm the encrypted snapshot cache into the page cache while the broker - // config resolves (it may shell out for a `!command` token). Decryption - // needs the resolved token, so the real cache read cannot start earlier. - void Bun.file(cachePath) - .arrayBuffer() - .catch(() => undefined); - const brokerConfig = await brokerConfigPromise; - if (brokerConfig) { - const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token }); - const ttlMs = resolveSnapshotTtlMs(); - const persist = - ttlMs > 0 - ? (snapshot: SnapshotResponse): void => { - void writeAuthBrokerSnapshotCache({ - path: cachePath, - token: brokerConfig.token, - url: brokerConfig.url, - snapshot, - }).catch(error => { - logger.debug("auth-broker snapshot cache write failed", { error: String(error) }); - }); - } - : undefined; - - let initialSnapshot: SnapshotResponse | undefined; - if (ttlMs > 0) { - initialSnapshot = - (await readAuthBrokerSnapshotCache({ - path: cachePath, - token: brokerConfig.token, - url: brokerConfig.url, - ttlMs, - }).catch(error => { - logger.debug("auth-broker snapshot cache read failed", { error: String(error) }); - return null; - })) ?? undefined; - } - if (!initialSnapshot) { - const initialResult = await client.fetchSnapshot(); - if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot"); - initialSnapshot = initialResult.snapshot; - persist?.(initialSnapshot); - } - const store = new RemoteAuthCredentialStore({ client, initialSnapshot, onSnapshot: persist }); - // Refresh + usage hooks live on RemoteAuthCredentialStore; AuthStorage - // discovers them automatically when no explicit option overrides them. - const storage = new AuthStorage(store, { - configValueResolver: resolveConfigValue, - sourceLabel: `broker ${brokerConfig.url}`, - }); - await storage.reload(); - return storage; - } - const dbPath = getAgentDbPath(agentDir); - const storage = await AuthStorage.create(dbPath, { - configValueResolver: resolveConfigValue, - sourceLabel: `local ${dbPath}`, - }); - await storage.reload(); - return storage; +export async function discoverAuthStorage(agentDir: string = getAgentDir()): Promise { + return discoverAuthStorageFromConfig(agentDir); } /** @@ -798,7 +720,7 @@ export async function discoverContextFiles( export async function discoverPromptTemplates(cwd?: string, agentDir?: string): Promise { return await loadPromptTemplatesInternal({ cwd: cwd ?? getProjectDir(), - agentDir: agentDir ?? getDefaultAgentDir(), + agentDir: agentDir ?? getAgentDir(), }); } @@ -814,7 +736,7 @@ export async function discoverSlashCommands(cwd?: string): Promise { const resolvedCwd = cwd ?? getProjectDir(); - const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); + const resolvedAgentDir = agentDir ?? getAgentDir(); return loadCustomCommandsInternal({ cwd: resolvedCwd, @@ -840,8 +762,9 @@ export interface BuildSystemPromptOptions { skills?: Skill[]; contextFiles?: Array<{ path: string; content: string }>; cwd?: string; + customPrompt?: string; appendPrompt?: string; - repeatToolDescriptions?: boolean; + inlineToolDescriptors?: boolean; } /** @@ -853,10 +776,11 @@ export interface BuildSystemPromptOptions { export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { return await buildSystemPromptInternal({ cwd: options.cwd, + customPrompt: options.customPrompt, skills: options.skills, contextFiles: options.contextFiles, appendSystemPrompt: options.appendPrompt, - repeatToolDescriptions: options.repeatToolDescriptions, + inlineToolDescriptors: options.inlineToolDescriptors, }); } @@ -1115,7 +1039,7 @@ function buildMCPPromptCommands(manager: MCPManager): LoadedCustomCommand[] { */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? getProjectDir(); - const agentDir = options.agentDir ?? getDefaultAgentDir(); + const agentDir = options.agentDir ?? getAgentDir(); const eventBus = options.eventBus ?? new EventBus(); registerSshCleanup(); @@ -1270,12 +1194,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole()) : []; let restoredSessionModelIndex = -1; + let restoredSessionThinkingLevel: ThinkingLevel | undefined; if (!hasExplicitModel && !model && sessionModelStrings.length > 0) { logger.time("restoreSessionModel", () => { let failedSessionModel: string | undefined; for (let i = 0; i < sessionModelStrings.length; i++) { const sessionModelStr = sessionModelStrings[i]; - const parsedModel = parseModelString(sessionModelStr); + const parsedModel = parseModelString(sessionModelStr, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined, + }); if (!parsedModel) { failedSessionModel ??= sessionModelStr; continue; @@ -1285,6 +1213,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (restoredModel && hasModelAuth(restoredModel)) { model = restoredModel; restoredSessionModelIndex = i; + restoredSessionThinkingLevel = parsedModel.thinkingLevel; break; } failedSessionModel ??= sessionModelStr; @@ -1309,15 +1238,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const taskDepth = options.taskDepth ?? 0; // Resolves the session/agent thinking level using the same precedence we - // apply at startup: explicit option → persisted session entry → default - // role's explicit selector → selected model's defaultLevel → global - // settings default. Run again after extension role reclaim so the final - // model's own defaults aren't masked by an earlier fallback model's. + // apply at startup: explicit option → persisted session entry → restored + // model selector suffix → default role's explicit selector → selected + // model's defaultLevel → global settings default. Run again after extension + // role reclaim so the final model's own defaults aren't masked by an earlier + // fallback model's. const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => { let level = options.thinkingLevel; if (level === undefined && hasExistingSession && hasThinkingEntry) { level = parseThinkingLevel(existingSession.thinkingLevel); } + if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) { + level = restoredSessionThinkingLevel; + } if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { level = defaultRoleSpec.thinkingLevel; } @@ -1325,7 +1258,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} level = selectedModel.thinking.defaultLevel; } if (level === undefined) { - level = settings.get("defaultThinkingLevel"); + level = parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel")); } return level; }; @@ -1537,6 +1470,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined), getActiveModelString, getActiveModel: () => agent?.state.model ?? model, + getImageAttachments: () => session?.getImageAttachments() ?? [], getPlanModeState: () => session?.getPlanModeState(), getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md", getGoalModeState: () => session?.getGoalModeState(), @@ -1909,13 +1843,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!hasExplicitModel && sessionRetryLimit > 0) { for (let i = 0; i < sessionRetryLimit; i++) { const sessionModelStr = sessionModelStrings[i]; - const parsedModel = parseModelString(sessionModelStr); + const parsedModel = parseModelString(sessionModelStr, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined, + }); if (!parsedModel) continue; const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); if (restoredModel && hasModelAuth(restoredModel)) { model = restoredModel; modelFallbackMessage = undefined; restoredSessionModelIndex = i; + restoredSessionThinkingLevel = parsedModel.thinkingLevel; // Recompute thinking-level from scratch against the reclaimed // model: any value derived from the earlier fallback model's // `thinking.defaultLevel` must not become sticky. @@ -2119,7 +2057,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} emitEvent: event => cursorEventEmitter?.(event), }); - const repeatToolDescriptions = settings.get("repeatToolDescriptions"); + const inlineToolDescriptors = settings.get("inlineToolDescriptors"); const eagerTasks = settings.get("task.eager") !== "default"; const eagerTasksAlways = settings.get("task.eager") === "always"; const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined; @@ -2187,20 +2125,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } appendPrompt = parts.join("\n\n"); } - // Owned/in-band tool dialect (non-native) repeats the catalog as `# Tool:` + // Owned/in-band tool dialects (non-native) require the catalog as `# Tool:` // sections; native tool calling lets the compact name list suffice. const nativeTools = resolveDialect(settings.get("tools.format"), agent?.state.model ?? model) === undefined; + if (options.appendSystemPrompt) { + appendPrompt = appendPrompt + ? `${appendPrompt}\n\n${options.appendSystemPrompt}` + : options.appendSystemPrompt; + } const defaultPrompt = await buildSystemPromptInternal({ cwd, + resolvedCustomPrompt: options.customSystemPrompt, skills, contextFiles, tools: promptTools, toolNames, rules: rulebookRules, alwaysApplyRules, + resolvedAppendSystemPrompt: appendPrompt, skillsSettings: settings.getGroup("skills"), - appendSystemPrompt: appendPrompt, - repeatToolDescriptions, + inlineToolDescriptors, nativeTools, intentField, mcpDiscoveryMode: hasDiscoverableTools, @@ -2464,6 +2408,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? undefined : serviceTierSetting; + // One-shot launch-latency marker: fired the first time the loop dispatches + // a chat request to the provider transport. See onFirstChatDispatch. + let notifyFirstChatDispatch = options.onFirstChatDispatch; agent = new Agent({ initialState: { systemPrompt, @@ -2497,6 +2444,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getToolContext: tc => toolContextStore.getContext(tc), getApiKey: requestModel => modelRegistry.resolver(requestModel, agent.sessionId), streamFn: (streamModel, context, streamOptions) => { + if (notifyFirstChatDispatch) { + const cb = notifyFirstChatDispatch; + notifyFirstChatDispatch = undefined; + try { + cb(); + } catch (err) { + logger.warn("onFirstChatDispatch hook threw", { + error: err instanceof Error ? err.message : String(err), + }); + } + } const openrouterRoutingPreset = settings.get("providers.openrouterVariant"); const openrouterVariant = openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined; @@ -2525,9 +2483,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return result; }, intentTracing: !!intentField, + pruneToolDescriptions: inlineToolDescriptors, dialect: resolveDialect(settings.get("tools.format"), model), abortOnFabricatedToolResult: settings.get("tools.abortOnFabricatedResult"), - getToolChoice: () => session?.nextToolChoice(), + getToolChoice: () => session?.nextToolChoiceDirective(), telemetry: options.telemetry, appendOnlyContext: model ? shouldEnableAppendOnlyContext(settings.get("provider.appendOnlyContext"), model) @@ -2589,9 +2548,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (watchdogFiles && watchdogFiles.length > 0) { advisorWatchdogPrompt = watchdogFiles.join("\n\n"); } + // Owned only when this session created the manager; subagents receive a + // parent's manager via `options.mcpManager` and MUST NOT disconnect it. + const ownedMcpManager = options.mcpManager ? undefined : mcpManager; session = new AgentSession({ advisorWatchdogPrompt, agent, + pruneToolDescriptions: inlineToolDescriptors, thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel, sessionManager, settings, @@ -2634,6 +2597,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return out; } : undefined, + disconnectOwnedMcpManager: ownedMcpManager ? () => ownedMcpManager.disconnectAll() : undefined, mcpDiscoveryEnabled, initialSelectedMCPToolNames, defaultSelectedMCPToolNames, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index aa678a4a2..feef4344c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -34,8 +34,8 @@ import { type CompactionSummaryMessage, countTokens, resolveTelemetry, - STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL, ThinkingLevel, + type ToolChoiceDirective, } from "@oh-my-pi/pi-agent-core"; import { AGGRESSIVE_SHAKE_CONFIG, @@ -63,6 +63,7 @@ import { type ShakeRegion, type SummaryOptions, shouldCompact, + shouldUseOpenAiRemoteCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -102,6 +103,7 @@ import { resolveServiceTier, streamSimple, } from "@oh-my-pi/pi-ai"; +import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; @@ -117,6 +119,7 @@ import { prompt, relativePathWithinRoot, Snowflake, + withTimeout, } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import { @@ -126,6 +129,7 @@ import { type AdvisorNote, AdvisorRuntime, type AdvisorSeverity, + AdvisorTranscriptRecorder, formatAdvisorBatchContent, isAdvisorInterruptImmuneTurnActive, isInterruptingSeverity, @@ -236,6 +240,7 @@ import { AUTO_THINKING, type ConfiguredThinkingLevel, clampAutoThinkingEffort, + parseConfiguredThinkingLevel, resolveProvisionalAutoLevel, resolveThinkingLevelForModel, shouldDisableReasoning, @@ -257,6 +262,7 @@ import type { CheckpointState } from "../tools/checkpoint"; import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { isAutoQaEnabled } from "../tools/report-tool-issue"; +import { buildResolveReminderMessage } from "../tools/resolve"; import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo"; import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; @@ -276,6 +282,7 @@ import { shouldEvaluateCodexAutoRedeem, shouldPromptCodexAutoRedeem, } from "./codex-auto-reset"; +import { findCompactMode } from "./compact-modes"; import { type BashExecutionMessage, type CustomMessage, @@ -363,6 +370,23 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = { deferredHandoff: false, continuationScheduled: true, }; + +/** + * Per-turn prune cache window. A tool result whose all-message suffix exceeds + * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the + * cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside + * this tail (matches the supersede pass's default `suffixTokenLimit`); deeper + * stale/age victims are left to compaction/shake, which rebuild the cache anyway. + */ +const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000; + +/** + * Idle gap after which the supersede pass may flush the whole sent region (the + * provider cache is cold, so re-writing it is free). MUST exceed the maximum + * Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a + * still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL. + */ +const PRUNE_IDLE_FLUSH_MS = 90 * 60_000; export type CommandMetadataChangedListener = () => void | Promise; export type AsyncJobSnapshotItem = Pick; @@ -512,6 +536,19 @@ export interface AgentSessionConfig { advisorReadOnlyTools?: AgentTool[]; /** Preloaded watchdog prompt content for the advisor. */ advisorWatchdogPrompt?: string; + /** + * Strip tool descriptions from provider-bound tool specs on side requests + * (handoff). Must match the session-start value used to build the system + * prompt so inline descriptors are not also sent through provider schemas. + */ + pruneToolDescriptions?: boolean; + /** + * Disconnect this session's OWNED MCP manager on dispose. Provided only when + * the session created the manager (top-level sessions); subagents reuse a + * parent's manager via `options.mcpManager` and omit this so a child's + * teardown never tears down the shared servers. + */ + disconnectOwnedMcpManager?: () => Promise; } /** Options for AgentSession.prompt() */ @@ -665,10 +702,16 @@ interface ActiveRetryFallbackState { pinned: boolean; } -function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined { +function parseRetryFallbackSelector( + selector: string, + modelLookup?: { find(provider: string, id: string): Model | undefined }, +): RetryFallbackSelector | undefined { const trimmed = selector.trim(); if (!trimmed) return undefined; - const parsed = parseModelString(trimmed); + const parsed = parseModelString(trimmed, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined, + }); if (!parsed) return undefined; return { raw: trimmed, @@ -1096,6 +1139,13 @@ export class AgentSession { #advisorReadOnlyTools?: AgentTool[]; #advisorWatchdogPrompt?: string; #advisorYieldQueueUnsubscribe?: () => void; + /** Persists the advisor agent's turns to `/__advisor.jsonl` for stats + * attribution and Agent Hub observability. Undefined when no advisor is active. */ + #advisorTranscriptRecorder?: AdvisorTranscriptRecorder; + /** Unsubscribe for the advisor agent's event stream feeding the recorder. */ + #advisorAgentUnsubscribe?: () => void; + /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */ + #advisorRecorderClosed: Promise = Promise.resolve(); #goalTurnCounter = 0; #planReferenceSent = false; #planReferencePath = "local://PLAN.md"; @@ -1196,6 +1246,7 @@ export class AgentSession { | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; #reloadSshTool: (() => Promise) | undefined; + #disconnectOwnedMcpManager: (() => Promise) | undefined; #requestedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; /** @@ -1280,6 +1331,8 @@ export class AgentSession { // unchanged — otherwise a mid-turn estimate would survive into idle. #contextUsageRevision = 0; #obfuscator: SecretObfuscator | undefined; + /** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */ + #pruneToolDescriptions = false; #checkpointState: CheckpointState | undefined = undefined; #pendingRewindReport: string | undefined = undefined; #lastSuccessfulYieldToolCallId: string | undefined = undefined; @@ -1291,19 +1344,15 @@ export class AgentSession { if (process.platform !== "darwin") return; if (isBunTestRuntime()) return; if (this.#powerAssertion) return; - const idle = this.settings.get("power.preventIdleSleep"); - const system = this.settings.get("power.preventSystemSleep"); - const user = this.settings.get("power.declareUserActive"); - const display = this.settings.get("power.preventDisplaySleep"); - // All four off → user opted out; do nothing. - if (!idle && !system && !user && !display) return; + const mode = this.settings.get("power.sleepPrevention"); + if (mode === "off") return; try { this.#powerAssertion = MacOSPowerAssertion.start({ reason: "Oh My Pi agent session", - idle, - system, - user, - display, + idle: true, + display: mode === "display" || mode === "system", + system: mode === "system", + user: mode === "system", }); } catch (error) { logger.warn("Failed to acquire macOS power assertion", { error: String(error) }); @@ -1488,6 +1537,7 @@ export class AgentSession { this.#modelRegistry = config.modelRegistry; this.#advisorReadOnlyTools = config.advisorReadOnlyTools; this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt; + this.#pruneToolDescriptions = config.pruneToolDescriptions === true; this.#validateRetryFallbackChains(); this.#toolRegistry = config.toolRegistry ?? new Map(); this.#requestedToolNames = config.requestedToolNames; @@ -1562,6 +1612,7 @@ export class AgentSession { this.#rebuildSystemPrompt = config.rebuildSystemPrompt; this.#getMcpServerInstructions = config.getMcpServerInstructions; this.#reloadSshTool = config.reloadSshTool; + this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager; this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#promptModelKey = this.#currentPromptModelKey(); this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false; @@ -1676,6 +1727,39 @@ export class AgentSession { this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1; } + /** + * Re-prime the advisor across a conversation boundary: `/new`, `/branch`, + * `/btw`, `/tree`, and session switch/resume. Beyond {@link AdvisorRuntime.reset} + * (which only re-primes the advisor's transcript view and is also fired by + * within-conversation rewrites like compaction/shake/rewind), this clears the + * session-level interrupt latches so the prior conversation's cooldown cannot + * leak into the new one: the post-interrupt immune-turn window + * (`#advisorPrimaryTurnsCompleted`, `#advisorInterruptImmuneTurnStart`) and the + * user-interrupt auto-resume suppression flag. It also drops advisor deliveries + * still queued against the prior conversation — pending asides in the yield + * queue (advisor entries use `skipIdleFlush`, so they linger until the next + * `drainLazy` rather than self-flushing), interrupting cards parked in the + * agent steer/follow-up queues, and preserved cards deferred to the next turn — + * so none of them inject into the new conversation. + */ + #resetAdvisorSessionState(): void { + // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor + // loop, and that abort can emit an `aborted` message_end we must not attribute to + // either session's transcript. Detach, reset, then re-attach the live agent's feed. + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; + this.#advisorRuntime?.reset(); + this.#attachAdvisorRecorderFeed(); + this.#advisorPrimaryTurnsCompleted = 0; + this.#advisorInterruptImmuneTurnStart = undefined; + this.#advisorAutoResumeSuppressed = false; + this.yieldQueue.clear("advisor"); + this.#extractQueuedAdvisorCards(); + if (this.#pendingNextTurnMessages.some(isAdvisorCard)) { + this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m)); + } + } + #buildAdvisorRuntime(seedToCurrent = false): boolean { if (this.#isDisposed) return false; if (this.#advisorRuntime) return true; @@ -1798,6 +1882,18 @@ export class AgentSession { }; this.#advisorAgent = advisorAgent; + // Persist the advisor's turns to `/__advisor.jsonl` (resolved lazily + // so it follows session switches) so its model usage is attributed in stats + // and its transcript shows in the Agent Hub — without registering it as a peer. + const recorder = new AdvisorTranscriptRecorder( + () => this.sessionManager.getSessionFile(), + () => this.sessionManager.getCwd(), + // On the advisor on→off→on toggle, wait for the prior recorder's close so + // two SessionManagers never hold the same __advisor.jsonl at once. + this.#advisorRecorderClosed, + ); + this.#advisorTranscriptRecorder = recorder; + this.#attachAdvisorRecorderFeed(); this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, { snapshotMessages: () => this.agent.state.messages, enqueueAdvice, @@ -1828,10 +1924,21 @@ export class AgentSession { } #stopAdvisorRuntime(): void { + // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts + // the loop, and an abort emits a final `message_end` we must not enqueue against + // a closing recorder (it would reopen and resurrect an already-released file). + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; if (this.#advisorRuntime) { this.#advisorRuntime.dispose(); this.#advisorRuntime = undefined; } + if (this.#advisorTranscriptRecorder) { + // Capture the close so dispose()/`/drop` can await the queued open+append+close — + // the last advisor turn would otherwise be lost on a fast process exit. + this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close(); + this.#advisorTranscriptRecorder = undefined; + } if (this.#advisorAgent) { this.#advisorAgent = undefined; } @@ -1839,6 +1946,18 @@ export class AgentSession { this.#advisorYieldQueueUnsubscribe = undefined; } + /** Subscribe the advisor agent's finalized messages into the transcript recorder. + * Idempotent-by-replacement: callers detach the prior feed first. Kept separate + * so the re-prime path can mute the feed across an abort-driven reset. */ + #attachAdvisorRecorderFeed(): void { + const agent = this.#advisorAgent; + const recorder = this.#advisorTranscriptRecorder; + if (!agent || !recorder) return; + this.#advisorAgentUnsubscribe = agent.subscribe(event => { + if (event.type === "message_end") recorder.record(event.message); + }); + } + async #promoteAdvisorContextModel(currentModel: Model): Promise { const promotionSettings = this.settings.getGroup("contextPromotion"); if (!promotionSettings.enabled) return false; @@ -2030,6 +2149,36 @@ export class AgentSession { return undefined; } + /** + * The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority: + * 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) — + * consuming, unchanged from `nextToolChoice`; + * 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a + * PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head + * and escalates to a forced `resolve` only if the model declines. A compliant turn + * pays ZERO tool_choice change (no prompt-cache messages-cache invalidation); + * 3. else undefined. + */ + nextToolChoiceDirective(): ToolChoiceDirective | undefined { + const hard = this.nextToolChoice(); + if (hard !== undefined) return hard; + const head = this.#toolChoiceQueue.peekPendingHead(); + if (head !== undefined) { + return { + soft: true, + id: head.id, + toolName: "resolve", + reminder: [buildResolveReminderMessage(head.sourceToolName)], + }; + } + return undefined; + } + + /** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */ + peekPendingInvoker(): ((input: unknown) => Promise | unknown) | undefined { + return this.#toolChoiceQueue.peekPendingInvoker(); + } + /** * Force the next model call to target a specific active tool, then terminate * the agent loop. Pushes a two-step sequence [forced, "none"] so the model @@ -3711,7 +3860,11 @@ export class AgentSession { if (event.type === "agent_start") { this.#turnIndex = 0; await this.#extensionRunner.emit({ type: "agent_start" }); - } else if (event.type === "agent_end") { + return; + } + + if (!this.#extensionRunner.hasHandlers(event.type)) return; + if (event.type === "agent_end") { // `agent_end` extension notification is emitted from the settled // agent_end maintenance path so `session_stop` control hooks are not // blocked by unrelated notification-only work. @@ -4017,7 +4170,34 @@ export class AgentSession { await shutdownTinyTitleClient(); this.#releasePowerAssertion(); await this.sessionManager.close(); + // beginDispose() stopped the advisor and captured its recorder close; await + // it so the final advisor turn is flushed before the process may exit. + await this.#advisorRecorderClosed; this.#closeAllProviderSessions("dispose"); + // Disconnect the MCP manager this session OWNS so its stdio servers are + // not orphaned at exit. Best-effort: a failure here must never throw out + // of dispose. Only owning (top-level) sessions provide this callback; + // subagents reuse a parent's manager and must not tear it down. Idempotent + // with the deferred-discovery disconnect in `createAgentSession`. + // + // BOUNDED: an owned manager may hold an HTTP/SSE server whose session- + // termination DELETE blocks up to the MCP request timeout (30s default, + // unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()` + // unbounded would stall /exit and print-mode shutdown on a broken remote + // endpoint. Race it against a short deadline — stdio close (the subprocess + // reap this targets) completes well within the bound; a slow transport + // close is left to finish detached. Mirrors the bounded async-job teardown. + if (this.#disconnectOwnedMcpManager) { + try { + await withTimeout( + this.#disconnectOwnedMcpManager(), + 3_000, + "Timed out disconnecting owned MCP manager during dispose", + ); + } catch (error) { + logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) }); + } + } // Flush the retain queue BEFORE clearing the session's pointer so // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`. // Reversed, the spliced batch survives just long enough to fail the @@ -4730,7 +4910,7 @@ export class AgentSession { * cache per-tool strings without preserving this property. * * Inputs NOT covered: tool input schemas; memory instructions read from disk; - * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`, + * and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`, * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The * closure-captured ones cannot change at runtime regardless of skip behavior. * For everything else, callers must explicitly call `refreshBaseSystemPrompt()` @@ -4912,6 +5092,24 @@ export class AgentSession { return this.agent.state.messages; } + /** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */ + getImageAttachments(): { label: string; uri: string; image: ImageContent }[] { + for (let i = this.agent.state.messages.length - 1; i >= 0; i--) { + const message = this.agent.state.messages[i]; + if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) { + continue; + } + const images = message.content.filter((part): part is ImageContent => part.type === "image"); + if (images.length === 0) continue; + return images.map((image, index) => ({ + label: `Image #${index + 1}`, + uri: `attachment://${index + 1}`, + image, + })); + } + return []; + } + buildDisplaySessionContext(): SessionContext { return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator); } @@ -6512,6 +6710,14 @@ export class AgentSession { this.#closeAllProviderSessions("new session"); this.agent.reset(); if (options?.drop && previousSessionFile) { + // Detach the advisor recorder feed and drain its writer BEFORE deleting the + // old artifacts dir: `await this.abort()` only stops the primary, so a still- + // running advisor turn could otherwise finish, emit `message_end`, and recreate + // `/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes + // the advisor and re-attaches the feed at the new session's path. + this.#advisorAgentUnsubscribe?.(); + this.#advisorAgentUnsubscribe = undefined; + if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close(); try { await this.sessionManager.dropSession(previousSessionFile); } catch (err) { @@ -6548,7 +6754,7 @@ export class AgentSession { this.#todoReminderAwaitingProgress = false; this.#planReferenceSent = false; this.#planReferencePath = "local://PLAN.md"; - this.#advisorRuntime?.reset(); + this.#resetAdvisorSessionState(); this.#reconnectToAgent(); // Emit session_switch event with reason "new" to hooks @@ -7152,11 +7358,16 @@ export class AgentSession { async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.sessionManager.getBranch(); + const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneToolOutputs( branchEntries, this.#withPlanProtection({ ...DEFAULT_PRUNE_CONFIG, pruneUseless: this.settings.getGroup("compaction").dropUseless, + // Cache-stable boundary: never re-write the warm, already-sent prefix + // (deep stale/age victims) or summarized-away entries every turn. + keepBoundaryId, + cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS, }), ); if (result.prunedCount === 0) { @@ -7184,12 +7395,17 @@ export class AgentSession { const { supersedeReads, dropUseless } = this.settings.getGroup("compaction"); if (!supersedeReads && !dropUseless) return undefined; const branchEntries = this.sessionManager.getBranch(); + const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId; const result = pruneSupersededToolResults( branchEntries, this.#withPlanProtection({ supersedeKey: supersedeReads ? readToolSupersedeKey : undefined, pruneUseless: dropUseless, protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools], + // Never re-write summarized-away entries; only flush the whole sent + // region once the cache is genuinely cold (idle exceeds the 1h TTL). + keepBoundaryId, + idleFlushMs: PRUNE_IDLE_FLUSH_MS, }), ); if (result.prunedCount === 0) { @@ -7273,8 +7489,14 @@ export class AgentSession { return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 }; } - const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG); - const regions = collectShakeRegions(this.sessionManager.getBranch(), config); + const branchEntries = this.sessionManager.getBranch(); + const config = this.#withPlanProtection({ + ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG), + // Skip entries summarized away by the latest compaction — shaking them + // only churns persisted history with no prompt/cache effect. + keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId, + }); + const regions = collectShakeRegions(branchEntries, config); if (regions.length === 0) { return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }; } @@ -7348,8 +7570,17 @@ export class AgentSession { if (this.#compactionAbortController) { throw new Error("Compaction already in progress"); } + // Resolve the `/compact ` subcommand up front so input validation + // runs before we disconnect/abort the active agent operation below. + const compactMode = options?.mode ? findCompactMode(options.mode) : undefined; + // Modes that produce no LLM summary (snapcompact) have nothing to focus. + // Reject focus text loudly so programmatic callers don't silently lose + // instructions (the slash path pre-validates via parseCompactArgs). + if (compactMode?.rejectsFocus && customInstructions) { + throw new Error(`/compact ${compactMode.name} does not take focus instructions.`); + } this.#disconnectFromAgent(); - await this.abort(); + await this.abort({ goalReason: "internal" }); const compactionAbortController = new AbortController(); this.#compactionAbortController = compactionAbortController; @@ -7359,8 +7590,26 @@ export class AgentSession { } const compactionSettings = this.settings.getGroup("compaction"); + // The `/compact ` override (resolved above) replaces the configured + // strategy/remote flags for this one invocation. Merged before + // prepareCompaction so the remote gating (preparation.settings. + // remoteEnabled/endpoint) and the snapcompact decision below both see it. + const effectiveSettings = compactMode + ? { ...compactionSettings, ...compactMode.overrides } + : compactionSettings; + if (compactMode?.requiresRemote) { + const remoteReady = + Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model); + if (!remoteReady) { + this.emitNotice( + "warning", + `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`, + "compaction", + ); + } + } const pathEntries = this.sessionManager.getBranch(); - const preparation = prepareCompaction(pathEntries, compactionSettings); + const preparation = prepareCompaction(pathEntries, effectiveSettings); if (!preparation) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; @@ -7399,14 +7648,25 @@ export class AgentSession { // directed LLM summary; a text-only model cannot read the frames back — // both take the summarizer path (the latter loudly). const wantsSnapcompact = - compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions; - const snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); + compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions; + let snapcompactReady = wantsSnapcompact && this.model.input.includes("image"); if (wantsSnapcompact && !snapcompactReady) { this.emitNotice( "warning", `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`, "compaction", ); + } else if (snapcompactReady) { + const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize)); + const renderScan = snapcompact.scanRenderability(text); + if (!renderScan.isSafe) { + this.emitNotice( + "warning", + `snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`, + "compaction", + ); + snapcompactReady = false; + } } let summary: string; @@ -7424,14 +7684,11 @@ export class AgentSession { convertToLlm, model: this.model, shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), - // Providers with hard image caps (OpenRouter: 8) silently drop - // frames past the cap — keep the archive within budget. - maxFrames: snapcompact.providerFrameBudget(this.model?.provider), }); const ctxWindow = this.model?.contextWindow ?? 0; const budget = ctxWindow > 0 - ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings) + ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings) : Number.POSITIVE_INFINITY; if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) { logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", { @@ -7517,6 +7774,10 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); + // Compaction discarded the conversation history that carried the approved + // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads + // the plan from disk and re-injects it on the next turn (issue #1246). + this.#planReferenceSent = false; this.#advisorRuntime?.reset(); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); @@ -7671,7 +7932,10 @@ export class AgentSession { this.#modelRegistry.resolver(model, this.sessionId), { systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), - tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools), + tools: obfuscateProviderTools( + this.#obfuscator, + this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools, + ), customInstructions: this.#obfuscateTextForProvider(customInstructions), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", @@ -7699,7 +7963,17 @@ export class AgentSession { await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); + // agent.reset() clears the core steering/follow-up queues. Preserve any queued + // steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a + // pre-loader TUI steer) so they survive into the post-handoff session instead of + // being silently dropped. Capture is synchronous immediately before reset and + // restore is synchronous immediately after — no await gap — so a steer arriving + // later (during ensureOnDisk/Bun.write below) appends to the restored queue + // rather than being clobbered. + const preservedSteering = this.agent.peekSteeringQueue().slice(); + const preservedFollowUp = this.agent.peekFollowUpQueue().slice(); this.agent.reset(); + this.agent.replaceQueues(preservedSteering, preservedFollowUp); this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); @@ -8755,14 +9029,20 @@ export class AgentSession { const existingRoleValue = this.settings.getModelRole(role); if (!existingRoleValue) return modelKey; - const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings); + const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, { + isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined, + }); return formatModelSelectorValue(modelKey, thinkingLevel); } #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined { const configuredTarget = currentModel.contextPromotionTarget?.trim(); if (!configuredTarget) return undefined; - const parsed = parseModelString(configuredTarget); + const parsed = parseModelString(configuredTarget, { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => + availableModels.some(model => model.provider === provider && model.id === id), + }); if (parsed) { const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id); if (explicitModel) return explicitModel; @@ -8960,14 +9240,15 @@ export class AgentSession { */ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); - const frames = archive ? snapcompact.images(archive) : undefined; + const blocks = archive ? snapcompact.historyBlocks(archive) : undefined; const summaryMessage = createCompactionSummaryMessage( result.summary, result.tokensBefore, new Date().toISOString(), result.shortSummary, undefined, - frames, + undefined, + blocks, ); let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage); for (const message of preparation.recentMessages) { @@ -9057,7 +9338,6 @@ export class AgentSession { ); } } - await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); // Abort any older auto-compaction before installing this run's controller. this.#autoCompactionAbortController?.abort(); const autoCompactionAbortController = new AbortController(); @@ -9065,11 +9345,16 @@ export class AgentSession { const autoCompactionSignal = autoCompactionAbortController.signal; try { + // Emit start AFTER the controller is installed so isCompacting is already true + // for any listener — and for input routed during this emit's event-loop yield: + // a message typed as the compaction loader appears must land in the compaction + // queue, not the core steering queue (which handoff's agent.reset() would wipe). + await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); if (compactionSettings.strategy === "handoff" && reason !== "overflow") { const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; const handoffResult = await this.handoff(handoffFocus, { autoTriggered: true, - signal: this.#autoCompactionAbortController.signal, + signal: autoCompactionSignal, }); if (!handoffResult) { const aborted = autoCompactionSignal.aborted; @@ -9191,15 +9476,15 @@ export class AgentSession { let details: unknown; // Snapcompact runs locally first; if its frame archive plus the kept - // history still overflows the model window (frames are capped by the - // image budget and cost ~FRAME_TOKEN_ESTIMATE each), an LLM summary is - // far cheaper — downgrade to context-full and take the summarizer path. + // history still overflows the model window (frames default to + // MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM + // summary is far cheaper — downgrade to context-full and take the + // summarizer path. let snapcompactResult: snapcompact.CompactionResult | undefined; if (action === "snapcompact" && compactionPrep.kind !== "fromHook") { snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, - maxFrames: snapcompact.providerFrameBudget(this.model?.provider), }); const ctxWindow = this.model?.contextWindow ?? 0; const budget = @@ -9383,6 +9668,10 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); + // Compaction discarded the conversation history that carried the approved + // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads + // the plan from disk and re-injects it on the next turn (issue #1246). + this.#planReferenceSent = false; this.#advisorRuntime?.reset(); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); @@ -9497,12 +9786,12 @@ export class AgentSession { triggerContextTokens?: number, ): Promise { const action = "shake"; - await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); this.#autoCompactionAbortController?.abort(); const controller = new AbortController(); this.#autoCompactionAbortController = controller; const signal = controller.signal; try { + await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { await this.#emitSessionEvent({ @@ -9687,21 +9976,20 @@ export class AgentSession { if (this.#isClassifierRefusal(message)) return true; if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true; if (this.#isMalformedFunctionCallError(message)) return true; - if (this.#streamInterruptedAfterObservableOutput(message)) return false; + if (this.#hasReplayUnsafeToolOutput(message)) return false; if (this.#isStaleOpenAIResponsesReplayError(message)) return true; const err = message.errorMessage; return this.#isTransientErrorMessage(err) || isUsageLimitError(err); } - #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean { - if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true; - for (const block of message.content) { - if (block.type === "toolCall") return true; - if (block.type === "text" && block.text.length > 0) return true; - if (block.type === "thinking" && block.thinking.length > 0) return true; - if (block.type === "redactedThinking" && block.data.length > 0) return true; - } - return false; + /** + * Retried turns remove the failed assistant message from active context. + * Text/thinking-only partials are safe to discard and replay. Retained + * tool calls are not: a completed tool call may already have emitted its + * tool result after this assistant message, so replaying can duplicate work. + */ + #hasReplayUnsafeToolOutput(message: AssistantMessage): boolean { + return message.content.some(block => block.type === "toolCall"); } #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean { @@ -9801,7 +10089,7 @@ export class AgentSession { this.configWarnings.push(msg); continue; } - const parsed = parseRetryFallbackSelector(selectorStr); + const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry); if (!parsed) { const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`; logger.warn(msg); @@ -9824,7 +10112,7 @@ export class AgentSession { #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined { const configuredSelector = this.settings.getModelRole(role); - return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined; + return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined; } #clearActiveRetryFallback(): void { @@ -9845,7 +10133,7 @@ export class AgentSession { } #resolveRetryFallbackRole(currentSelector: string): string | undefined { - const parsedCurrent = parseRetryFallbackSelector(currentSelector); + const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (!parsedCurrent) return undefined; const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent); const currentPlainSelector = this.model @@ -9877,7 +10165,7 @@ export class AgentSession { const chain = [primarySelector]; const seen = new Set([primarySelector.raw]); for (const selector of this.#getRetryFallbackChains()[role] ?? []) { - const parsed = parseRetryFallbackSelector(selector); + const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); @@ -9888,7 +10176,7 @@ export class AgentSession { #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] { const chain = this.#getRetryFallbackEffectiveChain(role); if (chain.length <= 1) return []; - const parsedCurrent = parseRetryFallbackSelector(currentSelector); + const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined; const currentPlainSelector = this.model && parsedCurrent @@ -9985,7 +10273,7 @@ export class AgentSession { originalThinkingLevel, lastAppliedFallbackThinkingLevel, } = this.#activeRetryFallback; - const originalSelector = parseRetryFallbackSelector(originalSelectorRaw); + const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry); if (!originalSelector) { this.#clearActiveRetryFallback(); return; @@ -10935,7 +11223,7 @@ export class AgentSession { } this.#disconnectFromAgent(); - await this.abort(); + await this.abort({ goalReason: "internal" }); // Flush pending writes before switching so restore snapshots reflect committed state. await this.sessionManager.flush(); @@ -10993,6 +11281,7 @@ export class AgentSession { } this.agent.replaceMessages(sessionContext.messages); + this.#resetAdvisorSessionState(); this.#syncTodoPhasesFromBranch(); if (switchingToDifferentSession) { this.#closeAllProviderSessions("session switch"); @@ -11036,7 +11325,7 @@ export class AgentSession { const hasServiceTierEntry = this.sessionManager .getBranch() .some(entry => entry.type === "service_tier_change"); - const defaultThinkingLevel = this.settings.get("defaultThinkingLevel"); + const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel")); const configuredServiceTier = this.settings.get("serviceTier"); // Session log entries store only concrete levels. When `auto` has resolved // for a turn, the persisted context may already carry that concrete level @@ -11198,7 +11487,7 @@ export class AgentSession { if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); - this.#advisorRuntime?.reset(); + this.#resetAdvisorSessionState(); this.#closeCodexProviderSessionsForHistoryRewrite(); } @@ -11287,7 +11576,7 @@ export class AgentSession { } this.agent.replaceMessages(sessionContext.messages); - this.#advisorRuntime?.reset(); + this.#resetAdvisorSessionState(); this.#closeCodexProviderSessionsForHistoryRewrite(); return { cancelled: false, sessionFile: this.sessionFile }; @@ -11453,7 +11742,7 @@ export class AgentSession { const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator); await this.#restoreMCPSelectionsForSessionContext(displayContext); this.agent.replaceMessages(displayContext.messages); - this.#advisorRuntime?.reset(); + this.#resetAdvisorSessionState(); this.#syncTodoPhasesFromBranch(); this.#closeCodexProviderSessionsForHistoryRewrite(); @@ -12034,6 +12323,7 @@ export class AgentSession { model: this.agent.state.model, thinkingLevel: this.#thinkingLevel, tools: this.agent.state.tools, + inlineToolDescriptors: this.#pruneToolDescriptions, }); } diff --git a/packages/coding-agent/src/session/auth-broker-config.ts b/packages/coding-agent/src/session/auth-broker-config.ts index 2c015b4cd..49b3b5d30 100644 --- a/packages/coding-agent/src/session/auth-broker-config.ts +++ b/packages/coding-agent/src/session/auth-broker-config.ts @@ -1,6 +1,11 @@ /** * Resolve auth-broker connection configuration for the local omp client. * + * This is a thin coding-agent wrapper around the shared resolver in + * `@oh-my-pi/pi-ai/auth-broker/discover` that preserves the process-lifetime + * memoization expected by the CLI and injects the full `resolveConfigValue` + * (including `!command` config indirection) from coding-agent's config layer. + * * Precedence (highest first): * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars. * 2. `auth.broker.url` / `auth.broker.token` in `~/.omp/agent/config.yml` @@ -15,55 +20,18 @@ * `runRootCommand`, and we want hand-edited config entries to be honoured at * boot without forcing a startup reorder. */ -import * as path from "node:path"; -import { getAgentDir, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; + +import { + type AuthBrokerClientConfig, + type DiscoverAuthStorageOptions, + discoverAuthStorage as discoverAuthStorageShared, + getAuthBrokerTokenFilePath, + resolveAuthBrokerConfig as resolveAuthBrokerConfigShared, +} from "@oh-my-pi/pi-ai/auth-broker/discover"; +import { getAgentDir } from "@oh-my-pi/pi-utils"; import { resolveConfigValue } from "../config/resolve-config-value"; -export interface AuthBrokerClientConfig { - url: string; - token: string; -} - -/** Path to the local bearer token file. Created on the broker host by `omp auth-broker token`. */ -export function getAuthBrokerTokenFilePath(): string { - return path.join(getConfigRootDir(), "auth-broker.token"); -} - -async function readTokenFile(): Promise { - try { - const raw = await Bun.file(getAuthBrokerTokenFilePath()).text(); - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : null; - } catch (err) { - if (isEnoent(err)) return null; - logger.warn("auth-broker token file unreadable", { error: String(err) }); - return null; - } -} - -interface ConfigSnapshot { - url?: string; - token?: string; -} - -async function readConfigYaml(): Promise { - const configPath = path.join(getAgentDir(), "config.yml"); - try { - const raw = await Bun.file(configPath).text(); - const parsed = YAML.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - const record = parsed as Record; - const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined; - const token = - typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined; - return { url, token }; - } catch (err) { - if (isEnoent(err)) return {}; - logger.warn("auth-broker config.yml unreadable", { error: String(err) }); - return {}; - } -} +export { type AuthBrokerClientConfig, getAuthBrokerTokenFilePath }; /** * Process-lifetime memo for {@link resolveAuthBrokerConfig}. Keyed on the env @@ -88,7 +56,10 @@ let cachedConfigPromise: Promise | null = null; export function resolveAuthBrokerConfig(): Promise { const key = `${process.env.OMP_AUTH_BROKER_URL ?? ""}\u0000${process.env.OMP_AUTH_BROKER_TOKEN ?? ""}\u0000${getAgentDir()}`; if (cachedConfigPromise && cachedConfigKey === key) return cachedConfigPromise; - const promise = resolveAuthBrokerConfigUncached(); + const promise = resolveAuthBrokerConfigShared({ + agentDir: getAgentDir(), + configValueResolver: resolveConfigValue, + }); cachedConfigKey = key; cachedConfigPromise = promise; promise.catch(() => { @@ -100,32 +71,21 @@ export function resolveAuthBrokerConfig(): Promise { - const envUrl = process.env.OMP_AUTH_BROKER_URL; - const envToken = process.env.OMP_AUTH_BROKER_TOKEN; - - let url = envUrl && envUrl.length > 0 ? envUrl : undefined; - let configToken: string | undefined; - if (!url || !envToken) { - const fromConfig = await readConfigYaml(); - if (!url && fromConfig.url) { - const resolved = await resolveConfigValue(fromConfig.url); - if (resolved && resolved.length > 0) url = resolved; - } - if (fromConfig.token) { - const resolved = await resolveConfigValue(fromConfig.token); - if (resolved && resolved.length > 0) configToken = resolved; - } - } - if (!url) return null; - - const token = - (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined; - if (!token) { - throw new Error( - `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` + - `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`, - ); - } - return { url, token }; +/** + * Create an AuthStorage instance, using the broker when configured and falling + * back to the local SQLite store otherwise. Delegates to the shared resolver in + * pi-ai so the CLI, subagents, and the catalog generator all see the same + * credentials. + * + * Default `agentDir` is the current configured agent directory. + */ +export function discoverAuthStorage( + agentDir: string = getAgentDir(), + options?: Omit, +): ReturnType { + return discoverAuthStorageShared({ + ...options, + agentDir, + configValueResolver: resolveConfigValue, + }); } diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 405f4d5ce..458e29d14 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -18,16 +18,7 @@ export type { ResetCreditRedeemOutcome, ResetCreditTarget, SerializedAuthStorage, - SnapshotResponse, StoredAuthCredential, } from "@oh-my-pi/pi-ai"; -export { - AuthBrokerClient, - AuthStorage, - DEFAULT_SNAPSHOT_CACHE_TTL_MS, - REMOTE_REFRESH_SENTINEL, - RemoteAuthCredentialStore, - readAuthBrokerSnapshotCache, - SqliteAuthCredentialStore, - writeAuthBrokerSnapshotCache, -} from "@oh-my-pi/pi-ai"; +export { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +export type { SnapshotResponse } from "@oh-my-pi/pi-ai/auth-broker/types"; diff --git a/packages/coding-agent/src/session/compact-modes.ts b/packages/coding-agent/src/session/compact-modes.ts new file mode 100644 index 000000000..346566055 --- /dev/null +++ b/packages/coding-agent/src/session/compact-modes.ts @@ -0,0 +1,105 @@ +/** + * Manual `/compact` subcommands. Kept in a dependency-free leaf module so the + * slash-command registry, the interactive controllers, and `AgentSession` + * can all import the mode metadata + parser without pulling in the heavy + * `agent-session` module graph (which would form an import cycle through the + * slash-command registry) — same rationale as `shake-types.ts`. + * + * Each mode is a one-off override layered on top of the configured + * `compaction.*` settings for a single invocation; it never mutates settings. + * Adding a mode is a single entry here: the command surface (autocomplete + + * ACP hint), the parser, and the engine override all read this table. + */ + +/** Subcommand selecting a one-off compaction mode for manual `/compact`. */ +export type CompactMode = "soft" | "remote" | "snapcompact"; + +/** + * Per-invocation overrides merged over the configured `compaction.*` settings. + * Narrowed to the two knobs the modes actually flip; the result stays + * assignable to the full `CompactionSettings`. + */ +export interface CompactionOverride { + strategy?: "context-full" | "snapcompact"; + remoteEnabled?: boolean; +} + +export interface CompactModeDef { + readonly name: CompactMode; + /** One-line description surfaced in autocomplete + help. */ + readonly description: string; + /** Settings overrides applied on top of `compaction.*` for this run. */ + readonly overrides: CompactionOverride; + /** + * When true, the mode produces no LLM summary, so trailing focus text is + * meaningless and rejected by the parser (snapcompact archives history into + * images without a directed summary). + */ + readonly rejectsFocus?: boolean; + /** + * When true, the mode explicitly demands a remote path; the engine warns and + * falls back to a local summary if neither a remote endpoint nor a + * provider-native compaction path is available. + */ + readonly requiresRemote?: boolean; +} + +export const COMPACT_MODES: readonly CompactModeDef[] = [ + { + name: "soft", + description: "Summarize locally with the active model (skip remote endpoints)", + overrides: { strategy: "context-full", remoteEnabled: false }, + }, + { + name: "remote", + description: "Summarize via the remote endpoint / provider-native compaction", + overrides: { strategy: "context-full", remoteEnabled: true }, + requiresRemote: true, + }, + { + name: "snapcompact", + description: "Archive history onto dense bitmap images the model reads back (no LLM call)", + overrides: { strategy: "snapcompact" }, + rejectsFocus: true, + }, +]; + +/** Resolve a subcommand token (case-insensitive) to its mode definition. */ +export function findCompactMode(name: string): CompactModeDef | undefined { + const key = name.trim().toLowerCase(); + return COMPACT_MODES.find(mode => mode.name === key); +} + +/** Parsed `/compact` arguments: an optional mode plus optional focus text. */ +export interface ParsedCompactArgs { + mode?: CompactMode; + instructions?: string; +} + +/** + * Split `/compact` args into a leading mode subcommand + focus instructions. + * + * Backward compatible: when the first token is not a known mode, the entire + * argument string is treated as focus instructions (the historical behavior). + * A recognized mode with `rejectsFocus` and trailing text is an error. + */ +export function parseCompactArgs(args: string): ParsedCompactArgs | { error: string } { + const trimmed = args.trim(); + if (!trimmed) return {}; + + const spaceIndex = trimmed.search(/\s/); + const firstToken = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); + const mode = findCompactMode(firstToken); + if (!mode) { + // No recognized mode prefix — keep the whole thing as focus instructions. + return { instructions: trimmed }; + } + + const focus = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim(); + if (mode.rejectsFocus && focus) { + return { + error: `/compact ${mode.name} does not take focus instructions (it archives history without an LLM summary).`, + }; + } + return { mode: mode.name, instructions: focus || undefined }; +} diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index f111e51f7..991c2c94a 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -204,16 +204,14 @@ function wrapSteeringUserMessage(message: UserMessage): UserMessage { } export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] { - const last = messages[messages.length - 1]; - if (!isSteeringUserMessage(last)) return messages; - - let firstSteer = messages.length - 1; - while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) { - firstSteer--; - } - + // Wrap EVERY steering message, not just a trailing run. The wire bytes of a + // steering message must be a pure function of the message itself, independent + // of its position in the array. When only the trailing steer was wrapped, the + // same persisted message was sent enveloped while it was the tail and raw once + // the assistant's reply buried it — rewriting already-cached prefix bytes and + // busting the provider prompt cache from that message onward on the next turn. let wrappedMessages: AgentMessage[] | undefined; - for (let i = firstSteer; i < messages.length; i++) { + for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (!isSteeringUserMessage(message)) continue; const wrappedMessage = wrapSteeringUserMessage(message); diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index dd46f079d..08d4339e7 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -20,6 +20,13 @@ export interface SessionContext { mode: string; /** Mode-specific data from the last mode_change entry */ modeData?: Record; + /** + * Array parallel to messages, indicating which assistant turns should + * have their prompt-cache misses suppressed/explained (because a model, + * compaction, or plan-mode transition directly preceded them). + * Only populated in transcript mode. + */ + cacheMissExplainedAt?: boolean[]; } /** Lists session model strings to try when restoring, in fallback order. */ @@ -191,12 +198,45 @@ export function buildSessionContext( // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AgentMessage[] = []; + const cacheMissExplainedAt: boolean[] = []; + let pendingReset = false; + let currentMode = "none"; + let lastAssistantModel: string | undefined; + + const handleEntryResetTracking = (entry: SessionEntry) => { + if (entry.type === "compaction") { + pendingReset = true; + } else if (entry.type === "model_change") { + pendingReset = true; + } else if (entry.type === "mode_change") { + const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan"); + if (isPlanTransition) { + pendingReset = true; + } + currentMode = entry.mode; + } + }; + + const pushMessage = (msg: AgentMessage) => { + messages.push(msg); + if (!options?.transcript) return; + if (msg.role === "assistant") { + const currentModel = `${msg.provider}/${msg.model}`; + const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel; + lastAssistantModel = currentModel; + cacheMissExplainedAt.push(pendingReset || modelChanged); + pendingReset = false; + } else { + cacheMissExplainedAt.push(false); + } + }; const appendMessage = (entry: SessionEntry) => { + handleEntryResetTracking(entry); if (entry.type === "message") { - messages.push(entry.message); + pushMessage(entry.message); } else if (entry.type === "custom_message") { - messages.push( + pushMessage( createCustomMessage( entry.customType, entry.content, @@ -207,7 +247,7 @@ export function buildSessionContext( ), ); } else if (entry.type === "branch_summary" && entry.summary) { - messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); } }; @@ -217,16 +257,18 @@ export function buildSessionContext( // TUI) at the point it fired, with any snapcompact frames re-attached so // the component can report them. for (const entry of path) { + handleEntryResetTracking(entry); if (entry.type === "compaction") { const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( entry.summary, entry.tokensBefore, entry.timestamp, entry.shortSummary, undefined, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); } else { @@ -251,14 +293,15 @@ export function buildSessionContext( // Emit summary first; re-attach any archived snapcompact frames so the // model can keep reading the archived history after every context rebuild. const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( compaction.summary, compaction.tokensBefore, compaction.timestamp, compaction.shortSummary, providerPayload, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); @@ -333,6 +376,9 @@ export function buildSessionContext( ); if (normalized.length === 0) { messages.splice(i, 1); + if (options?.transcript) { + cacheMissExplainedAt.splice(i, 1); + } } else { messages[i] = { ...message, content: normalized }; } @@ -340,6 +386,7 @@ export function buildSessionContext( return { messages, + cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined, thinkingLevel, serviceTier, models, diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts index a985e5b96..b6bd8cd37 100644 --- a/packages/coding-agent/src/session/session-dump-format.ts +++ b/packages/coding-agent/src/session/session-dump-format.ts @@ -7,9 +7,9 @@ * args), `### Tool Result: `, and the execution/summary sections. */ import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai"; import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { YAML } from "bun"; import { canonicalizeMessage } from "../utils/thinking-display"; import { @@ -38,6 +38,7 @@ export interface FormatSessionDumpTextOptions { model?: Model | null; thinkingLevel?: ThinkingLevel | string | null; tools?: readonly SessionDumpToolInfo[]; + inlineToolDescriptors?: boolean; } interface InventoryTool { @@ -78,7 +79,8 @@ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools: lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`); lines.push("\n"); - if (inventoryTools.length > 0) { + const hasSystemPromptToolInventory = options.inlineToolDescriptors === true; + if (inventoryTools.length > 0 && !hasSystemPromptToolInventory) { lines.push("## Available Tools\n"); lines.push(renderToolInventory(inventoryTools, model?.id ?? "")); lines.push("\n"); diff --git a/packages/coding-agent/src/session/session-history-format.ts b/packages/coding-agent/src/session/session-history-format.ts index 71682a0a8..f41f6f20a 100644 --- a/packages/coding-agent/src/session/session-history-format.ts +++ b/packages/coding-agent/src/session/session-history-format.ts @@ -7,8 +7,8 @@ * as one-liners. No system prompt, no tool catalog, no config sections. */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { BashExecutionMessage, BranchSummaryMessage, diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 064578d80..bc9e0c87c 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1,7 +1,15 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai"; -import { getBlobsDir, getProjectDir, getSessionsDir, isEnoent, logger, toError } from "@oh-my-pi/pi-utils"; +import { + directoryExists, + getBlobsDir, + getProjectDir, + getSessionsDir, + isEnoent, + logger, + toError, +} from "@oh-my-pi/pi-utils"; import { ArtifactManager } from "./artifacts"; import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store"; import { @@ -743,9 +751,12 @@ export class SessionManager { // Adopt the loaded session's working directory. Sessions live in a dir // keyed by their cwd, so resuming a session from another project must - // re-point cwd/sessionDir at that project. + // re-point cwd/sessionDir at that project — unless that project directory + // no longer exists on disk, in which case adopting it (and the process + // chdir interactive mode then performs) would fail with ENOENT. Keep the + // current cwd so the resumed session stays where the user already is. const headerCwd = header.cwd ? path.resolve(header.cwd) : undefined; - if (headerCwd && headerCwd !== path.resolve(this.#cwd)) { + if (headerCwd && headerCwd !== path.resolve(this.#cwd) && (await directoryExists(headerCwd))) { this.#cwd = headerCwd; this.#sessionDir = path.dirname(resolvedSessionFile); this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile); @@ -1562,8 +1573,19 @@ export class SessionManager { ): Promise { const loaded = await loadEntriesFromFile(filePath, storage); const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined; - const cwd = header?.cwd ?? options?.initialCwd ?? getProjectDir(); - const dir = sessionDir ?? path.dirname(path.resolve(filePath)); + // Resume into the session's recorded cwd only when that directory still + // exists. A deleted project dir would make the constructor's #cwd — and the + // `setProjectDir` chdir interactive mode runs next — point at (and fail on) + // a missing path, so fall back to the launch cwd and anchor /new and /branch + // there too, keeping the resumed session where the user already is. + const recordedCwd = header?.cwd; + const recordedCwdUsable = !!recordedCwd && (await directoryExists(recordedCwd)); + const cwd = recordedCwdUsable ? recordedCwd : (options?.initialCwd ?? getProjectDir()); + const dir = + sessionDir ?? + (recordedCwd && !recordedCwdUsable + ? SessionManager.getDefaultSessionDir(cwd, undefined, storage) + : path.dirname(path.resolve(filePath))); const manager = new SessionManager(cwd, dir, true, storage); manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true; await manager.setSessionFile(filePath); @@ -1674,7 +1696,12 @@ export class SessionManager { (newestInTargetDir === null || (newestIsBreadcrumb && !currentProjectAlreadyHasSession)); if (looksLikeMovedProject) { logger.info("Re-rooting moved session", { from: breadcrumbCwd, to: resolvedCwd }); - const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage); + // Anchor at the gone breadcrumb cwd so the moveTo below relocates the + // session: open() now falls back to the launch cwd for a missing + // recorded cwd, which would no-op moveTo when it equals `cwd`. + const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage, { + initialCwd: breadcrumbCwd, + }); await manager.moveTo(cwd, sessionDir); return manager; } diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index a18417f60..10f8588ac 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = ( // Per-provider image-count budgets live in @oh-my-pi/snapcompact // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so // dimension/size limits never bind; only COUNT does. Once the budget is -// spent (e.g. OpenRouter's hard 8-image cap, already consumed by archive -// frames), tool results ship verbatim as text. +// spent by already-attached archive/system-prompt images, tool results ship +// verbatim as text. const MAX_SYSTEM_PROMPT_FRAMES = 6; /** Tool results under this many tokens are never rasterized — the swap can't * save enough to justify trading crisp text for an image. */ diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index b4aa18ab4..5eafeb528 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -495,8 +495,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}): * Returned without surrounding newlines so callers can position it freely. */ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string { - const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`; - return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`; + // A 0/1-line elision (e.g. one giant single line) would read as + // "[…0ln elided…]"; fall back to a byte count there. + if (elidedLines <= 1) return `[…${elidedBytes}B elided…]`; + return `[…${elidedLines}ln elided…]`; } /** @@ -578,8 +580,6 @@ export function truncateMiddle(content: string, options: TruncationOptions = {}) export interface InlineByteCapOptions { /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */ maxBytes?: number; - /** What the text is, for the elision marker (e.g. "bash output"). */ - label: string; /** * Persist the full text as a session artifact. When an artifact id is * returned, a `[raw output: artifact://]` footer is appended so the @@ -619,7 +619,7 @@ export async function enforceInlineByteCap(text: string, options: InlineByteCapO const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text); const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text); const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8")); - const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`; + const marker = `[…${elidedBytes}B elided…]`; let composed = `${head}\n${marker}\n${tail}`; const artifactId = await options.saveArtifact?.(text); diff --git a/packages/coding-agent/src/session/tool-choice-queue.ts b/packages/coding-agent/src/session/tool-choice-queue.ts index 76b22d8cd..6bab82316 100644 --- a/packages/coding-agent/src/session/tool-choice-queue.ts +++ b/packages/coding-agent/src/session/tool-choice-queue.ts @@ -65,6 +65,20 @@ interface InFlight { invoked: boolean; } +/** + * A non-forcing pending preview invoker. Registered by `queueResolveHandler` + * (resolve previews) so the `resolve` tool can dispatch to a staged action + * WITHOUT this queue forcing `tool_choice`. The agent-loop's + * SoftToolRequirement lifecycle (remind-then-escalate) owns any forcing. + */ +interface PendingInvoker { + /** Unique id for this staged preview; never reused (never clobbered by label). */ + id: string; + /** Source tool that staged the preview (e.g. "ast_edit"), for the reminder. */ + sourceToolName: string; + onInvoked: (input: unknown) => Promise | unknown; +} + // ── Queue ─────────────────────────────────────────────────────────────────── export class ToolChoiceQueue { @@ -75,6 +89,12 @@ export class ToolChoiceQueue { * Consumers (e.g. todo reminder suppression) read via consumeLastServedLabel(). */ #lastResolvedLabel: string | undefined; + /** + * Non-forcing pending preview invokers, stacked by UNIQUE id. The `resolve` + * tool dispatches to the head; the agent-loop's soft-tool-requirement + * lifecycle drives resolution without this queue forcing `tool_choice`. + */ + #pendingInvokers: PendingInvoker[] = []; // ── Push ────────────────────────────────────────────────────────────── @@ -190,6 +210,44 @@ export class ToolChoiceQueue { }; } + // ── Non-forcing pending invokers ────────────────────────────────────── + // Preview producers (queueResolveHandler) register here so `resolve` can + // dispatch to a staged action WITHOUT a forced tool_choice (no messages-cache + // bust). Stacked by UNIQUE id: a re-register replaces only the same id, so + // concurrent/sequential previews each survive and resolve independently. + + /** Register (or replace by exact id) a non-forcing pending preview invoker. */ + registerPendingInvoker( + id: string, + sourceToolName: string, + onInvoked: (input: unknown) => Promise | unknown, + ): void { + this.removePendingInvoker(id); + this.#pendingInvokers.push({ id, sourceToolName, onInvoked }); + } + + /** Drop the pending invoker with this id (e.g. after it resolves). */ + removePendingInvoker(id: string): void { + this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id); + } + + /** True when at least one non-forcing pending preview is registered. */ + get hasPendingInvoker(): boolean { + return this.#pendingInvokers.length > 0; + } + + /** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */ + peekPendingInvoker(): ((input: unknown) => Promise | unknown) | undefined { + return this.#pendingInvokers.at(-1)?.onInvoked; + } + + /** The head pending preview's stable id + source tool, for building the agent-level + * SoftToolRequirement (the id drives reminder re-injection when the head changes). */ + peekPendingHead(): { id: string; sourceToolName: string } | undefined { + const head = this.#pendingInvokers.at(-1); + return head ? { id: head.id, sourceToolName: head.sourceToolName } : undefined; + } + // ── Cleanup ─────────────────────────────────────────────────────────── /** Remove all directives with the given label. Rejects in-flight if it matches. */ @@ -206,6 +264,7 @@ export class ToolChoiceQueue { this.reject("cleared"); } this.#queue = []; + this.#pendingInvokers = []; this.#lastResolvedLabel = undefined; } diff --git a/packages/coding-agent/src/session/yield-queue.ts b/packages/coding-agent/src/session/yield-queue.ts index 0de2ef991..9afe4fb14 100644 --- a/packages/coding-agent/src/session/yield-queue.ts +++ b/packages/coding-agent/src/session/yield-queue.ts @@ -124,7 +124,13 @@ export class YieldQueue { return thunks; } - clear(): void { + /** Drop queued entries. With `kind`, drop only that kind's entries (leaving + * any pending idle-flush for other kinds intact); otherwise drop everything. */ + clear(kind?: string): void { + if (kind !== undefined) { + this.#entries.delete(kind); + return; + } this.#entries.clear(); this.#idleFlushPending = false; } diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 2a2f1aeab..074f6238e 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; -import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; @@ -27,9 +27,11 @@ import { resolveMemoryBackend } from "../memory-backend"; import { theme } from "../modes/theme/theme"; import type { InteractiveModeContext } from "../modes/types"; import type { AgentSession, FreshSessionResult } from "../session/agent-session"; +import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; import { urlHyperlinkAlways } from "../tui"; import { getChangelogPath, parseChangelog } from "../utils/changelog"; +import { CollabQrCodeComponent } from "./helpers/collab-qrcode"; import { buildContextReportText } from "./helpers/context-report"; import { formatDuration } from "./helpers/format"; import { createMarketplaceManager } from "./helpers/marketplace-manager"; @@ -99,6 +101,19 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string ].join("\n"); } +function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void { + try { + ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]); + } catch (err) { + ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`); + } +} + +function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void { + ctx.showStatus(collabLinkHint(host, heading, view), { dim: false }); + showCollabQrCode(ctx, view ? host.webViewLink : host.webLink); +} + function formatFreshSessionResult(result: FreshSessionResult): string { const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; @@ -589,8 +604,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ const ctx = runtime.ctx; ctx.editor.setText(""); const args = command.args.trim(); - const [first = ""] = args.split(/\s+/, 1); - if (first === "stop") { + const { verb, rest } = parseSubcommand(args); + if (verb === "stop") { if (!ctx.collabHost) { ctx.showStatus("Not hosting a collab session"); return; @@ -599,7 +614,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showStatus("Collab stopped"); return; } - if (first === "status") { + if (verb === "status") { if (ctx.collabHost) { const names = ctx.collabHost.participants.map(p => p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name, @@ -620,15 +635,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showError("Already in a collab session as a guest (/leave first)"); return; } - const view = first === "view"; + const knownStartVerb = verb === "start" || verb === "view"; + const view = verb === "view"; if (ctx.collabHost) { - ctx.showStatus( - collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view), - { dim: false }, + showCollabLink( + ctx, + ctx.collabHost, + view ? "Read-only collab session active" : "Collab session active", + view, ); return; } - const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args; + const explicitUrl = knownStartVerb ? rest : args; const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || ""; if (!relayInput) { ctx.showError( @@ -638,15 +656,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } // Scheme-less relay args default to wss (ws:// must be spelled out for localhost). const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`; + const webUrl = ctx.settings.get("collab.webUrl") || ""; const host = new CollabHost(ctx); try { - await host.start(relayUrl); + await host.start(relayUrl, webUrl); } catch (err) { ctx.showError(`Failed to start collab session: ${errorMessage(err)}`); return; } ctx.collabHost = host; - ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false }); + showCollabLink(ctx, host, "Collab session started!", view); }, }, { @@ -1239,13 +1258,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "compact", description: "Manually compact the session context", acpDescription: "Compact the conversation", - inlineHint: "[focus instructions]", + subcommands: COMPACT_MODES.map(mode => ({ + name: mode.name, + description: mode.description, + usage: mode.rejectsFocus ? undefined : "[focus]", + })), + acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`, allowArgs: true, handle: async (command, runtime) => { + const parsed = parseCompactArgs(command.args); + if ("error" in parsed) return usage(parsed.error, runtime); const before = runtime.session.getContextUsage?.(); const beforeTokens = before?.tokens; try { - await runtime.session.compact(command.args || undefined); + await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined); } catch (err) { // Compaction precondition failures (no model, already compacted, too // small) and provider errors propagate as plain Errors; surface them @@ -1263,9 +1289,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ return commandConsumed(); }, handleTui: async (command, runtime) => { - const customInstructions = command.args || undefined; + const parsed = parseCompactArgs(command.args); runtime.ctx.editor.setText(""); - await runtime.ctx.handleCompactCommand(customInstructions); + if ("error" in parsed) { + runtime.ctx.showWarning(parsed.error); + return; + } + await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode); }, }, { diff --git a/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts new file mode 100644 index 000000000..388886599 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts @@ -0,0 +1,28 @@ +import { type Component, visibleWidth } from "@oh-my-pi/pi-tui"; +import { fgOrPlain } from "../../modes/theme/theme"; +import { QrCode, renderQrHalfBlocks } from "../../utils/qrcode"; + +/** + * One-shot transcript block that prints a collab browser-join URL as a + * scannable QR code. The symbol is encoded once at construction (byte mode, + * EC level M) and rendered as ANSI half-blocks; on terminals too narrow for + * the symbol it degrades to a one-line hint pointing at the printed URL. + */ +export class CollabQrCodeComponent implements Component { + readonly #lines: readonly string[]; + readonly #minWidth: number; + + constructor(readonly url: string) { + const rows = renderQrHalfBlocks(QrCode.encodeText(url, "M")); + this.#lines = rows.map(row => ` ${row}`); + this.#minWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row)), 0) + 1; + } + + render(width: number): readonly string[] { + if (width < this.#minWidth) { + const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`; + return [` ${fgOrPlain("warning", warning)}`]; + } + return this.#lines; + } +} diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 0a485216c..bbeee28e5 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -367,17 +367,21 @@ export function buildSystemPromptToolMetadata( export interface BuildSystemPromptOptions { /** Custom system prompt (replaces default). */ customPrompt?: string; + /** Already-loaded custom system prompt text; bypasses path resolution. */ + resolvedCustomPrompt?: string; /** Tools to include in prompt. */ tools?: Map; /** Tool names to include in prompt. */ toolNames?: string[]; /** Text to append to system prompt. */ appendSystemPrompt?: string; - /** Repeat full tool descriptions in system prompt. Default: false */ - repeatToolDescriptions?: boolean; + /** Already-loaded append prompt text; bypasses path resolution. */ + resolvedAppendSystemPrompt?: string; + /** Inline full tool descriptors in the system prompt. Default: false */ + inlineToolDescriptors?: boolean; /** * Whether provider-native tool calling is active (no owned/in-band syntax). - * When true and `repeatToolDescriptions` is false, the inventory renders as a + * When true and `inlineToolDescriptors` is false, the inventory renders as a * compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true */ nativeTools?: boolean; @@ -431,9 +435,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const { customPrompt, + resolvedCustomPrompt: providedResolvedCustomPrompt, tools, appendSystemPrompt, - repeatToolDescriptions = false, + inlineToolDescriptors: providedInlineToolDescriptors, + resolvedAppendSystemPrompt: providedResolvedAppendPrompt, nativeTools = true, skillsSettings, toolNames: providedToolNames, @@ -454,6 +460,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): model, personality = "default", } = options; + const inlineToolDescriptors = providedInlineToolDescriptors ?? false; const resolvedCwd = cwd ?? getProjectDir(); const prepDefaults = { @@ -499,9 +506,15 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): return result.value; } - const systemPromptCustomizationPromise = logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { - cwd: resolvedCwd, - }); + // Caller-supplied `customPrompt` / `resolvedCustomPrompt` owns block 0; the + // secondary capability-path `SYSTEM.md` walk-up MUST NOT silently augment it, + // because that would defeat CLI precedence over project/user `SYSTEM.md`. + const callerControlsCustomPrompt = + (typeof providedResolvedCustomPrompt === "string" && providedResolvedCustomPrompt.length > 0) || + (typeof customPrompt === "string" && customPrompt.length > 0); + const systemPromptCustomizationPromise: Promise = callerControlsCustomPrompt + ? Promise.resolve(null) + : logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd }); const contextFilesPromise = providedContextFiles ? Promise.resolve(providedContextFiles) : logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); @@ -522,12 +535,16 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): await Promise.all([ withDeadline( "customPrompt", - resolvePromptInput(customPrompt, "system prompt"), + providedResolvedCustomPrompt !== undefined + ? Promise.resolve(providedResolvedCustomPrompt) + : resolvePromptInput(customPrompt, "system prompt"), prepDefaults.resolvedCustomPrompt, ), withDeadline( "appendSystemPrompt", - resolvePromptInput(appendSystemPrompt, "append system prompt"), + providedResolvedAppendPrompt !== undefined + ? Promise.resolve(providedResolvedAppendPrompt) + : resolvePromptInput(appendSystemPrompt, "append system prompt"), prepDefaults.resolvedAppendPrompt, ), withDeadline( @@ -599,10 +616,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): examples: meta?.examples, }; }); - // List mode shows a compact tool-name list; it only applies when descriptions - // are not repeated AND native tool calling is active (the model already has the - // schemas). Otherwise render full `# Tool:` sections. - const toolListMode = !repeatToolDescriptions && nativeTools; + // List mode shows a compact tool-name list; it only applies when descriptors + // stay in provider-native tool schemas AND native tool calling is active. + // Otherwise render full `# Tool:` sections inline in the system prompt. + const toolListMode = !inlineToolDescriptors && nativeTools; const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? ""); // Filter skills for the rendered system prompt: @@ -632,7 +649,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): tools: toolNames, toolInfo, toolInventory, - repeatToolDescriptions, + inlineToolDescriptors, toolListMode, toolRefs, environment, @@ -661,7 +678,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): }; const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); const systemPrompt = [rendered]; - const projectPrompt = resolvedCustomPrompt ? "" : prompt.render(projectPromptTemplate, data).trim(); + // Custom prompt templates already render context files and append text; the + // project footer still carries environment, cwd, workspace, and dir-context. + const projectPrompt = prompt + .render(projectPromptTemplate, resolvedCustomPrompt ? { ...data, contextFiles: [], appendPrompt: "" } : data) + .trim(); if (projectPrompt) { systemPrompt.push(projectPrompt); } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 551518467..8dc2eb1ae 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -196,7 +196,7 @@ function installSubagentRetryFallbackChain(args: { function renderIrcPeerRoster(selfId: string): string { const peers = AgentRegistry.global() .list() - .filter(ref => ref.id !== selfId && ref.status !== "aborted"); + .filter(ref => ref.id !== selfId && ref.status !== "aborted" && ref.kind !== "advisor"); if (peers.length === 0) return "- (no other agents)"; const lines = peers.map( peer => @@ -302,6 +302,16 @@ export interface ExecutorOptions { enableLsp?: boolean; signal?: AbortSignal; onProgress?: (progress: AgentProgress) => void; + /** + * Epochs (ms, `Date.now()`) bracketing the concurrency-semaphore wait: + * `invokedAt` is stamped at the spawn boundary before `acquire()`, + * `acquiredAt` immediately after. {@link runSubprocess} reports true queue + * wait (`acquiredAt - invokedAt`) and pre-run setup (`startTime - acquiredAt`) + * separately in the launch-timing debug log. Undefined for callers that + * bypass the semaphore path. + */ + invokedAt?: number; + acquiredAt?: number; sessionFile?: string | null; persistArtifacts?: boolean; artifactsDir?: string; @@ -1698,6 +1708,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + firstChatDispatchAt ??= performance.now(); + }, }); const sessionPromise = createAgentSession(buildSubagentSessionOptions(sessionManager)); @@ -2056,6 +2082,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise created.session.dispose()).catch(() => {}); throw err; } + sessionCreatedAt = performance.now(); monitor.setActiveSession(session); installRegistryStatusSync(session); @@ -2201,6 +2228,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise + from !== undefined && to !== undefined ? Math.round(to - from) : undefined; + const queueMs = + options.invokedAt !== undefined && options.acquiredAt !== undefined + ? Math.round(options.acquiredAt - options.invokedAt) + : undefined; + const preRunMs = options.acquiredAt !== undefined ? Math.round(startTime - options.acquiredAt) : undefined; + const setupToFirstChatMs = span(perfStart, firstChatDispatchAt); + const invokeToFirstChatMs = + options.invokedAt !== undefined && setupToFirstChatMs !== undefined + ? Math.round(startTime - options.invokedAt) + setupToFirstChatMs + : undefined; + logger.debug("subagent launch timing", { + id, + agent: agent.name, + queueMs, + preRunMs, + resolveMs: span(perfStart, resolvedAt), + sessionOpenMs: span(resolvedAt, sessionOpenedAt), + createSessionMs: span(sessionOpenedAt, sessionCreatedAt), + readyMs: span(sessionCreatedAt, readyAt), + promptToFirstChatMs: span(readyAt, firstChatDispatchAt), + setupToFirstChatMs, + invokeToFirstChatMs, + }); return { exitCode, error, diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 3367800ba..7530c9dce 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -798,6 +798,7 @@ export class TaskTool implements AgentTool part.type === "text")?.text ?? "(no output)"; const singleResult = result.details?.results[0]; @@ -900,7 +902,9 @@ export class TaskTool implements AgentTool> { const semaphore = this.#getSpawnSemaphore(); if (spawnItems.length === 1) { + const invokedAt = Date.now(); await semaphore.acquire(); + const acquiredAt = Date.now(); try { return await this.#executeSync( toolCallId, @@ -909,6 +913,8 @@ export class TaskTool implements AgentTool { + const invokedAt = Date.now(); await semaphore.acquire(); + const acquiredAt = Date.now(); try { const itemOnUpdate: AgentToolUpdateCallback | undefined = onUpdate ? update => { @@ -953,6 +961,8 @@ export class TaskTool implements AgentTool> { - return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId, spawnIndex, detached); + return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId, spawnIndex, detached, launchTiming); } /** Spawn a fresh subagent and run it to completion. */ @@ -1025,6 +1036,7 @@ export class TaskTool implements AgentTool> { const startTime = Date.now(); const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd); @@ -1265,6 +1277,8 @@ export class TaskTool implements AgentTool string | null, options?: { parentPrefix?: string }) { this.#getArtifactsDir = getArtifactsDir; this.#parentPrefix = options?.parentPrefix; + // Reserve the advisor transcript stem: a subagent allocated this id would + // write `.jsonl`, clobbering the advisor's `__advisor.jsonl` in the same + // artifacts dir. Reserving bumps such a request to `__advisor-2`. + this.#taken.add(ADVISOR_TRANSCRIPT_STEM); } /** diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index f7361dd63..6f571e93d 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -32,26 +32,45 @@ const THINKING_LEVEL_METADATA: Record = { [ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" }, [ThinkingLevel.XHigh]: { value: ThinkingLevel.XHigh, - label: "xhigh", + label: "max", description: "Maximum reasoning (~32k tokens)", }, }; -const THINKING_LEVELS = new Set([ThinkingLevel.Inherit, ThinkingLevel.Off, ...THINKING_EFFORTS]); -const EFFORT_LEVELS = new Set(THINKING_EFFORTS); +const EFFORT_BY_SELECTOR: Readonly> = { + [Effort.Minimal]: Effort.Minimal, + [Effort.Low]: Effort.Low, + [Effort.Medium]: Effort.Medium, + [Effort.High]: Effort.High, + [Effort.XHigh]: Effort.XHigh, + max: Effort.XHigh, +}; +const THINKING_LEVEL_BY_SELECTOR: Readonly> = { + [ThinkingLevel.Inherit]: ThinkingLevel.Inherit, + [ThinkingLevel.Off]: ThinkingLevel.Off, + [ThinkingLevel.Minimal]: ThinkingLevel.Minimal, + [ThinkingLevel.Low]: ThinkingLevel.Low, + [ThinkingLevel.Medium]: ThinkingLevel.Medium, + [ThinkingLevel.High]: ThinkingLevel.High, + [ThinkingLevel.XHigh]: ThinkingLevel.XHigh, +}; + +function getOwnSelector(selectors: Readonly>, value: string | null | undefined): T | undefined { + return value === undefined || value === null || !Object.hasOwn(selectors, value) ? undefined : selectors[value]; +} /** * Parses a provider-facing effort value. */ export function parseEffort(value: string | null | undefined): Effort | undefined { - return value !== undefined && value !== null && EFFORT_LEVELS.has(value) ? (value as Effort) : undefined; + return getOwnSelector(EFFORT_BY_SELECTOR, value); } /** * Parses an agent-local thinking selector. */ export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined { - return value !== undefined && value !== null && THINKING_LEVELS.has(value) ? (value as ThinkingLevel) : undefined; + return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value); } /** @@ -125,6 +144,7 @@ const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = { */ export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined { if (value === AUTO_THINKING) return AUTO_THINKING; + if (value === "max") return ThinkingLevel.XHigh; return parseThinkingLevel(value); } diff --git a/packages/coding-agent/src/tools/__tests__/json-tree.test.ts b/packages/coding-agent/src/tools/__tests__/json-tree.test.ts new file mode 100644 index 000000000..1d0c1e4bc --- /dev/null +++ b/packages/coding-agent/src/tools/__tests__/json-tree.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; +import { formatArgsInline } from "../json-tree"; + +describe("formatArgsInline", () => { + test("a trailing scalar grows into the available width instead of a fixed cap", () => { + // Regression: the value used to be hard-capped at 24 columns, so a long + // note was truncated even when the card had plenty of room (issue: advise + // preview cut to `note="Your “stric…"`). + const note = "x".repeat(200); + const narrow = formatArgsInline({ severity: "concern", note }, 40); + const wide = formatArgsInline({ severity: "concern", note }, 120); + expect(Bun.stringWidth(wide)).toBeGreaterThan(Bun.stringWidth(narrow) + 40); + // Both stay within their budget. + expect(Bun.stringWidth(narrow)).toBeLessThanOrEqual(40); + expect(Bun.stringWidth(wide)).toBeLessThanOrEqual(120); + }); + + test("every key stays visible even when a leading value is long", () => { + const out = formatArgsInline({ path: "x".repeat(200), pattern: "needle", limit: 5 }, 80); + expect(out).toContain("path="); + expect(out).toContain("pattern="); + expect(out).toContain("limit="); + expect(Bun.stringWidth(out)).toBeLessThanOrEqual(80); + }); + + test("short values render fully without truncation markers", () => { + expect(formatArgsInline({ a: "x", b: 5, c: true }, 80)).toBe('a="x", b=5, c=true'); + }); + + test("hidden meta keys are skipped", () => { + const out = formatArgsInline({ [INTENT_FIELD]: "noise", __partialJson: "{}", path: "src/foo.ts" }, 80); + expect(out).toBe('path="src/foo.ts"'); + }); +}); diff --git a/packages/coding-agent/src/tools/approval.ts b/packages/coding-agent/src/tools/approval.ts index 90d56369b..c1d53fbe8 100644 --- a/packages/coding-agent/src/tools/approval.ts +++ b/packages/coding-agent/src/tools/approval.ts @@ -159,7 +159,7 @@ export function requiresApproval( export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string { if (value.length <= maxChars) return value; const omitted = value.length - maxChars; - return `${value.slice(0, maxChars)}… (${omitted} chars truncated)`; + return `${value.slice(0, maxChars)}[…${omitted}ch elided…]`; } /** diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index fc34c7296..3c1a57e6c 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component { : truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth); const visibleLines = this.#readViewport(innerWidth, maxContentRows); const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)]; - const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth)); - const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical); + const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth)); + const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical); const boxLine = (line: string) => `${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`; return [ - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`, boxLine(header), ...content.map(boxLine), boxLine(footer), - `${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`, + `${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`, ]; } diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 327dabd13..141707f3a 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -485,7 +485,6 @@ export class BashTool implements AgentTool saveBashOriginalArtifact(this.session, full), }); diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index d703620d8..ac8829579 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -45,7 +45,7 @@ const browserSchema = type({ ), "dialogs?": type("'accept' | 'dismiss'").describe("auto-handle dialogs"), "code?": type("string").describe("js body to run in tab"), - "timeout?": type("number").describe("timeout in seconds (default 30, max 300)"), + "timeout?": type("number").describe("timeout in seconds"), "all?": type("boolean").describe("close every tab"), "kill?": type("boolean").describe("also kill spawned-app browsers"), }); @@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool saveBrowserOutputArtifact(this.session, full), }); details.result = cappedText; diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index a775254ad..631faf1a2 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -31,7 +31,7 @@ const evalCellSchema = type({ language: type("'py' | 'js'").describe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM'), code: type("string").describe("cell body, verbatim. Use top-level await freely."), "title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'), - "timeout?": type("number").describe("per-cell timeout in seconds (1-3600, default 30)"), + "timeout?": type("number").describe("per-cell timeout in seconds"), "reset?": type("boolean").describe( "wipe this cell's language kernel before running. Other languages are untouched.", ), @@ -64,7 +64,7 @@ function formatDisplayJsonForText(value: unknown): string { text = String(value); } if (text.length > MAX_DISPLAY_TEXT_BYTES) { - text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`; + text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`; } return text; } diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index b3e6c40e4..0ad6f267a 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -20,12 +20,11 @@ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block import { webpExclusionForModel } from "../utils/image-loading"; import { formatDimensionNote, resizeImage } from "../utils/image-resize"; import { ensureTool } from "../utils/tools-manager"; +import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "../utils/zip"; import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel"; -import { specialHandlers } from "../web/scrapers"; -import type { RenderResult } from "../web/scrapers/types"; +import type { RenderResult, SpecialHandler } from "../web/scrapers/types"; import { finalizeOutput, loadPage, looksLikeHtml, MAX_BYTES, MAX_OUTPUT_CHARS } from "../web/scrapers/types"; import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils"; -import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "./archive-reader"; import { applyListLimit } from "./list-limit"; import { formatStyledArtifactReference, type OutputMeta } from "./output-meta"; import { type LineRange, parseLineRanges } from "./path-utils"; @@ -51,34 +50,9 @@ const CONVERTIBLE_MIMES = new Set([ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/rtf", "application/epub+zip", - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "audio/mpeg", - "audio/wav", - "audio/ogg", ]); -const CONVERTIBLE_EXTENSIONS = new Set([ - ".pdf", - ".doc", - ".docx", - ".ppt", - ".pptx", - ".xls", - ".xlsx", - ".rtf", - ".epub", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".mp3", - ".wav", - ".ogg", -]); +const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]); const NOTEBOOK_MIMES = new Set(["application/x-ipynb+json"]); const NOTEBOOK_EXTENSIONS = new Set([".ipynb"]); @@ -1044,6 +1018,18 @@ async function tryRenderBinaryPayload( // Unified Special Handler Dispatch // ============================================================================= +let specialHandlersPromise: Promise | undefined; + +/** + * Lazily load the site-specific scraper handlers. The scrapers barrel eagerly + * imports ~80 site modules, none of which are needed until the first fetch that + * requires a special handler, so we keep them out of the cold-startup graph. + */ +function loadSpecialHandlers(): Promise { + specialHandlersPromise ??= import("../web/scrapers").then(m => m.specialHandlers); + return specialHandlersPromise; +} + /** * Try all special handlers */ @@ -1053,6 +1039,7 @@ async function handleSpecialUrls( signal: AbortSignal | undefined, storage: AgentStorage | null, ): Promise { + const specialHandlers = await loadSpecialHandlers(); for (const handler of specialHandlers) { if (signal?.aborted) { throw new ToolAbortError(); @@ -1144,45 +1131,25 @@ async function renderUrl( notes.push( `Image MIME type ${imageMimeType} is unsupported for inline model serialization; returning text metadata only`, ); - const shouldTryConvertibleFallback = isConvertible(mime, extHint); - if (shouldTryConvertibleFallback) { - notes.push("Attempting binary conversion fallback for unsupported image MIME type"); - } else { - notes.push("Falling back to textual rendering from initial response"); - } - skipConvertibleBinaryRetry = !shouldTryConvertibleFallback; + notes.push("Falling back to textual rendering from initial response"); + skipConvertibleBinaryRetry = true; } else { const binary = await fetchBinary(finalUrl, timeout, signal); if (binary.ok) { notes.push("Fetched image binary"); - const conversionExtension = getExtensionHint(finalUrl, binary.contentDisposition) || extHint; - let convertedText: string | null = null; - const converted = await convertWithMarkit(binary.buffer, conversionExtension, timeout, signal); - if (converted.ok) { - if (converted.content.trim().length > 50) { - notes.push("Converted with markit"); - convertedText = converted.content; - } else { - notes.push("markit conversion produced no usable output"); - } - } else if (converted.error) { - notes.push(`markit conversion failed: ${converted.error}`); - } else { - notes.push("markit conversion failed"); - } if (binary.buffer.byteLength > MAX_INLINE_IMAGE_SOURCE_BYTES) { notes.push( `Image exceeds inline source limit (${binary.buffer.byteLength} bytes > ${MAX_INLINE_IMAGE_SOURCE_BYTES} bytes)`, ); const output = finalizeOutput( - convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`, + `Fetched image content (${imageMimeType}), but it is too large to inline render.`, ); return { url, finalUrl, contentType: imageMimeType, - method: convertedText ? "markit" : "image-too-large", + method: "image-too-large", content: output.content, fetchedAt, truncated: output.truncated, @@ -1199,15 +1166,13 @@ async function renderUrl( if (!isDecodedImage) { notes.push(`Fetched payload could not be decoded as ${imageMimeType}; returning text metadata only`); const output = finalizeOutput( - convertedText ?? - rawContent ?? - `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`, + rawContent ?? `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`, ); return { url, finalUrl, contentType: imageMimeType, - method: convertedText ? "markit" : "image-invalid", + method: "image-invalid", content: output.content, fetchedAt, truncated: output.truncated, @@ -1219,13 +1184,13 @@ async function renderUrl( `Image exceeds inline output limit after resize (${resized.buffer.length} bytes > ${MAX_INLINE_IMAGE_OUTPUT_BYTES} bytes)`, ); const output = finalizeOutput( - convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`, + `Fetched image content (${imageMimeType}), but it is too large to inline render.`, ); return { url, finalUrl, contentType: imageMimeType, - method: convertedText ? "markit" : "image-too-large", + method: "image-too-large", content: output.content, fetchedAt, truncated: output.truncated, @@ -1234,7 +1199,7 @@ async function renderUrl( } const dimensionNote = formatDimensionNote(resized); - let imageSummary = convertedText ?? `Fetched image content (${resized.mimeType}).`; + let imageSummary = `Fetched image content (${resized.mimeType}).`; if (dimensionNote) { imageSummary += `\n${dimensionNote}`; } diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 4fe03a45a..1bb9b7767 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -44,13 +44,7 @@ const findSchema = type({ .describe("globs including search paths"), "hidden?": type("boolean").describe("include hidden files"), "gitignore?": type("boolean").describe("respect gitignore"), - "limit?": type("number").describe("max results (clamped to 1-200)"), - "timeout?": type("number").describe("timeout in seconds (0.5–60)"), -}).narrow((o, ctx) => { - if (o.timeout !== undefined && (o.timeout < 0.5 || o.timeout > 60)) { - return ctx.mustBe("a timeout between 0.5 and 60 seconds"); - } - return true; + "limit?": type("number").describe("max results"), }); export type FindToolInput = typeof findSchema.infer; @@ -58,8 +52,6 @@ export type FindToolInput = typeof findSchema.infer; const DEFAULT_LIMIT = 200; const MAX_LIMIT = 200; const DEFAULT_GLOB_TIMEOUT_MS = 5000; -const MIN_GLOB_TIMEOUT_MS = 500; -const MAX_GLOB_TIMEOUT_MS = 60_000; export interface FindToolDetails { truncation?: TruncationResult; @@ -132,10 +124,6 @@ export class FindTool implements AgentTool { caption: "Find directories matching a name (returns both files and dirs; directories are suffixed with `/`)", call: { paths: ["**/tests"] }, }, - { - caption: "Long-running search on a slow volume", - call: { paths: ["/Volumes/Storage/**/*.py"], timeout: 30 }, - }, ]; readonly strict = true; @@ -156,7 +144,7 @@ export class FindTool implements AgentTool { onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, ): Promise> { - const { paths, limit, hidden, gitignore, timeout } = params; + const { paths, limit, hidden, gitignore } = params; return untilAborted(signal, async () => { const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); @@ -237,8 +225,7 @@ export class FindTool implements AgentTool { const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit))); const includeHidden = hidden ?? true; const useGitignore = gitignore ?? true; - const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS; - const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs)); + const timeoutMs = DEFAULT_GLOB_TIMEOUT_MS; const timeoutSignal = AbortSignal.timeout(timeoutMs); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const formatMatchPath = (matchPath: string, base: string, fileType?: natives.FileType): string => { @@ -448,7 +435,7 @@ export class FindTool implements AgentTool { partial.sort((a, b) => b.m - a.m); const sortedPaths = partial.map(entry => entry.p); const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1); - const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`; + const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — narrow the pattern instead of retrying blindly`; return buildResult(sortedPaths, { notice, forceTruncated: true }); } diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 229b96471..8f179f042 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2179,7 +2179,7 @@ function formatPrFiles(files: GhPrFile[] | undefined): string[] { } if (files.length > FILE_PREVIEW_LIMIT) { - lines.push(`- ... ${files.length - FILE_PREVIEW_LIMIT} more files`); + lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`); } return lines; diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index cb328c09a..21fcaaef1 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -1,6 +1,6 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core"; -import type { FetchImpl, Model, ToolChoice } from "@oh-my-pi/pi-ai"; +import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import type { AsyncJobManager } from "../async/job-manager"; import type { Rule } from "../capability/rule"; @@ -113,6 +113,13 @@ export type ContextFileEntry = { depth?: number; }; +/** Image attachment handle exposed to tools for user-facing labels such as `Image #1`. */ +export type ImageAttachmentEntry = { + label: string; + uri: string; + image: ImageContent; +}; + export type { DiscoverableTool, DiscoverableToolSearchIndex, @@ -305,6 +312,10 @@ export interface ToolSession { steer?(message: { customType: string; content: string; details?: unknown }): void; /** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */ peekQueueInvoker?(): ((input: unknown) => Promise | unknown) | undefined; + /** Peek the most-recently registered non-forcing pending preview invoker. The `resolve` + * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the + * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */ + peekPendingInvoker?(): ((input: unknown) => Promise | unknown) | undefined; /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode). * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight, * letting modes accept `resolve` invocations without forcing the tool choice every turn. */ @@ -353,6 +364,8 @@ export interface ToolSession { /** Get the active OpenTelemetry config so subagent dispatch can forward * the parent's tracer/hooks with the subagent's own identity stamped. */ getTelemetry?: () => AgentTelemetryConfig | undefined; + /** Return image attachments visible to tools for resolving labels such as `Image #1`. */ + getImageAttachments?: () => ImageAttachmentEntry[]; } export type ToolFactory = (session: ToolSession) => Tool | null | Promise; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index faee2c998..4d3a67faf 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,6 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; -import { type Api, completeSimple, type Model, type ToolExample } from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, type ImageContent, type Model, type ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; import { extractTextContent } from "../commit/utils"; @@ -11,6 +11,7 @@ import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-sys import { ImageInputTooLargeError, type LoadedImageInput, + loadImageAttachmentInput, loadImageInput, MAX_IMAGE_INPUT_BYTES, webpExclusionForModel, @@ -19,13 +20,62 @@ import type { ToolSession } from "./index"; import { ToolError } from "./tool-errors"; const inspectImageSchema = type({ - path: type("string").describe("image path"), + path: type("string").describe("image file path, Image #N label, or attachment://N URI"), question: type("string").describe("question about image"), "+": "reject", }); export type InspectImageParams = typeof inspectImageSchema.infer; +interface ImageAttachmentReference { + index: number; +} + +const IMAGE_ATTACHMENT_REFERENCE_REGEX = + /^\s*(?:\[?Image #([1-9]\d*)(?:,[^\]\n]*)?\]?|(?:attachment|image):\/\/([1-9]\d*))\s*$/i; + +function parseImageAttachmentReference(path: string): ImageAttachmentReference | null { + const match = IMAGE_ATTACHMENT_REFERENCE_REGEX.exec(path); + if (!match) return null; + const rawIndex = match[1] ?? match[2]; + if (!rawIndex) return null; + return { index: Number(rawIndex) }; +} + +function formatAvailableImageAttachments(attachments: readonly { label: string; uri: string }[]): string { + if (attachments.length === 0) return "none"; + return attachments.map(attachment => `${attachment.label} -> ${attachment.uri}`).join(", "); +} + +async function loadAttachmentReferenceInput(options: { + path: string; + reference: ImageAttachmentReference; + attachments: readonly { label: string; uri: string; image: ImageContent }[]; + autoResize: boolean; + excludeWebP: boolean | undefined; +}): Promise { + const attachment = options.attachments[options.reference.index - 1]; + if (!attachment) { + const available = formatAvailableImageAttachments(options.attachments); + if (options.attachments.length === 0) { + throw new ToolError( + `No image attachments are available in this turn. path="${options.path}" must be a readable file path or attachment URI.`, + ); + } + throw new ToolError( + `Could not resolve image attachment '${options.path}'. Available image attachments: ${available}. Pass an attachment URI or a readable filesystem path.`, + ); + } + return loadImageAttachmentInput({ + image: attachment.image, + label: attachment.label, + uri: attachment.uri, + autoResize: options.autoResize, + maxBytes: MAX_IMAGE_INPUT_BYTES, + excludeWebP: options.excludeWebP, + }); +} + export interface InspectImageToolDetails { model: string; imagePath: string; @@ -129,14 +179,27 @@ export class InspectImageTool implements AgentTool { const bus = IrcBus.global(); const peers = registry .list() - .filter(ref => ref.id !== senderId && ref.status !== "aborted") + .filter(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor") .map(ref => ({ id: ref.id, displayName: ref.displayName, diff --git a/packages/coding-agent/src/tools/json-tree.ts b/packages/coding-agent/src/tools/json-tree.ts index f83ada48a..6e86b5a29 100644 --- a/packages/coding-agent/src/tools/json-tree.ts +++ b/packages/coding-agent/src/tools/json-tree.ts @@ -1,7 +1,7 @@ /** * JSON tree rendering utilities shared across tool renderers. */ -import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { Theme } from "../modes/theme/theme"; import { truncateToWidth } from "./render-utils"; @@ -19,6 +19,8 @@ const ARGS_INLINE_PAIR_SEP = ", "; const ARGS_INLINE_PAIR_SEP_WIDTH = Bun.stringWidth(ARGS_INLINE_PAIR_SEP); const ARGS_INLINE_MORE = "…"; const ARGS_INLINE_MORE_WIDTH = Bun.stringWidth(ARGS_INLINE_MORE); +/** Minimal value footprint (quotes + a couple chars) reserved for each not-yet-rendered key. */ +const ARGS_INLINE_TAIL_VALUE_RESERVE = 4; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -49,10 +51,15 @@ export function formatScalar(value: unknown, maxLen: number): string { * Format args inline for collapsed view. */ export function formatArgsInline(args: Record, maxWidth: number): string { - let result = ""; - let width = 0; + const keys: string[] = []; for (const key in args) { if (key in HIDDEN_ARG_KEYS) continue; + keys.push(key); + } + let result = ""; + let width = 0; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; const value = args[key]; const sep = width > 0 ? ARGS_INLINE_PAIR_SEP : ""; const sepW = width > 0 ? ARGS_INLINE_PAIR_SEP_WIDTH : 0; @@ -61,11 +68,21 @@ export function formatArgsInline(args: Record, maxWidth: number if (cap <= 0) { return `${result}${ARGS_INLINE_MORE}`; } - const valueMaxLen = Math.min(maxWidth - current, 24); + // Reserve each still-pending key's minimal footprint (sep + name + `=` + + // a short value) so a long value can't starve the keys that follow it. + let tailReserve = 0; + for (let j = i + 1; j < keys.length; j++) { + tailReserve += ARGS_INLINE_PAIR_SEP_WIDTH + Bun.stringWidth(keys[j]) + 1 + ARGS_INLINE_TAIL_VALUE_RESERVE; + } + // Budget the whole `key=value` piece against the width left after the + // tail reserve, then back out the value's share. The last key reserves + // nothing and fills the line. + const pieceBudget = Math.min(cap, maxWidth - current - tailReserve); + const valueMaxLen = Math.max(1, pieceBudget - Bun.stringWidth(key) - 3); const valueStr = formatScalar(value, valueMaxLen); const piece = `${key}=${valueStr}`; const pieceW = Bun.stringWidth(piece); - if (pieceW > cap) { + if (pieceW > pieceBudget) { return `${result}${sep}${truncateToWidth(piece, cap)}`; } result += sep + piece; diff --git a/packages/coding-agent/src/tools/memory-edit.ts b/packages/coding-agent/src/tools/memory-edit.ts index 7019c1662..45b3ab020 100644 --- a/packages/coding-agent/src/tools/memory-edit.ts +++ b/packages/coding-agent/src/tools/memory-edit.ts @@ -7,7 +7,7 @@ const memoryEditSchema = type({ op: type("'update' | 'forget' | 'invalidate'").describe("memory edit operation"), id: type("string").describe("memory id from recall output"), "content?": type("string").describe("replacement content for update"), - "importance?": type("number").describe("replacement importance for update, clamped to [0, 1]"), + "importance?": type("number").describe("replacement importance for update (0–1)"), "replacement_id?": type("string").describe("replacement memory id for invalidate"), }); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index d925a5d3e..30791e8f1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -48,8 +48,8 @@ import { webpExclusionForModel, } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; +import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; -import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader"; import { type ConflictEntry, type ConflictScope, @@ -275,7 +275,7 @@ function formatMergedBraceLine( shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): { model: string; display: string } { - const merged = `${headText.trimEnd()} .. ${tailText.trim()}`; + const merged = `${headText.trimEnd()} … ${tailText.trim()}`; if (shouldAddHashLines) { return { model: `${startLine}-${endLine}:${merged}`, display: merged }; } @@ -315,7 +315,7 @@ const FOOTER_RANGE_SAMPLES = 2; /** * Footer appended to summarized reads telling the model how to recover the - * elided body. Without this hint, agents either ignore the `...`/`{ .. }` + * elided body. Without this hint, agents either ignore the `…`/`{ … }` * markers or burn a turn guessing the right selector (see issue #1046). The * footer demonstrates the multi-range selector syntax with concrete sample * ranges drawn from the actual elision so the model re-reads only what it @@ -327,7 +327,6 @@ function formatSummaryElisionFooter( elidedLines: number, ): string { if (elidedRanges.length === 0) return ""; - const lineWord = elidedLines === 1 ? "line" : "lines"; const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES); const selector = elidedRanges .slice(0, sampleCount) @@ -335,7 +334,7 @@ function formatSummaryElisionFooter( .join(","); const example = `${readPath}:${selector}`; const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`; - return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`; + return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`; } const READ_CHUNK_SIZE = 8 * 1024; @@ -1904,8 +1903,8 @@ export class ReadTool implements AgentTool { let elidedLines = 0; for (const unit of units) { if (unit.kind === "elided") { - modelParts.push("..."); - displayParts.push("..."); + modelParts.push("…"); + displayParts.push("…"); elidedRanges.push({ start: unit.startLine, end: unit.endLine }); elidedLines += unit.endLine - unit.startLine + 1; continue; diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index fb5fad8e2..8cbcc80bb 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -1,4 +1,10 @@ -import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { + AgentTool, + AgentToolContext, + AgentToolResult, + AgentToolUpdateCallback, + CustomMessage, +} from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; @@ -28,11 +34,18 @@ export interface ResolveToolDetails { sourceResultDetails?: unknown; } +/** Monotonic suffix making each staged preview's pending-invoker id UNIQUE, so + * stacked previews never clobber one another by label. */ +let pendingPreviewSeq = 0; + /** - * Queue a resolve-protocol handler on the tool-choice queue. Forces the next - * LLM call to invoke the hidden `resolve` tool, wraps the caller's apply/reject - * callbacks into an onInvoked closure that matches the resolve schema, and - * steers a preview reminder so the model understands why. + * Register a non-forcing resolve-protocol handler for a staged preview. Wraps the + * caller's apply/reject into an onInvoked closure (matching the resolve schema) and + * stores it on the tool-choice queue's pending-invoker registry under a UNIQUE id. + * The `resolve` tool dispatches to it; the agent-loop's SoftToolRequirement + * lifecycle injects the preview reminder and escalates to a forced `resolve` only + * if the model declines — so a compliant turn pays ZERO tool_choice change (no + * prompt-cache messages-cache invalidation). * * This is the canonical entry point for any tool that wants preview/apply * semantics. No session-level abstraction is needed: callers pass their @@ -48,46 +61,55 @@ export function queueResolveHandler( }, ): void { const queue = session.getToolChoiceQueue?.(); - const forced = session.buildToolChoice?.("resolve"); - if (!queue || !forced || typeof forced === "string") return; + if (!queue) return; - const steerReminder = (): void => { - session.steer?.({ - customType: "resolve-reminder", - content: [ - "", - "This is a preview. Call the `resolve` tool to apply or discard these changes.", - "", - ].join("\n"), - details: { toolName: options.sourceToolName }, + // Unique per preview: stacked/sequential previews each get their own entry. + const id = `pending-action:${options.sourceToolName}:${pendingPreviewSeq++}`; + + const onInvoked = async (input: unknown): Promise> => { + const result = await runResolveInvocation(input as ResolveParams, { + sourceToolName: options.sourceToolName, + label: options.label, + apply: options.apply, + reject: options.reject, + onApplyError: () => { + // Apply threw (e.g. ast_edit overlapping replacements). Keep the preview + // pending under the SAME id so the model can `discard` or fix-and-retry; + // runResolveInvocation rethrows, so the success-path removal below is skipped. + queue.registerPendingInvoker(id, options.sourceToolName, onInvoked); + }, }); + // Resolved (apply succeeded, or discard): consume the staged action exactly once. + queue.removePendingInvoker(id); + return result; }; - const pushDirective = (): void => { - queue.pushOnce(forced, { - label: `pending-action:${options.sourceToolName}`, - now: true, - onRejected: () => "requeue", - onInvoked: async (input: unknown) => - runResolveInvocation(input as ResolveParams, { - sourceToolName: options.sourceToolName, - label: options.label, - apply: options.apply, - reject: options.reject, - onApplyError: () => { - // Apply threw (e.g. ast_edit overlapping replacements). Re-push the - // same directive so the preview remains pending and the model can - // `discard` or fix-and-retry on the next turn instead of being - // stranded with no pending action to address. - pushDirective(); - steerReminder(); - }, - }), - }); - }; + // NON-FORCING: register so `resolve` can dispatch here WITHOUT changing + // tool_choice. The agent-loop injects the reminder (from the SoftToolRequirement + // the session builds) and forces a resolve turn only on non-compliance. + queue.registerPendingInvoker(id, options.sourceToolName, onInvoked); +} - pushDirective(); - steerReminder(); +/** + * The canonical preview reminder. The resolve mechanism owns the wording; the + * agent-loop delivers it via the session's `SoftToolRequirement.reminder` (injected + * once per pending-preview head) instead of a host-side steer, so it lands as a + * stable mid-history append and never churns the cached prefix. + */ +export function buildResolveReminderMessage(sourceToolName: string): CustomMessage { + return { + role: "custom", + customType: "resolve-reminder", + content: [ + "", + "This is a preview. Call the `resolve` tool to apply or discard these changes.", + "", + ].join("\n"), + display: false, + details: { toolName: sourceToolName }, + attribution: "agent", + timestamp: Date.now(), + }; } /** @@ -185,7 +207,10 @@ export class ResolveTool implements AgentTool> { return untilAborted(signal, async () => { - const invoker = this.session.peekQueueInvoker?.() ?? this.session.peekStandingResolveHandler?.(); + const invoker = + this.session.peekQueueInvoker?.() ?? + this.session.peekPendingInvoker?.() ?? + this.session.peekStandingResolveHandler?.(); if (!invoker) { // `discard` is a request to cancel/abort a staged action. When nothing is // pending, the desired end-state (no staged change) already holds, so honor diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index d601026df..51547edf2 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -28,13 +28,8 @@ import { uriHyperlink, } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; +import { type ArchiveReader, type ExtractedArchiveFile, openArchive, parseArchivePathCandidates } from "../utils/zip"; import type { ToolSession } from "."; -import { - type ArchiveReader, - type ExtractedArchiveFile, - openArchive, - parseArchivePathCandidates, -} from "./archive-reader"; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 495266632..ade8a4b46 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -20,8 +20,14 @@ import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { fileHyperlink, framedBlock, renderStatusLine } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; +import { + type ArchiveMemberContent, + archiveFormatFromPath, + parseArchivePathCandidates, + readArchiveEntries, + writeArchive, +} from "../utils/zip"; import { truncateForPrompt } from "./approval"; -import { parseArchivePathCandidates } from "./archive-reader"; import { assertEditableFile } from "./auto-generated-guard"; import { type ConflictEntry, @@ -65,12 +71,6 @@ import { toolResult } from "./tool-result"; const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/; const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]"; -let fflateModulePromise: Promise | undefined; -async function loadFflate(): Promise { - if (!fflateModulePromise) fflateModulePromise = import("fflate"); - return fflateModulePromise; -} - const writeSchema = type({ path: type("string").describe("file path"), content: type("string").describe("file content"), @@ -369,9 +369,10 @@ export class WriteTool implements AgentTool resolvedArchivePath.absolutePath) : resolvedArchivePath.absolutePath; - const lowerPath = finalPath.toLowerCase(); - const isZip = lowerPath.endsWith(".zip"); - const isGzip = lowerPath.endsWith(".tar.gz") || lowerPath.endsWith(".tgz"); + // A realpath swap can land on a name without an archive extension; a + // whole-archive rewrite then defaults to an uncompressed tar, matching the + // previous `isZip`/`isGzip`/else fallthrough. + const format = archiveFormatFromPath(finalPath) ?? "tar"; // Rewrites are whole-archive: write to a temp file and rename so a // crash/disk-full mid-write can't destroy the original archive. const tmpPath = `${finalPath}.tmp-${process.pid}`; @@ -381,66 +382,25 @@ export class WriteTool implements AgentTool = {}; - - if (resolvedArchivePath.exists) { - try { - const bytes = await Bun.file(resolvedArchivePath.absolutePath).bytes(); - const { unzipSync } = await loadFflate(); - const existing = unzipSync(new Uint8Array(bytes)); - for (const [entryPath, data] of Object.entries(existing)) { - zipEntries[entryPath.replace(/\\/g, "/")] = data; - } - } catch (error) { - throw new ToolError(error instanceof Error ? error.message : String(error)); - } - } - - zipEntries[resolvedArchivePath.archiveSubPath] = new TextEncoder().encode(content); - + const entries = new Map(); + if (resolvedArchivePath.exists) { try { - const { zipSync } = await loadFflate(); - const zipBuffer = zipSync(zipEntries); - await Bun.write(tmpPath, zipBuffer); - await fs.rename(tmpPath, finalPath); + const existing = await readArchiveEntries({ bytes: await Bun.file(finalPath).bytes(), format }); + for (const [entryPath, data] of existing) { + entries.set(entryPath, data); + } } catch (error) { - await fs.rm(tmpPath, { force: true }).catch(() => {}); throw new ToolError(error instanceof Error ? error.message : String(error)); } - } else { - const archiveEntries: Record = {}; - if (resolvedArchivePath.exists) { - let archive: Bun.Archive; - try { - archive = new Bun.Archive(await Bun.file(resolvedArchivePath.absolutePath).bytes()); - } catch (error) { - throw new ToolError(error instanceof Error ? error.message : String(error)); - } + } + entries.set(resolvedArchivePath.archiveSubPath, content); - let files: Map; - try { - files = await archive.files(); - } catch (error) { - throw new ToolError(error instanceof Error ? error.message : String(error)); - } - - for (const [entryPath, file] of files) { - archiveEntries[entryPath.replace(/\\/g, "/")] = file; - } - } - - archiveEntries[resolvedArchivePath.archiveSubPath] = content; - - try { - // `Bun.Archive.write` never infers compression from the extension; - // request gzip explicitly so `.tar.gz`/`.tgz` stay compressed. - await Bun.Archive.write(tmpPath, archiveEntries, isGzip ? { compress: "gzip" } : undefined); - await fs.rename(tmpPath, finalPath); - } catch (error) { - await fs.rm(tmpPath, { force: true }).catch(() => {}); - throw new ToolError(error instanceof Error ? error.message : String(error)); - } + try { + await writeArchive(tmpPath, format, entries); + await fs.rename(tmpPath, finalPath); + } catch (error) { + await fs.rm(tmpPath, { force: true }).catch(() => {}); + throw new ToolError(error instanceof Error ? error.message : String(error)); } invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath); diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index c08b0792e..970a96f14 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -48,8 +48,8 @@ function normalizeContentPaddingLeft(value: number | undefined): number { export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] { const { header, headerMeta, state, sections = [], width, applyBg = true } = options; - const h = theme.boxSharp.horizontal; - const v = theme.boxSharp.vertical; + const h = theme.boxRound.horizontal; + const v = theme.boxRound.vertical; const cap = h.repeat(3); const lineWidth = Math.max(0, width); // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors @@ -84,8 +84,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const rows: BlockRow[] = []; rows.push({ kind: "bar", - leftChar: theme.boxSharp.topLeft, - rightChar: theme.boxSharp.topRight, + leftChar: theme.boxRound.topLeft, + rightChar: theme.boxRound.topRight, label: header, meta: headerMeta, }); @@ -99,15 +99,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st if (section.label) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, label: section.label, }); } else if (section.separator && sectionIndex > 0) { rows.push({ kind: "bar", - leftChar: theme.boxSharp.teeRight, - rightChar: theme.boxSharp.teeLeft, + leftChar: theme.boxRound.teeRight, + rightChar: theme.boxRound.teeLeft, }); } const allLines = section.lines.flatMap(l => l.split("\n")); @@ -126,7 +126,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st } } - rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight }); + rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight }); const H = rows.length; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index f42326ffc..87ad2bfa1 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -24,7 +24,7 @@ import { resolveReadPath } from "../tools/path-utils"; import { formatDimensionNote, resizeImage } from "./image-resize"; /** Regex to match @filepath patterns in text */ -const FILE_MENTION_REGEX = /@([^\s@]+)/g; +const FILE_MENTION_REGEX = /@(?:"([^"]+)"|'([^']+)'|([^\s@]+))/g; const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/; const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/; const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/; @@ -168,7 +168,10 @@ export function extractFileMentions(text: string): string[] { const index = match.index ?? 0; if (!isMentionBoundary(text, index)) continue; - const cleaned = sanitizeMentionPath(match[1]); + const rawPath = match[1] ?? match[2] ?? match[3]; + if (!rawPath) continue; + + const cleaned = match[1] !== undefined || match[2] !== undefined ? rawPath.trim() : sanitizeMentionPath(rawPath); if (!cleaned) continue; mentions.push(cleaned); diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index d2b33b509..a537ffcba 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -38,6 +38,17 @@ export interface LoadImageInputOptions { excludeWebP?: boolean; } +/** Options for loading an in-memory chat image attachment as a vision-model input. */ +export interface LoadImageAttachmentInputOptions { + image: ImageContent; + label: string; + uri: string; + autoResize: boolean; + maxBytes?: number; + /** Force non-WebP output (e.g. for Ollama). Leave unset to honor `OMP_NO_WEBP`. */ + excludeWebP?: boolean; +} + export interface LoadedImageInput { resolvedPath: string; mimeType: string; @@ -161,3 +172,50 @@ export async function loadImageInput(options: LoadImageInputOptions): Promise { + const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES; + if (!SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(options.image.mimeType)) { + return null; + } + + const inputBytes = Buffer.byteLength(options.image.data, "base64"); + if (inputBytes > maxBytes) { + throw new ImageInputTooLargeError(inputBytes, maxBytes); + } + + let outputData = options.image.data; + let outputMimeType = options.image.mimeType; + let outputBytes = inputBytes; + let dimensionNote: string | undefined; + + const shouldReencodeWebP = options.excludeWebP === true && options.image.mimeType === "image/webp"; + if (options.autoResize || shouldReencodeWebP) { + try { + const resized = await resizeImage(options.image, { excludeWebP: options.excludeWebP }); + outputData = resized.data; + outputMimeType = resized.mimeType; + outputBytes = resized.buffer.byteLength; + dimensionNote = formatDimensionNote(resized); + } catch { + // keep original image when resize fails + } + } + + let textNote = `Read image attachment ${options.label} [${outputMimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + return { + resolvedPath: options.uri, + mimeType: outputMimeType, + data: outputData, + textNote, + dimensionNote, + bytes: outputBytes, + }; +} diff --git a/packages/coding-agent/src/utils/markit.ts b/packages/coding-agent/src/utils/markit.ts index f9bd418d7..3ff5829b3 100644 --- a/packages/coding-agent/src/utils/markit.ts +++ b/packages/coding-agent/src/utils/markit.ts @@ -1,6 +1,7 @@ import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import type { Markit, StreamInfo } from "markit-ai"; +import type { Markit, StreamInfo } from "../markit"; import { ToolAbortError } from "../tools/tool-errors"; +import { loadEmbeddedMupdfWasm } from "./mupdf-wasm-embed"; export interface MarkitConversionResult { content: string; @@ -21,10 +22,7 @@ export interface MarkitFileConversionOptions { interface MuPdfWasmModuleConfig { print?: (...values: unknown[]) => void; printErr?: (...values: unknown[]) => void; -} - -declare global { - var $libmupdf_wasm_Module: MuPdfWasmModuleConfig | undefined; + wasmBinary?: Uint8Array; } function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void { @@ -32,17 +30,35 @@ function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): voi logger.debug("mupdf wasm output", { stream, message }); } +// `$libmupdf_wasm_Module` is declared globally (as `any`) by the mupdf package. +// Install print hooks before the WASM module initializes so its stdout/stderr +// route to the file logger instead of corrupting the TUI. function installMuPdfWasmLogger(): void { - const moduleConfig = globalThis.$libmupdf_wasm_Module ?? {}; - moduleConfig.print = (...values) => logMuPdfWasmOutput("stdout", values); - moduleConfig.printErr = (...values) => logMuPdfWasmOutput("stderr", values); + const moduleConfig: MuPdfWasmModuleConfig = globalThis.$libmupdf_wasm_Module ?? {}; + moduleConfig.print = (...values: unknown[]) => logMuPdfWasmOutput("stdout", values); + moduleConfig.printErr = (...values: unknown[]) => logMuPdfWasmOutput("stderr", values); + globalThis.$libmupdf_wasm_Module = moduleConfig; +} + +// Hand the WASM module its bytes directly when the compiled binary embedded them +// (scripts/embed-mupdf-wasm.ts); a single-file binary has no node_modules for +// mupdf to read `mupdf-wasm.wasm` from. Source/npm builds get undefined here and +// mupdf loads its own wasm. Must run before the mupdf module evaluates. +function installEmbeddedMupdfWasm(): void { + const wasmBinary = loadEmbeddedMupdfWasm(); + if (!wasmBinary) return; + const moduleConfig: MuPdfWasmModuleConfig = globalThis.$libmupdf_wasm_Module ?? {}; + moduleConfig.wasmBinary = wasmBinary; globalThis.$libmupdf_wasm_Module = moduleConfig; } installMuPdfWasmLogger(); let markit: () => Markit | Promise = async () => { - const promise = import("markit-ai").then(({ Markit }) => { + // Lazy: keep the document engine (mammoth/mupdf) off the startup + // import graph — it loads only when a document is first converted. + installEmbeddedMupdfWasm(); + const promise = import("../markit").then(({ Markit }) => { const instance = new Markit(); markit = () => instance; return instance; diff --git a/packages/coding-agent/src/utils/mupdf-wasm-embed.ts b/packages/coding-agent/src/utils/mupdf-wasm-embed.ts new file mode 100644 index 000000000..328efa174 --- /dev/null +++ b/packages/coding-agent/src/utils/mupdf-wasm-embed.ts @@ -0,0 +1,12 @@ +// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand. +// +// Compiled single-file binaries cannot let mupdf resolve its `mupdf-wasm.wasm` +// sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts +// and scripts/ci-release-build-binaries.ts) regenerates this module to embed the +// wasm bytes via `with { type: "file" }` and copies the wasm next to it. Source +// checkouts, `bun test`, and the npm `dist/cli.js` bundle keep mupdf external and +// load the wasm from node_modules, so this placeholder returns undefined and the +// build resets back to it afterward. +export function loadEmbeddedMupdfWasm(): Uint8Array | undefined { + return undefined; +} diff --git a/packages/coding-agent/src/utils/qrcode.ts b/packages/coding-agent/src/utils/qrcode.ts new file mode 100644 index 000000000..05558a908 --- /dev/null +++ b/packages/coding-agent/src/utils/qrcode.ts @@ -0,0 +1,535 @@ +/** + * Self-contained QR Code generator (byte mode, versions 1-40, EC levels + * L/M/Q/H) with a half-block ANSI terminal renderer. + * + * Pure TypeScript, zero dependencies: the collab `/collab qrcode` command uses + * it to print scannable browser-join codes without pulling a runtime QR + * package into the bundle. The algorithm follows ISO/IEC 18004; the two + * error-correction tables below are direct transcriptions of that spec. + */ + +export type QrEcLevel = "L" | "M" | "Q" | "H"; + +/** Per-EC-level metadata: index into the spec tables and the 2-bit format code. */ +const EC_LEVELS: Record = { + L: { table: 0, formatBits: 1 }, + M: { table: 1, formatBits: 0 }, + Q: { table: 2, formatBits: 3 }, + H: { table: 3, formatBits: 2 }, +}; + +const MIN_VERSION = 1; +const MAX_VERSION = 40; + +// ISO/IEC 18004 Table 9 — error-correction codewords per block, indexed +// [ecTable][version]. Index 0 of each row pads the 1-based version axis. +// biome-ignore format: spec table, one row per EC level +const ECC_CODEWORDS_PER_BLOCK: readonly (readonly number[])[] = [ + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], +]; + +// ISO/IEC 18004 Table 9 — number of error-correction blocks, indexed +// [ecTable][version]. +// biome-ignore format: spec table, one row per EC level +const NUM_EC_BLOCKS: readonly (readonly number[])[] = [ + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], +]; + +const BYTE_MODE_INDICATOR = 0x4; +const PAD_BYTES = [0xec, 0x11] as const; + +const PENALTY_N1 = 3; +const PENALTY_N2 = 3; +const PENALTY_N3 = 40; +const PENALTY_N4 = 10; + +function getBit(value: number, index: number): boolean { + return ((value >>> index) & 1) !== 0; +} + +/** Whether mask `m` flips the module at (x, y); the 8 data-mask conditions from the spec. */ +function maskBit(m: number, x: number, y: number): boolean { + switch (m) { + case 0: + return (x + y) % 2 === 0; + case 1: + return y % 2 === 0; + case 2: + return x % 3 === 0; + case 3: + return (x + y) % 3 === 0; + case 4: + return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; + case 5: + return ((x * y) % 2) + ((x * y) % 3) === 0; + case 6: + return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; + default: + return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; + } +} + +/** GF(256) multiply under the QR primitive polynomial x^8 + x^4 + x^3 + x^2 + 1 (0x11D). */ +function gfMultiply(x: number, y: number): number { + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + return z & 0xff; +} + +/** Reed-Solomon generator polynomial coefficients for `degree` EC codewords. */ +function rsDivisor(degree: number): Uint8Array { + const result = new Uint8Array(degree); + result[degree - 1] = 1; + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = gfMultiply(result[j]!, root); + if (j + 1 < result.length) result[j] ^= result[j + 1]!; + } + root = gfMultiply(root, 0x02); + } + return result; +} + +/** Reed-Solomon remainder (the EC codewords) of `data` divided by `divisor`. */ +function rsRemainder(data: Uint8Array, divisor: Uint8Array): Uint8Array { + const result = new Uint8Array(divisor.length); + for (const b of data) { + const factor = b ^ result[0]!; + result.copyWithin(0, 1); + result[result.length - 1] = 0; + for (let i = 0; i < divisor.length; i++) result[i] ^= gfMultiply(divisor[i]!, factor); + } + return result; +} + +/** Total data modules (bits available before EC) for a version. */ +function rawDataModules(version: number): number { + let result = (16 * version + 128) * version + 64; + if (version >= 2) { + const numAlign = Math.floor(version / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (version >= 7) result -= 36; + } + return result; +} + +/** Number of usable data codewords (8-bit) at a given version + EC level. */ +function dataCodewords(version: number, ecTable: number): number { + return ( + Math.floor(rawDataModules(version) / 8) - + ECC_CODEWORDS_PER_BLOCK[ecTable]![version]! * NUM_EC_BLOCKS[ecTable]![version]! + ); +} + +/** Byte-mode character-count indicator width in bits for a version. */ +function charCountBits(version: number): number { + return version <= 9 ? 8 : 16; +} + +export interface QrEncodeOptions { + /** Lowest version to consider (default 1). */ + minVersion?: number; + /** Highest version to consider (default 40). */ + maxVersion?: number; + /** Force a mask 0-7; -1 (default) auto-selects the lowest-penalty mask. */ + mask?: number; +} + +/** + * A finished QR symbol: a square grid of dark/light modules plus the chosen + * version, EC level, and mask. `module(x, y)` is the only access path the + * renderers need. + */ +export class QrCode { + readonly size: number; + /** Selected mask pattern (0-7). */ + readonly mask: number; + readonly #modules: boolean[][]; + readonly #isFunction: boolean[][]; + + private constructor( + readonly version: number, + readonly ecLevel: QrEcLevel, + dataCodewordsInterleaved: Uint8Array, + mask: number, + ) { + this.size = version * 4 + 17; + this.#modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); + this.#isFunction = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); + + this.#drawFunctionPatterns(); + this.#drawCodewords(dataCodewordsInterleaved); + this.mask = this.#selectMask(mask); + } + + module(x: number, y: number): boolean { + return this.#modules[y]![x]!; + } + + /** Encode a string in byte mode (UTF-8). Throws if it exceeds version 40. */ + static encodeText(text: string, ecLevel: QrEcLevel = "M", options?: QrEncodeOptions): QrCode { + return QrCode.encodeBytes(new TextEncoder().encode(text), ecLevel, options); + } + + /** Encode raw bytes in byte mode. Throws if they exceed version 40 at this EC level. */ + static encodeBytes(data: Uint8Array, ecLevel: QrEcLevel = "M", options?: QrEncodeOptions): QrCode { + const ec = EC_LEVELS[ecLevel]; + const minVersion = Math.max(MIN_VERSION, options?.minVersion ?? MIN_VERSION); + const maxVersion = Math.min(MAX_VERSION, options?.maxVersion ?? MAX_VERSION); + + let version = minVersion; + for (; ; version++) { + const capacityBits = dataCodewords(version, ec.table) * 8; + const usedBits = 4 + charCountBits(version) + data.length * 8; + if (usedBits <= capacityBits) break; + if (version >= maxVersion) { + throw new Error(`data too long for a QR code (${data.length} bytes, EC ${ecLevel})`); + } + } + + const bits = new BitBuffer(); + bits.append(BYTE_MODE_INDICATOR, 4); + bits.append(data.length, charCountBits(version)); + for (const b of data) bits.append(b, 8); + + const capacityBits = dataCodewords(version, ec.table) * 8; + bits.append(0, Math.min(4, capacityBits - bits.length)); // terminator + bits.append(0, (8 - (bits.length % 8)) % 8); // byte-align + for (let pad = 0; bits.length < capacityBits; pad ^= 1) bits.append(PAD_BYTES[pad]!, 8); + + const codewords = QrCode.#interleave(bits.toBytes(), version, ec.table); + const mask = options?.mask ?? -1; + if (mask < -1 || mask > 7) throw new Error(`invalid mask ${mask}`); + return new QrCode(version, ecLevel, codewords, mask); + } + + /** Split into blocks, append Reed-Solomon EC, and interleave per the spec. */ + static #interleave(data: Uint8Array, version: number, ecTable: number): Uint8Array { + const numBlocks = NUM_EC_BLOCKS[ecTable]![version]!; + const eccLen = ECC_CODEWORDS_PER_BLOCK[ecTable]![version]!; + const rawCodewords = Math.floor(rawDataModules(version) / 8); + const numShort = numBlocks - (rawCodewords % numBlocks); + const shortLen = Math.floor(rawCodewords / numBlocks); + const divisor = rsDivisor(eccLen); + + const blocks: Uint8Array[] = []; + const blockLen = shortLen + 1; + for (let i = 0, offset = 0; i < numBlocks; i++) { + const datLen = shortLen - eccLen + (i < numShort ? 0 : 1); + const dat = data.subarray(offset, offset + datLen); + offset += datLen; + // Every block is padded to the longest block's length so interleaving + // stays column-aligned; short blocks leave a zero in the last data slot. + const block = new Uint8Array(blockLen); + block.set(dat, 0); + block.set(rsRemainder(dat, divisor), blockLen - eccLen); + blocks.push(block); + } + + const result = new Uint8Array(rawCodewords); + let w = 0; + for (let i = 0; i < blockLen; i++) { + for (let b = 0; b < numBlocks; b++) { + // Skip the padding column at the data/EC boundary of short blocks. + if (i === shortLen - eccLen && b < numShort) continue; + result[w++] = blocks[b]![i]!; + } + } + return result; + } + + // ── Module placement ────────────────────────────────────────────────── + + #setFunction(x: number, y: number, dark: boolean): void { + this.#modules[y]![x] = dark; + this.#isFunction[y]![x] = true; + } + + #drawFunctionPatterns(): void { + for (let i = 0; i < this.size; i++) { + this.#setFunction(6, i, i % 2 === 0); + this.#setFunction(i, 6, i % 2 === 0); + } + this.#drawFinder(3, 3); + this.#drawFinder(this.size - 4, 3); + this.#drawFinder(3, this.size - 4); + + const align = this.#alignmentPositions(); + const last = align.length - 1; + for (let i = 0; i <= last; i++) { + for (let j = 0; j <= last; j++) { + // Skip the three finder corners. + if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) continue; + this.#drawAlignment(align[i]!, align[j]!); + } + } + + this.#drawFormatBits(0); // placeholder until mask chosen + this.#drawVersion(); + } + + #drawFinder(cx: number, cy: number): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const x = cx + dx; + const y = cy + dy; + if (x >= 0 && x < this.size && y >= 0 && y < this.size) { + this.#setFunction(x, y, dist !== 2 && dist !== 4); + } + } + } + } + + #drawAlignment(cx: number, cy: number): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + this.#setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } + } + + #alignmentPositions(): number[] { + if (this.version === 1) return []; + const numAlign = Math.floor(this.version / 7) + 2; + const step = this.version === 32 ? 26 : Math.ceil((this.size - 13) / (numAlign * 2 - 2)) * 2; + const result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos); + return result; + } + + #drawFormatBits(mask: number): void { + const data = (EC_LEVELS[this.ecLevel].formatBits << 3) | mask; + let rem = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; + + for (let i = 0; i <= 5; i++) this.#setFunction(8, i, getBit(bits, i)); + this.#setFunction(8, 7, getBit(bits, 6)); + this.#setFunction(8, 8, getBit(bits, 7)); + this.#setFunction(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.#setFunction(14 - i, 8, getBit(bits, i)); + + for (let i = 0; i < 8; i++) this.#setFunction(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.#setFunction(8, this.size - 15 + i, getBit(bits, i)); + this.#setFunction(8, this.size - 8, true); // always-dark module + } + + #drawVersion(): void { + if (this.version < 7) return; + let rem = this.version; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits = (this.version << 12) | rem; + for (let i = 0; i < 18; i++) { + const bit = getBit(bits, i); + const a = this.size - 11 + (i % 3); + const b = Math.floor(i / 3); + this.#setFunction(a, b, bit); + this.#setFunction(b, a, bit); + } + } + + #drawCodewords(data: Uint8Array): void { + let i = 0; + const totalBits = data.length * 8; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = ((right + 1) & 2) === 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.#isFunction[y]![x] && i < totalBits) { + this.#modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); + i++; + } + } + } + } + } + + // ── Masking ─────────────────────────────────────────────────────────── + + #applyMask(mask: number): void { + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + if (!this.#isFunction[y]![x] && maskBit(mask, x, y)) { + this.#modules[y]![x] = !this.#modules[y]![x]; + } + } + } + } + + #selectMask(forced: number): number { + let mask = forced; + if (mask === -1) { + let minPenalty = Infinity; + for (let m = 0; m < 8; m++) { + this.#applyMask(m); + this.#drawFormatBits(m); + const penalty = this.#penaltyScore(); + if (penalty < minPenalty) { + mask = m; + minPenalty = penalty; + } + this.#applyMask(m); // undo (XOR mask is self-inverse) + } + } + this.#applyMask(mask); + this.#drawFormatBits(mask); + return mask; + } + + #penaltyScore(): number { + let result = 0; + const size = this.size; + const mods = this.#modules; + + // Rule 1 + Rule 3 — adjacent same-color runs, finder-like patterns (rows). + for (let y = 0; y < size; y++) { + let runColor = false; + let runLen = 0; + const history = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < size; x++) { + if (mods[y]![x] === runColor) { + runLen++; + if (runLen === 5) result += PENALTY_N1; + else if (runLen > 5) result++; + } else { + this.#finderAddHistory(runLen, history); + if (!runColor) result += this.#finderCountPatterns(history) * PENALTY_N3; + runColor = mods[y]![x]!; + runLen = 1; + } + } + result += this.#finderTerminate(runColor, runLen, history) * PENALTY_N3; + } + // Rule 1 + Rule 3 — columns. + for (let x = 0; x < size; x++) { + let runColor = false; + let runLen = 0; + const history = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < size; y++) { + if (mods[y]![x] === runColor) { + runLen++; + if (runLen === 5) result += PENALTY_N1; + else if (runLen > 5) result++; + } else { + this.#finderAddHistory(runLen, history); + if (!runColor) result += this.#finderCountPatterns(history) * PENALTY_N3; + runColor = mods[y]![x]!; + runLen = 1; + } + } + result += this.#finderTerminate(runColor, runLen, history) * PENALTY_N3; + } + + // Rule 2 — 2x2 blocks of one color. + for (let y = 0; y < size - 1; y++) { + for (let x = 0; x < size - 1; x++) { + const c = mods[y]![x]; + if (c === mods[y]![x + 1] && c === mods[y + 1]![x] && c === mods[y + 1]![x + 1]) { + result += PENALTY_N2; + } + } + } + + // Rule 4 — dark/light balance. + let dark = 0; + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (mods[y]![x]) dark++; + const total = size * size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + result += k * PENALTY_N4; + return result; + } + + #finderCountPatterns(history: readonly number[]): number { + const n = history[1]!; + const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n; + return ( + (core && history[0]! >= n * 4 && history[6]! >= n ? 1 : 0) + + (core && history[6]! >= n * 4 && history[0]! >= n ? 1 : 0) + ); + } + + #finderAddHistory(runLen: number, history: number[]): void { + if (history[0] === 0) runLen += this.size; // light border before the first run + history.pop(); + history.unshift(runLen); + } + + #finderTerminate(runColor: boolean, runLen: number, history: number[]): number { + if (runColor) { + this.#finderAddHistory(runLen, history); + runLen = 0; + } + runLen += this.size; // light border after the final run + this.#finderAddHistory(runLen, history); + return this.#finderCountPatterns(history); + } +} + +/** Append-only MSB-first bit buffer. */ +class BitBuffer { + #bits: number[] = []; + + get length(): number { + return this.#bits.length; + } + + append(value: number, count: number): void { + for (let i = count - 1; i >= 0; i--) this.#bits.push((value >>> i) & 1); + } + + toBytes(): Uint8Array { + const out = new Uint8Array(this.#bits.length >>> 3); + for (let i = 0; i < this.#bits.length; i++) out[i >>> 3] = (out[i >>> 3]! << 1) | this.#bits[i]!; + return out; + } +} + +export interface QrRenderOptions { + /** Quiet-zone width in modules on every side (default 4, per spec). */ + margin?: number; +} + +const ANSI_RESET = "\x1b[0m"; +const ANSI_QR_ROW_PREFIX = "\x1b[47m\x1b[30m"; // white background, black foreground + +/** + * Render a QR symbol as ANSI half-block rows: each text row packs two module + * rows via `▀`/`▄`/`█`, drawn black-on-white so a phone camera reads dark + * modules as data and the quiet zone as the light margin. The leading margin + * makes the symbol scannable regardless of the terminal's own background. + */ +export function renderQrHalfBlocks(qr: QrCode, options?: QrRenderOptions): string[] { + const margin = Math.max(0, options?.margin ?? 4); + const dim = qr.size + margin * 2; + const dark = (gx: number, gy: number): boolean => { + const x = gx - margin; + const y = gy - margin; + return x >= 0 && x < qr.size && y >= 0 && y < qr.size && qr.module(x, y); + }; + + const lines: string[] = []; + for (let gy = 0; gy < dim; gy += 2) { + let row = ANSI_QR_ROW_PREFIX; + for (let gx = 0; gx < dim; gx++) { + const top = dark(gx, gy); + const bottom = gy + 1 < dim && dark(gx, gy + 1); + row += top ? (bottom ? "█" : "▀") : bottom ? "▄" : " "; + } + lines.push(row + ANSI_RESET); + } + return lines; +} diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index fb4c1b38e..7d6921ec6 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils"; +import { extractArchive } from "./zip"; const TOOLS_DIR = getToolsDir(); const TOOL_DOWNLOAD_TIMEOUT_MS = 120_000; @@ -220,17 +221,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise; +}; + +/** + * Build a Turndown instance configured for GFM with the fixes omp relies on: + * `~~strikethrough~~`, unescaped heading periods, and single-space list markers. + * + * Shared by the web scrapers (HTML → markdown) and the markit document engine + * (`src/markit`). The rule set must stay identical across both call sites. + */ +export function createTurndown(): TurndownService { + const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + }); + turndown.use(gfm); + // GFM spec uses ~~ (double tilde), not ~ (single) + turndown.addRule("strikethrough", { + filter: ["del", "s", "strike"], + replacement(content) { + return `~~${content}~~`; + }, + }); + // Unescape the backslash turndown inserts before periods in headings ("1." -> "1\.") + turndown.addRule("heading", { + filter: ["h1", "h2", "h3", "h4", "h5", "h6"], + replacement(content, node) { + const level = Number(node.nodeName.charAt(1)); + const prefix = "#".repeat(level); + const cleaned = content.replace(/\\([.])/g, "$1").trim(); + return `\n\n${prefix} ${cleaned}\n\n`; + }, + }); + // Single space after the marker (turndown hardcodes three) + turndown.addRule("listItem", { + filter: "li", + replacement(content, node, options) { + const body = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n "); + const parent = node.parentNode as unknown as TurndownListParent | null; + let prefix = `${options.bulletListMarker} `; + if (parent?.nodeName === "OL") { + const start = parent.getAttribute("start"); + const index = Array.prototype.indexOf.call(parent.children, node); + prefix = `${(start ? Number(start) : 1) + index}. `; + } + return prefix + body + (node.nextSibling ? "\n" : ""); + }, + }); + return turndown; +} + +/** + * Normalize HTML tables so turndown-plugin-gfm can render them: + * - strip `

` tags inside ``/`` cells (joining paragraphs with a space) + * - wrap the first row in `` when missing + */ +export function normalizeTablesHtml(html: string): string { + let result = html.replace( + /<(td|th)([^>]*)>([\s\S]*?)<\/(td|th)>/gi, + (_match, tag: string, attrs: string, inner: string, closeTag: string) => { + const stripped = inner + .replace(/^\s*

/i, "") + .replace(/<\/p>\s*$/i, "") + .replace(/<\/p>\s*

/gi, " "); + return `<${tag}${attrs}>${stripped}`; + }, + ); + result = result.replace( + /]*)>\s*(?:\s*)?()([\s\S]*?)<\/(?:tbody>\s*<\/)?table>/gi, + (_match, attrs: string, firstRow: string, rest: string) => { + const theadRow = firstRow.replace(//gi, ""); + return `${theadRow}${rest}`; + }, + ); + return result; +} diff --git a/packages/coding-agent/src/tools/archive-reader.ts b/packages/coding-agent/src/utils/zip.ts similarity index 52% rename from packages/coding-agent/src/tools/archive-reader.ts rename to packages/coding-agent/src/utils/zip.ts index b004e6af5..962c4e18c 100644 --- a/packages/coding-agent/src/tools/archive-reader.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -1,10 +1,47 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; +// The single archive boundary for the codebase: ZIP (framed here, over the raw +// DEFLATE codec in `node:zlib`) and tar / tar.gz (via `Bun.Archive`). This is +// the ONLY module that frames ZIP containers or touches `Bun.Archive`; the +// markit document converters, the read/search/write tools, the URL fetcher, the +// debug report bundler, and the tool-binary installer all go through here so +// there is exactly one archive implementation to reason about. Do not parse or +// build ZIP/tar, or call `Bun.Archive`, anywhere else. import * as path from "node:path"; -import { inflateSync, strFromU8 } from "fflate"; +import * as zlib from "node:zlib"; +import { formatBytes } from "@oh-my-pi/pi-utils"; +import { ToolError } from "../tools/tool-errors"; -import { formatBytes } from "./render-utils"; -import { ToolError } from "./tool-errors"; +/** A ZIP archive decoded to a `path → bytes` map of its file members. */ +export type Unzipped = Record; + +const ENCODER = new TextEncoder(); +// `node:zlib` is only the DEFLATE codec; ZIP container framing is ours (see +// `unzip` / `zip` below). Entry names use the platform text decoders. +const UTF8_DECODER = new TextDecoder(); +// ZIP central-directory names without the UTF-8 flag carry no reliable encoding; +// decode them as their legacy code page (windows-1252) as a stable best effort. +const LEGACY_NAME_DECODER = new TextDecoder("windows-1252"); + +/** Read a single ZIP entry as UTF-8 text, or `undefined` when the entry is absent. */ +export function unzipText(entries: Unzipped, entryPath: string): string | undefined { + const data = entries[entryPath]; + return data ? UTF8_DECODER.decode(data) : undefined; +} + +/** + * Decode an in-memory ZIP archive into a `path → bytes` map of its file members + * (directory entries and `..`-escaping names are dropped). Shares the + * central-directory record parser with the lazy, file-backed reader. + */ +export function unzip(bytes: Uint8Array): Unzipped { + const info = readCentralDirectoryInfoSync(bytes); + const centralDirectory = readMemoryRange(bytes, info.offset, info.offset + info.size); + const out: Unzipped = {}; + for (const entry of parseZipCentralDirectory(memoryByteSource(bytes), centralDirectory, info.entries)) { + if (entry.isDirectory || entry.storage?.type !== "zip") continue; + out[entry.path] = extractZipMember(bytes, entry.storage, entry.size); + } + return out; +} /** * Cap on the on-disk size of tar/tar.gz archives, which are loaded fully into @@ -19,8 +56,23 @@ const MAX_TAR_ARCHIVE_BYTES = 256 * 1024 * 1024; */ const MAX_ARCHIVE_MEMBER_BYTES = 64 * 1024 * 1024; +/** Inflate one raw DEFLATE stream, bounded to its declared uncompressed size. */ +function inflateRaw(bytes: Uint8Array, declaredSize: number): Uint8Array { + return zlib.inflateRawSync(bytes, { maxOutputLength: Math.max(declaredSize, 1) }); +} + export type ArchiveFormat = "zip" | "tar" | "tar.gz"; +/** + * Where to read an archive from: a filesystem path (format inferred from the + * extension; ZIP is read lazily via ranged central-directory access) or + * in-memory bytes with an explicit format. + */ +export type ArchiveSource = string | { bytes: Uint8Array; format: ArchiveFormat }; + +/** Content for a member when packing or extracting an archive. */ +export type ArchiveMemberContent = string | Uint8Array | Blob; + export interface ArchivePathCandidate { archivePath: string; subPath: string; @@ -41,6 +93,55 @@ export interface ExtractedArchiveFile extends ArchiveNode { bytes: Uint8Array; } +/** A byte window into an archive — file-backed (lazy) or in-memory. */ +interface ByteSource { + readonly size: number; + read(start: number, end: number): Promise; +} + +function assertValidRange(start: number, end: number): void { + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + throw new ToolError("Invalid ZIP archive range"); + } +} + +/** Read an exact in-memory range, throwing (not clamping) when it runs past the buffer. */ +function readMemoryRange(buffer: Uint8Array, start: number, end: number): Uint8Array { + assertValidRange(start, end); + if (end > buffer.byteLength) { + throw new ToolError("Invalid ZIP archive: truncated data"); + } + return buffer.subarray(start, end); +} + +function fileByteSource(filePath: string): ByteSource { + const file = Bun.file(filePath); + const size = file.size; + if (!Number.isSafeInteger(size)) { + throw new ToolError("ZIP archive is too large to read safely"); + } + return { + size, + async read(start, end) { + assertValidRange(start, end); + const bytes = await file.slice(start, end).bytes(); + if (bytes.byteLength !== end - start) { + throw new ToolError("Invalid ZIP archive: truncated data"); + } + return bytes; + }, + }; +} + +function memoryByteSource(buffer: Uint8Array): ByteSource { + return { + size: buffer.byteLength, + async read(start, end) { + return readMemoryRange(buffer, start, end); + }, + }; +} + interface TarStorage { type: "tar"; file: File; @@ -48,7 +149,7 @@ interface TarStorage { interface ZipStorage { type: "zip"; - archivePath: string; + source: ByteSource; compressedSize: number; compression: number; flags: number; @@ -132,7 +233,8 @@ function ensureParentDirectories(map: Map): void { } } -function getArchiveFormatFromPath(filePath: string): ArchiveFormat | undefined { +/** Infer an archive format from a filesystem path's extension. */ +export function archiveFormatFromPath(filePath: string): ArchiveFormat | undefined { const normalized = filePath.toLowerCase(); if (normalized.endsWith(".tar.gz") || normalized.endsWith(".tgz")) return "tar.gz"; if (normalized.endsWith(".tar")) return "tar"; @@ -233,18 +335,6 @@ function readUInt64LEAsNumber(bytes: Uint8Array, offset: number): number { return value; } -async function readZipRange(filePath: string, start: number, end: number): Promise { - if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { - throw new ToolError("Invalid ZIP archive range"); - } - - const bytes = await Bun.file(filePath).slice(start, end).bytes(); - if (bytes.byteLength !== end - start) { - throw new ToolError("Invalid ZIP archive: truncated data"); - } - return bytes; -} - function findEndOfCentralDirectory(tail: Uint8Array): number { for (let offset = tail.byteLength - ZIP_EOCD_MIN_LENGTH; offset >= 0; offset--) { if (readUInt32LE(tail, offset) !== ZIP_EOCD_SIGNATURE) continue; @@ -256,7 +346,7 @@ function findEndOfCentralDirectory(tail: Uint8Array): number { } async function readZip64CentralDirectoryInfo( - filePath: string, + source: ByteSource, tail: Uint8Array, tailStart: number, eocdOffset: number, @@ -267,7 +357,7 @@ async function readZip64CentralDirectoryInfo( const locator = locatorOffset >= tailStart ? tail.subarray(locatorOffset - tailStart, locatorOffset - tailStart + ZIP64_EOCD_LOCATOR_LENGTH) - : await readZipRange(filePath, locatorOffset, eocdOffset); + : await source.read(locatorOffset, eocdOffset); if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return undefined; const zip64EocdDisk = readUInt32LE(locator, 4); @@ -277,7 +367,7 @@ async function readZip64CentralDirectoryInfo( throw new ToolError("Multi-disk ZIP archives are not supported"); } - const record = await readZipRange(filePath, zip64EocdOffset, zip64EocdOffset + 56); + const record = await source.read(zip64EocdOffset, zip64EocdOffset + 56); if (readUInt32LE(record, 0) !== ZIP64_EOCD_SIGNATURE) { throw new ToolError("Invalid ZIP archive: missing ZIP64 end of central directory"); } @@ -292,14 +382,15 @@ async function readZip64CentralDirectoryInfo( }; } -async function readZipCentralDirectoryInfo(filePath: string, fileSize: number): Promise { +async function readZipCentralDirectoryInfo(source: ByteSource): Promise { + const fileSize = source.size; if (fileSize < ZIP_EOCD_MIN_LENGTH) { throw new ToolError("Invalid ZIP archive: missing end of central directory"); } const tailLength = Math.min(fileSize, ZIP_EOCD_MIN_LENGTH + ZIP_EOCD_MAX_COMMENT_LENGTH); const tailStart = fileSize - tailLength; - const tail = await readZipRange(filePath, tailStart, fileSize); + const tail = await source.read(tailStart, fileSize); const eocdIndex = findEndOfCentralDirectory(tail); const eocdOffset = tailStart + eocdIndex; @@ -311,7 +402,7 @@ async function readZipCentralDirectoryInfo(filePath: string, fileSize: number): let size = readUInt32LE(tail, eocdIndex + 12); let offset = readUInt32LE(tail, eocdIndex + 16); const needsZip64 = entries === ZIP_UINT16_MAX || size === ZIP_UINT32_MAX || offset === ZIP_UINT32_MAX; - const zip64Info = await readZip64CentralDirectoryInfo(filePath, tail, tailStart, eocdOffset); + const zip64Info = await readZip64CentralDirectoryInfo(source, tail, tailStart, eocdOffset); if (zip64Info) { ({ entries, size, offset } = zip64Info); } else if (needsZip64) { @@ -386,7 +477,7 @@ function readZip64EntryValues( } function parseZipCentralDirectory( - filePath: string, + source: ByteSource, centralDirectory: Uint8Array, expectedEntries: number, ): ArchiveIndexEntry[] { @@ -417,7 +508,10 @@ function parseZipCentralDirectory( throw new ToolError("Invalid ZIP archive: truncated central directory entry"); } - const rawPath = strFromU8(centralDirectory.subarray(nameStart, extraStart), (flags & ZIP_UTF8_FLAG) === 0); + const useLegacyEncoding = (flags & ZIP_UTF8_FLAG) === 0; + const rawPath = (useLegacyEncoding ? LEGACY_NAME_DECODER : UTF8_DECODER).decode( + centralDirectory.subarray(nameStart, extraStart), + ); const normalizedPath = normalizeArchiveEntryPath(rawPath); if (normalizedPath) { const values = readZip64EntryValues( @@ -448,7 +542,7 @@ function parseZipCentralDirectory( ? undefined : { type: "zip", - archivePath: filePath, + source, compressedSize: values.compressedSize, compression, flags, @@ -463,16 +557,27 @@ function parseZipCentralDirectory( return entries; } +/** Decode a single ZIP member's already-read payload, bounded to its declared size. */ +function decodeZipMember(compressed: Uint8Array, compression: number, declaredSize: number): Uint8Array { + if (compression === ZIP_STORED_COMPRESSION) { + return compressed; + } + if (compression !== ZIP_DEFLATE_COMPRESSION) { + throw new ToolError(`Unsupported ZIP compression method: ${compression}`); + } + try { + return inflateRaw(compressed, declaredSize); + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } +} + async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): Promise { if ((storage.flags & ZIP_ENCRYPTED_FLAG) !== 0) { throw new ToolError("Encrypted ZIP entries are not supported"); } - const localHeader = await readZipRange( - storage.archivePath, - storage.localHeaderOffset, - storage.localHeaderOffset + 30, - ); + const localHeader = await storage.source.read(storage.localHeaderOffset, storage.localHeaderOffset + 30); if (readUInt32LE(localHeader, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) { throw new ToolError("Invalid ZIP archive: malformed local file header"); } @@ -480,20 +585,8 @@ async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): const fileNameLength = readUInt16LE(localHeader, 26); const extraLength = readUInt16LE(localHeader, 28); const dataStart = storage.localHeaderOffset + 30 + fileNameLength + extraLength; - const compressedBytes = await readZipRange(storage.archivePath, dataStart, dataStart + storage.compressedSize); - - if (storage.compression === ZIP_STORED_COMPRESSION) { - return compressedBytes; - } - if (storage.compression !== ZIP_DEFLATE_COMPRESSION) { - throw new ToolError(`Unsupported ZIP compression method: ${storage.compression}`); - } - - try { - return inflateSync(compressedBytes, { out: new Uint8Array(uncompressedSize) }); - } catch (error) { - throw new ToolError(error instanceof Error ? error.message : String(error)); - } + const compressedBytes = await storage.source.read(dataStart, dataStart + storage.compressedSize); + return decodeZipMember(compressedBytes, storage.compression, uncompressedSize); } async function readTarEntries(bytes: Uint8Array): Promise { @@ -528,21 +621,18 @@ async function readTarEntries(bytes: Uint8Array): Promise { return entries; } -async function readZipEntries(filePath: string): Promise { - const fileSize = Bun.file(filePath).size; - if (!Number.isSafeInteger(fileSize)) { - throw new ToolError("ZIP archive is too large to read safely"); - } - - const directoryInfo = await readZipCentralDirectoryInfo(filePath, fileSize); - const centralDirectory = await readZipRange( - filePath, - directoryInfo.offset, - directoryInfo.offset + directoryInfo.size, - ); - return parseZipCentralDirectory(filePath, centralDirectory, directoryInfo.entries); +async function readZipEntries(source: ByteSource): Promise { + const directoryInfo = await readZipCentralDirectoryInfo(source); + const centralDirectory = await source.read(directoryInfo.offset, directoryInfo.offset + directoryInfo.size); + return parseZipCentralDirectory(source, centralDirectory, directoryInfo.entries); } +/** + * Split an `archive.ext:inner/path` reference into every plausible + * `{ archivePath, subPath }` pair, longest archive prefix first. A path may + * contain more than one archive extension, so each candidate is a guess at + * where the archive ends and the member portion begins. + */ export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] { const normalized = filePath.replace(/\\/g, "/"); const pattern = /\.(?:tar\.gz|tgz|zip|tar)(?=(?::|$))/gi; @@ -567,6 +657,11 @@ export function parseArchivePathCandidates(filePath: string): ArchivePathCandida return candidates.sort((left, right) => right.archivePath.length - left.archivePath.length); } +/** + * An indexed, read-only view over a single archive. ZIP archives are indexed + * from the central directory and members are inflated on demand; tar archives + * are fully materialized by `Bun.Archive` up front. + */ export class ArchiveReader { readonly format: ArchiveFormat; #entries = new Map(); @@ -680,42 +775,317 @@ export class ArchiveReader { } } -export async function openArchive(filePath: string): Promise { - const format = getArchiveFormatFromPath(filePath); - if (!format) { - throw new ToolError(`Unsupported archive format: ${filePath}`); +/** + * Open an archive for reading. ZIP archives opened from a path are indexed + * lazily via ranged central-directory reads (members inflate on demand); tar + * archives and in-memory ZIPs are read from a single buffer. + */ +export async function openArchive(source: ArchiveSource): Promise { + if (typeof source === "string") { + const format = archiveFormatFromPath(source); + if (!format) { + throw new ToolError(`Unsupported archive format: ${source}`); + } + if (format === "zip") { + return new ArchiveReader(format, await readZipEntries(fileByteSource(source))); + } + + const file = Bun.file(source); + const archiveSize = file.size; + if (archiveSize > MAX_TAR_ARCHIVE_BYTES) { + throw new ToolError( + `Archive is too large to read in memory (${formatBytes(archiveSize)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`, + ); + } + return new ArchiveReader(format, await readTarEntries(await file.bytes())); } + const { bytes, format } = source; if (format === "zip") { - return new ArchiveReader(format, await readZipEntries(filePath)); + return new ArchiveReader(format, await readZipEntries(memoryByteSource(bytes))); } - - const file = Bun.file(filePath); - const archiveSize = file.size; - if (archiveSize > MAX_TAR_ARCHIVE_BYTES) { + if (bytes.byteLength > MAX_TAR_ARCHIVE_BYTES) { throw new ToolError( - `Archive is too large to read in memory (${formatBytes(archiveSize)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`, + `Archive is too large to read in memory (${formatBytes(bytes.byteLength)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`, ); } - const entries = await readTarEntries(await file.bytes()); - return new ArchiveReader(format, entries); + return new ArchiveReader(format, await readTarEntries(bytes)); } +/** Render the top-level entries of an in-memory archive as one line each. */ export async function listArchiveRoot( bytes: Uint8Array, format: ArchiveFormat, opts: { limit?: number } = {}, ): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-archive-")); - const tempPath = path.join(tempDir, `payload.${format}`); - try { - await Bun.write(tempPath, bytes); - const archive = await openArchive(tempPath); - const entries = archive.listDirectory(""); - const limitedEntries = opts.limit !== undefined && opts.limit > 0 ? entries.slice(0, opts.limit) : entries; - const lines = formatArchiveEntryLines(limitedEntries); - return lines.length > 0 ? lines.join("\n") : "(empty archive directory)"; - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const archive = await openArchive({ bytes, format }); + const entries = archive.listDirectory(""); + const limitedEntries = opts.limit !== undefined && opts.limit > 0 ? entries.slice(0, opts.limit) : entries; + const lines = formatArchiveEntryLines(limitedEntries); + return lines.length > 0 ? lines.join("\n") : "(empty archive directory)"; +} + +async function resolveArchiveBytes(source: ArchiveSource): Promise<{ bytes: Uint8Array; format: ArchiveFormat }> { + if (typeof source !== "string") return source; + const format = archiveFormatFromPath(source); + if (!format) { + throw new ToolError(`Unsupported archive format: ${source}`); + } + return { bytes: await Bun.file(source).bytes(), format }; +} + +async function memberToBytes(content: ArchiveMemberContent): Promise { + if (typeof content === "string") return ENCODER.encode(content); + if (content instanceof Uint8Array) return content; + return new Uint8Array(await content.arrayBuffer()); +} + +/** + * Fully materialize every file member into a `path → content` map: ZIP members + * are inflated in memory, tar members are returned as lazy `File`s. Use this + * when you need every entry (rewrite, extract); for browsing or single-member + * reads prefer `openArchive`, which is lazy for ZIP. + */ +export async function readArchiveEntries(source: ArchiveSource): Promise> { + const { bytes, format } = await resolveArchiveBytes(source); + const entries = new Map(); + if (format === "zip") { + const unzipped = unzip(bytes); + for (const name in unzipped) { + entries.set(name, unzipped[name]!); + } + return entries; + } + const files = await new Bun.Archive(bytes).files(); + for (const [name, file] of files) { + entries.set(name.replace(/\\/g, "/"), file); + } + return entries; +} + +/** + * Serialize `entries` into an archive of `format` and write it to `destPath`. + * ZIP is framed in memory, tar / tar.gz via `Bun.Archive` (gzip for tar.gz). + * String members are encoded as UTF-8. + */ +export async function writeArchive( + destPath: string, + format: ArchiveFormat, + entries: Iterable, +): Promise { + if (format === "zip") { + const record: Record = {}; + for (const [name, content] of entries) { + record[name.replace(/\\/g, "/")] = await memberToBytes(content); + } + await Bun.write(destPath, zip(record)); + return; + } + + const record: Record = {}; + for (const [name, content] of entries) { + record[name.replace(/\\/g, "/")] = content; + } + await Bun.Archive.write(destPath, record, format === "tar.gz" ? { compress: "gzip" } : undefined); +} + +/** + * Extract every file member to `destDir`, creating parent directories as + * needed. Entries that would escape `destDir` (via `..` or an absolute path) + * are rejected. Returns the number of files written. + */ +export async function extractArchive(source: ArchiveSource, destDir: string): Promise { + const extractRoot = path.resolve(destDir); + const entries = await readArchiveEntries(source); + let count = 0; + for (const [name, content] of entries) { + if (name.endsWith("/")) continue; + const outputPath = path.resolve(extractRoot, name); + if (!outputPath.startsWith(extractRoot + path.sep)) { + throw new ToolError(`Archive entry escapes extraction dir: ${name}`); + } + await Bun.write(outputPath, content); + count++; + } + return count; +} + +function writeUInt16LE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUInt32LE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >>> 8) & 0xff; + buf[offset + 2] = (value >>> 16) & 0xff; + buf[offset + 3] = (value >>> 24) & 0xff; +} + +/** + * Frame a `path → bytes` map into a ZIP archive in memory. Each member is raw + * DEFLATE unless that would not shrink it, in which case it is stored. ZIP64 is + * not emitted; archives beyond the 32-bit limits throw rather than corrupt. + */ +export function zip(entries: Unzipped): Uint8Array { + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + let offset = 0; + let count = 0; + + for (const name in entries) { + const data = entries[name]!; + const nameBytes = ENCODER.encode(name); + const crc = zlib.crc32(data) >>> 0; + const uncompressedSize = data.byteLength; + const deflated = zlib.deflateRawSync(data); + const stored = deflated.byteLength >= uncompressedSize; + const method = stored ? ZIP_STORED_COMPRESSION : ZIP_DEFLATE_COMPRESSION; + const payload = stored ? data : deflated; + + // Without ZIP64 the name length is a u16 and offsets/sizes are u32 (with + // 0xffff/0xffffffff reserved as ZIP64 sentinels); reject anything that + // would silently wrap a header field instead of producing a valid archive. + if ( + count + 1 >= ZIP_UINT16_MAX || + nameBytes.byteLength > ZIP_UINT16_MAX || + uncompressedSize >= ZIP_UINT32_MAX || + offset + 30 + nameBytes.byteLength + payload.byteLength >= ZIP_UINT32_MAX + ) { + throw new ToolError("ZIP archive is too large to write (ZIP64 is not supported)"); + } + + const header = new Uint8Array(30 + nameBytes.byteLength); + writeUInt32LE(header, 0, ZIP_LOCAL_FILE_HEADER_SIGNATURE); + writeUInt16LE(header, 4, 20); + writeUInt16LE(header, 6, ZIP_UTF8_FLAG); + writeUInt16LE(header, 8, method); + // Fixed 1980-01-01 timestamp keeps the output deterministic. + writeUInt16LE(header, 12, 0x21); + writeUInt32LE(header, 14, crc); + writeUInt32LE(header, 18, payload.byteLength); + writeUInt32LE(header, 22, uncompressedSize); + writeUInt16LE(header, 26, nameBytes.byteLength); + header.set(nameBytes, 30); + localParts.push(header, payload); + + const record = new Uint8Array(46 + nameBytes.byteLength); + writeUInt32LE(record, 0, ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE); + writeUInt16LE(record, 4, 20); + writeUInt16LE(record, 6, 20); + writeUInt16LE(record, 8, ZIP_UTF8_FLAG); + writeUInt16LE(record, 10, method); + writeUInt16LE(record, 14, 0x21); + writeUInt32LE(record, 16, crc); + writeUInt32LE(record, 20, payload.byteLength); + writeUInt32LE(record, 24, uncompressedSize); + writeUInt16LE(record, 28, nameBytes.byteLength); + writeUInt32LE(record, 42, offset); + record.set(nameBytes, 46); + centralParts.push(record); + + offset += header.byteLength + payload.byteLength; + count++; + } + + const centralSize = centralParts.reduce((sum, part) => sum + part.byteLength, 0); + if (centralSize >= ZIP_UINT32_MAX || offset + centralSize + ZIP_EOCD_MIN_LENGTH >= ZIP_UINT32_MAX) { + throw new ToolError("ZIP archive is too large to write (ZIP64 is not supported)"); + } + const eocd = new Uint8Array(ZIP_EOCD_MIN_LENGTH); + writeUInt32LE(eocd, 0, ZIP_EOCD_SIGNATURE); + writeUInt16LE(eocd, 8, count); + writeUInt16LE(eocd, 10, count); + writeUInt32LE(eocd, 12, centralSize); + writeUInt32LE(eocd, 16, offset); + + const out = new Uint8Array(offset + centralSize + ZIP_EOCD_MIN_LENGTH); + let pos = 0; + for (const part of localParts) { + out.set(part, pos); + pos += part.byteLength; + } + for (const part of centralParts) { + out.set(part, pos); + pos += part.byteLength; + } + out.set(eocd, pos); + return out; +} + +function readZip64CentralDirectoryInfoSync(bytes: Uint8Array, eocdOffset: number): ZipCentralDirectoryInfo | undefined { + const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_LENGTH; + if (locatorOffset < 0) return undefined; + + const locator = readMemoryRange(bytes, locatorOffset, locatorOffset + ZIP64_EOCD_LOCATOR_LENGTH); + if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return undefined; + if (readUInt32LE(locator, 4) !== 0 || readUInt32LE(locator, 16) > 1) { + throw new ToolError("Multi-disk ZIP archives are not supported"); + } + + const zip64EocdOffset = readUInt64LEAsNumber(locator, 8); + const record = readMemoryRange(bytes, zip64EocdOffset, zip64EocdOffset + 56); + if (readUInt32LE(record, 0) !== ZIP64_EOCD_SIGNATURE) { + throw new ToolError("Invalid ZIP archive: missing ZIP64 end of central directory"); + } + if (readUInt32LE(record, 16) !== 0 || readUInt32LE(record, 20) !== 0) { + throw new ToolError("Multi-disk ZIP archives are not supported"); + } + + return { + entries: readUInt64LEAsNumber(record, 32), + size: readUInt64LEAsNumber(record, 40), + offset: readUInt64LEAsNumber(record, 48), + }; +} + +function readCentralDirectoryInfoSync(bytes: Uint8Array): ZipCentralDirectoryInfo { + const fileSize = bytes.byteLength; + if (fileSize < ZIP_EOCD_MIN_LENGTH) { + throw new ToolError("Invalid ZIP archive: missing end of central directory"); + } + + const tailLength = Math.min(fileSize, ZIP_EOCD_MIN_LENGTH + ZIP_EOCD_MAX_COMMENT_LENGTH); + const tailStart = fileSize - tailLength; + const tail = readMemoryRange(bytes, tailStart, fileSize); + const eocdIndex = findEndOfCentralDirectory(tail); + + if (readUInt16LE(tail, eocdIndex + 4) !== 0 || readUInt16LE(tail, eocdIndex + 6) !== 0) { + throw new ToolError("Multi-disk ZIP archives are not supported"); + } + + let entries = readUInt16LE(tail, eocdIndex + 10); + let size = readUInt32LE(tail, eocdIndex + 12); + let offset = readUInt32LE(tail, eocdIndex + 16); + const needsZip64 = entries === ZIP_UINT16_MAX || size === ZIP_UINT32_MAX || offset === ZIP_UINT32_MAX; + const zip64Info = readZip64CentralDirectoryInfoSync(bytes, tailStart + eocdIndex); + if (zip64Info) { + ({ entries, size, offset } = zip64Info); + } else if (needsZip64) { + throw new ToolError("Invalid ZIP archive: missing ZIP64 central directory metadata"); + } + + if (offset + size > fileSize) { + throw new ToolError("Invalid ZIP archive: central directory exceeds file size"); + } + + return { entries, offset, size }; +} + +function extractZipMember(bytes: Uint8Array, storage: ZipStorage, uncompressedSize: number): Uint8Array { + if ((storage.flags & ZIP_ENCRYPTED_FLAG) !== 0) { + throw new ToolError("Encrypted ZIP entries are not supported"); + } + + const headerStart = storage.localHeaderOffset; + const localHeader = readMemoryRange(bytes, headerStart, headerStart + 30); + if (readUInt32LE(localHeader, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) { + throw new ToolError("Invalid ZIP archive: malformed local file header"); + } + + const fileNameLength = readUInt16LE(localHeader, 26); + const extraLength = readUInt16LE(localHeader, 28); + const dataStart = headerStart + 30 + fileNameLength + extraLength; + const compressed = readMemoryRange(bytes, dataStart, dataStart + storage.compressedSize); + return decodeZipMember(compressed, storage.compression, uncompressedSize); } diff --git a/packages/coding-agent/src/web/scrapers/firefox-addons.ts b/packages/coding-agent/src/web/scrapers/firefox-addons.ts index a36976b1b..c7fe84e2a 100644 --- a/packages/coding-agent/src/web/scrapers/firefox-addons.ts +++ b/packages/coding-agent/src/web/scrapers/firefox-addons.ts @@ -173,7 +173,7 @@ export const handleFirefoxAddons: SpecialHandler = async ( md += `- ${permission}\n`; } if (permissions.length > preview.length) { - md += `\n*...and ${permissions.length - preview.length} more*\n`; + md += `\n[…${permissions.length - preview.length} permissions elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/github.ts b/packages/coding-agent/src/web/scrapers/github.ts index ff15e22d1..1e1926298 100644 --- a/packages/coding-agent/src/web/scrapers/github.ts +++ b/packages/coding-agent/src/web/scrapers/github.ts @@ -468,7 +468,7 @@ async function renderGitHubRepo( md += `${prefix}${item.path}\n`; } if (tree.length > 100) { - md += `... and ${tree.length - 100} more files\n`; + md += `[…${tree.length - 100} files elided…]\n`; } md += "```\n\n"; } diff --git a/packages/coding-agent/src/web/scrapers/go-pkg.ts b/packages/coding-agent/src/web/scrapers/go-pkg.ts index feb9145ce..115fec145 100644 --- a/packages/coding-agent/src/web/scrapers/go-pkg.ts +++ b/packages/coding-agent/src/web/scrapers/go-pkg.ts @@ -211,7 +211,7 @@ export const handleGoPkg: SpecialHandler = async ( sections.push(exported.slice(0, 50).join("\n")); if (exported.length > 50) { notes.push(`showing 50 of ${exported.length} exports`); - sections.push(`\n... and ${exported.length - 50} more`); + sections.push(`\n[…${exported.length - 50} exports elided…]`); } sections.push(""); } @@ -240,7 +240,7 @@ export const handleGoPkg: SpecialHandler = async ( sections.push(imports.slice(0, 20).join("\n")); if (imports.length > 20) { notes.push(`showing 20 of ${imports.length} imports`); - sections.push(`\n... and ${imports.length - 20} more`); + sections.push(`\n[…${imports.length - 20} imports elided…]`); } sections.push(""); } diff --git a/packages/coding-agent/src/web/scrapers/metacpan.ts b/packages/coding-agent/src/web/scrapers/metacpan.ts index dc67159a1..61415e9dd 100644 --- a/packages/coding-agent/src/web/scrapers/metacpan.ts +++ b/packages/coding-agent/src/web/scrapers/metacpan.ts @@ -162,7 +162,7 @@ function formatModuleMarkdown(module: ModuleResponse, release: ReleaseResponse | md += "\n"; } if (runtimeDeps.length > 20) { - md += `\n*...and ${runtimeDeps.length - 20} more*\n`; + md += `\n[…${runtimeDeps.length - 20} dependencies elided…]\n`; } } } @@ -212,7 +212,7 @@ function formatReleaseMarkdown(release: ReleaseResponse): string { md += "\n"; } if (runtimeDeps.length > 20) { - md += `\n*...and ${runtimeDeps.length - 20} more*\n`; + md += `\n[…${runtimeDeps.length - 20} dependencies elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/nvd.ts b/packages/coding-agent/src/web/scrapers/nvd.ts index 20392d877..226ddbc3a 100644 --- a/packages/coding-agent/src/web/scrapers/nvd.ts +++ b/packages/coding-agent/src/web/scrapers/nvd.ts @@ -182,7 +182,7 @@ export const handleNvd: SpecialHandler = async ( md += `- \`${cpe}\`\n`; } if (cpes.length > 20) { - md += `\n*...and ${cpes.length - 20} more*\n`; + md += `\n[…${cpes.length - 20} CPEs elided…]\n`; } md += "\n"; } @@ -195,7 +195,7 @@ export const handleNvd: SpecialHandler = async ( md += `- ${ref.url}${tags}\n`; } if (vuln.references.length > 15) { - md += `\n*...and ${vuln.references.length - 15} more references*\n`; + md += `\n[…${vuln.references.length - 15} references elided…]\n`; } } diff --git a/packages/coding-agent/src/web/scrapers/ollama.ts b/packages/coding-agent/src/web/scrapers/ollama.ts index 91ee2613a..35f0573d0 100644 --- a/packages/coding-agent/src/web/scrapers/ollama.ts +++ b/packages/coding-agent/src/web/scrapers/ollama.ts @@ -139,7 +139,7 @@ function formatTagList(tags: string[], maxItems: number): string { const limited = tags.slice(0, maxItems); const formatted = limited.map(tag => `\`${tag}\``).join(", "); if (tags.length > maxItems) { - return `${formatted} (and ${tags.length - maxItems} more)`; + return `${formatted} […${tags.length - maxItems} tags elided…]`; } return formatted; } diff --git a/packages/coding-agent/src/web/scrapers/opencorporates.ts b/packages/coding-agent/src/web/scrapers/opencorporates.ts index 74e2b7db1..3cff50682 100644 --- a/packages/coding-agent/src/web/scrapers/opencorporates.ts +++ b/packages/coding-agent/src/web/scrapers/opencorporates.ts @@ -216,7 +216,7 @@ export const handleOpenCorporates: SpecialHandler = async ( md += "\n"; } if (inactiveOfficers.length > 10) { - md += `\n*...and ${inactiveOfficers.length - 10} more former officers*\n`; + md += `\n[…${inactiveOfficers.length - 10} former officers elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/pub-dev.ts b/packages/coding-agent/src/web/scrapers/pub-dev.ts index b7ad5b3a2..a90ae1e9c 100644 --- a/packages/coding-agent/src/web/scrapers/pub-dev.ts +++ b/packages/coding-agent/src/web/scrapers/pub-dev.ts @@ -109,7 +109,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number, md += "\n"; } if (deps.length > 20) { - md += `\n*...and ${deps.length - 20} more*\n`; + md += `\n[…${deps.length - 20} dependencies elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/repology.ts b/packages/coding-agent/src/web/scrapers/repology.ts index aabde8d78..f7f09fbf6 100644 --- a/packages/coding-agent/src/web/scrapers/repology.ts +++ b/packages/coding-agent/src/web/scrapers/repology.ts @@ -239,7 +239,7 @@ export const handleRepology: SpecialHandler = async ( } if (packages.length > 15) { - md += `\n*...and ${packages.length - 15} more repositories*\n`; + md += `\n[…${packages.length - 15} repositories elided…]\n`; } md += `\n---\n\n[View on Repology](${url})\n`; diff --git a/packages/coding-agent/src/web/scrapers/sourcegraph.ts b/packages/coding-agent/src/web/scrapers/sourcegraph.ts index cbf59cdb4..5c13e7888 100644 --- a/packages/coding-agent/src/web/scrapers/sourcegraph.ts +++ b/packages/coding-agent/src/web/scrapers/sourcegraph.ts @@ -294,7 +294,7 @@ async function renderSearch( } if (results.length > maxResults) { - md += `... and ${results.length - maxResults} more results\n`; + md += `[…${results.length - maxResults} results elided…]\n`; } return { content: md, ok: true }; diff --git a/packages/coding-agent/src/web/scrapers/terraform.ts b/packages/coding-agent/src/web/scrapers/terraform.ts index fb7f05204..20d72dd33 100644 --- a/packages/coding-agent/src/web/scrapers/terraform.ts +++ b/packages/coding-agent/src/web/scrapers/terraform.ts @@ -150,7 +150,7 @@ async function handleModuleUrl( md += `| ${input.name} | \`${type}\` | ${required} | ${desc} |\n`; } if (inputs.length > 30) { - md += `\n*... and ${inputs.length - 30} more inputs*\n`; + md += `\n[…${inputs.length - 30} inputs elided…]\n`; } md += "\n"; } @@ -165,7 +165,7 @@ async function handleModuleUrl( md += "\n"; } if (outputs.length > 20) { - md += `\n*... and ${outputs.length - 20} more outputs*\n`; + md += `\n[…${outputs.length - 20} outputs elided…]\n`; } md += "\n"; } @@ -180,7 +180,7 @@ async function handleModuleUrl( md += "\n"; } if (deps.length > 15) { - md += `\n*... and ${deps.length - 15} more dependencies*\n`; + md += `\n[…${deps.length - 15} dependencies elided…]\n`; } md += "\n"; } @@ -193,7 +193,7 @@ async function handleModuleUrl( md += `- \`${res.type}\` (${res.name})\n`; } if (resources.length > 20) { - md += `\n*... and ${resources.length - 20} more resources*\n`; + md += `\n[…${resources.length - 20} resources elided…]\n`; } md += "\n"; } @@ -205,7 +205,7 @@ async function handleModuleUrl( md += `- **${sub.name}**: \`${sub.path}\`\n`; } if (mod.submodules.length > 10) { - md += `\n*... and ${mod.submodules.length - 10} more submodules*\n`; + md += `\n[…${mod.submodules.length - 10} submodules elided…]\n`; } } @@ -267,7 +267,7 @@ async function handleProviderUrl( md += `- [${doc.title}](https://registry.terraform.io/providers/${namespace}/${type}/latest/docs/${doc.category}/${doc.slug})\n`; } if (docs.length > 15) { - md += `\n*... and ${docs.length - 15} more*\n`; + md += `\n[…${docs.length - 15} documents elided…]\n`; } md += "\n"; } diff --git a/packages/coding-agent/src/web/scrapers/types.ts b/packages/coding-agent/src/web/scrapers/types.ts index ae985a74a..cd84db68e 100644 --- a/packages/coding-agent/src/web/scrapers/types.ts +++ b/packages/coding-agent/src/web/scrapers/types.ts @@ -243,58 +243,15 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom /** Module-level Turndown instance — built lazily on first use. */ let turndownPromise: Promise | undefined; -type TurndownListParent = { - nodeName: string; - getAttribute(name: string): string | null; - children: ArrayLike; -}; - function getTurndown(): Promise { turndownPromise ||= initTurndown(); return turndownPromise; } async function initTurndown(): Promise { - const [{ default: TurndownService }, { gfm }] = await Promise.all([ - import("turndown"), - import("turndown-plugin-gfm"), - ]); - const turndown = new TurndownService({ - headingStyle: "atx", - codeBlockStyle: "fenced", - bulletListMarker: "-", - }); - turndown.use(gfm); - turndown.addRule("strikethrough", { - filter: ["del", "s", "strike"], - replacement(content) { - return `~~${content}~~`; - }, - }); - turndown.addRule("heading", { - filter: ["h1", "h2", "h3", "h4", "h5", "h6"], - replacement(content, node) { - const level = Number(node.nodeName.charAt(1)); - const prefix = "#".repeat(level); - const cleaned = content.replace(/\\([.])/g, "$1").trim(); - return `\n\n${prefix} ${cleaned}\n\n`; - }, - }); - turndown.addRule("listItem", { - filter: "li", - replacement(content, node, options) { - content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n "); - const parent = node.parentNode as unknown as TurndownListParent | null; - let prefix = `${options.bulletListMarker} `; - if (parent?.nodeName === "OL") { - const start = parent.getAttribute("start"); - const index = Array.prototype.indexOf.call(parent.children, node); - prefix = `${(start ? Number(start) : 1) + index}. `; - } - return prefix + content + (node.nextSibling ? "\n" : ""); - }, - }); - return turndown; + // Lazy import keeps turndown/turndown-plugin-gfm off the startup graph. + const { createTurndown } = await import("../../utils/turndown"); + return createTurndown(); } /** diff --git a/packages/coding-agent/src/web/scrapers/wikidata.ts b/packages/coding-agent/src/web/scrapers/wikidata.ts index 141c676ce..43331177d 100644 --- a/packages/coding-agent/src/web/scrapers/wikidata.ts +++ b/packages/coding-agent/src/web/scrapers/wikidata.ts @@ -169,7 +169,7 @@ export const handleWikidata: SpecialHandler = async ( if (values.length > 0) { // Limit values shown per property const displayValues = values.slice(0, 10); - const overflow = values.length > 10 ? ` (+${values.length - 10} more)` : ""; + const overflow = values.length > 10 ? ` […${values.length - 10} values elided…]` : ""; processedProperties.push(`- **${propLabel}:** ${displayValues.join(", ")}${overflow}`); } } @@ -187,7 +187,7 @@ export const handleWikidata: SpecialHandler = async ( const maxProps = 50; md += processedProperties.slice(0, maxProps).join("\n"); if (processedProperties.length > maxProps) { - md += `\n\n*...and ${processedProperties.length - maxProps} more properties*`; + md += `\n\n[…${processedProperties.length - maxProps} properties elided…]`; } md += "\n"; } diff --git a/packages/coding-agent/src/workspace-tree.ts b/packages/coding-agent/src/workspace-tree.ts index b128a44e0..914926400 100644 --- a/packages/coding-agent/src/workspace-tree.ts +++ b/packages/coding-agent/src/workspace-tree.ts @@ -298,7 +298,7 @@ function applyLineCap( const removed = new Set(removable.map(item => item.index)); const kept = lines.filter((_, index) => !removed.has(index)); kept.push({ - label: `… (${removable.length} lines elided beyond depth/cap)`, + label: `[…${removable.length}ln elided…]`, depth: 0, isRoot: false, }); diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index edd6c079e..1f131d878 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -29,6 +29,13 @@ import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "@oh-my-pi/pi-coding-agent/stt/models"; +import { + DEFAULT_TTS_LOCAL_MODEL_KEY, + DEFAULT_TTS_VOICE, + TTS_LOCAL_MODELS, + TTS_LOCAL_VOICE_OPTIONS, +} from "@oh-my-pi/pi-coding-agent/tts/models"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import type { z } from "zod/v4"; @@ -876,7 +883,50 @@ describe("ACP agent", () => { await Bun.sleep(0); }); - it("accepts only ACP underscore-prefixed extension methods", async () => { + it("lists static speech models for ACP mobile voice settings", async () => { + const harness = await createHarness(); + const voices = TTS_LOCAL_VOICE_OPTIONS.map(({ value, label }) => ({ value, label })); + + const result = await harness.agent.extMethod("speech.models.list", {}); + + expect(result).toEqual({ + settings: { + speechToTextModel: "stt.modelName", + textToSpeechModel: "tts.localModel", + textToSpeechVoice: "tts.localVoice", + speechVoice: "speech.voice", + }, + defaults: { + speechToTextModel: DEFAULT_STT_MODEL_KEY, + textToSpeechModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + voice: DEFAULT_TTS_VOICE, + }, + speechToText: { + setting: "stt.modelName", + defaultValue: DEFAULT_STT_MODEL_KEY, + models: STT_MODEL_OPTIONS.map(({ value, label, description }) => ({ value, label, description })), + }, + textToSpeech: { + modelSetting: "tts.localModel", + voiceSetting: "tts.localVoice", + speechVoiceSetting: "speech.voice", + defaultModel: DEFAULT_TTS_LOCAL_MODEL_KEY, + defaultVoice: DEFAULT_TTS_VOICE, + models: TTS_LOCAL_MODELS.map(({ key, label, description, voices: modelVoices }) => ({ + value: key, + label, + description, + voices: modelVoices.map(({ id, label: voiceLabel }) => ({ value: id, label: voiceLabel })), + })), + voices, + }, + }); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("accepts OMP extension methods and rejects unknown unprefixed methods", async () => { const harness = await createHarness(); const result = await harness.agent.extMethod("_omp/sessions/listAll", { limit: 2 }); diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 46aa4b9a3..9ad225bec 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -248,6 +248,8 @@ describe("ACP lazy startup", () => { type ObservedAdvisorSettings = { enabled: boolean; subagents: boolean; + syncBacklog: "off" | "1" | "3" | "5"; + immuneTurns: number; }; const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise => { @@ -257,6 +259,8 @@ describe("ACP lazy startup", () => { const settings = Settings.isolated({ "advisor.enabled": true, "advisor.subagents": true, + "advisor.syncBacklog": "5", + "advisor.immuneTurns": 3, }); let observed: ObservedAdvisorSettings | undefined; const stopMessage = "stop test protocol mode"; @@ -284,6 +288,8 @@ describe("ACP lazy startup", () => { observed = { enabled: settings.get("advisor.enabled"), subagents: settings.get("advisor.subagents"), + syncBacklog: settings.get("advisor.syncBacklog"), + immuneTurns: settings.get("advisor.immuneTurns"), }; throw new Error(stopMessage); }, @@ -291,6 +297,8 @@ describe("ACP lazy startup", () => { observed = { enabled: settings.get("advisor.enabled"), subagents: settings.get("advisor.subagents"), + syncBacklog: settings.get("advisor.syncBacklog"), + immuneTurns: settings.get("advisor.immuneTurns"), }; throw new Error(stopMessage); }, @@ -314,6 +322,8 @@ describe("ACP lazy startup", () => { await expect(runProtocolStartup(mode)).resolves.toEqual({ enabled: false, subagents: false, + syncBacklog: "off", + immuneTurns: 1, }); } }); diff --git a/packages/coding-agent/test/advisor/advisor-visibility.test.ts b/packages/coding-agent/test/advisor/advisor-visibility.test.ts new file mode 100644 index 000000000..18cb07b4a --- /dev/null +++ b/packages/coding-agent/test/advisor/advisor-visibility.test.ts @@ -0,0 +1,58 @@ +/** + * Contracts: an advisor-kind registry ref is observability-only — present for the + * Agent Hub, hidden from every agent-facing surface, and never messageable. + * + * - `AgentRegistry.listVisibleTo` (irc roster / broadcast targets) excludes advisors. + * - `IrcBus.send` to an advisor ref fails as non-messageable, without reviving it. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; + +describe("advisor registry visibility", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + IrcBus.resetGlobalForTests(); + }); + + afterEach(() => { + IrcBus.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + }); + + it("excludes advisor refs from listVisibleTo", () => { + const registry = AgentRegistry.global(); + registry.register({ id: "Main", displayName: "Main", kind: "main", session: null, status: "running" }); + registry.register({ id: "Worker", displayName: "Worker", kind: "sub", session: null, status: "idle" }); + registry.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + status: "idle", + }); + + const visible = registry.listVisibleTo("Main").map(ref => ref.id); + expect(visible).toContain("Worker"); + expect(visible).not.toContain("Main/advisor"); + }); + + it("refuses to message an advisor ref", async () => { + const registry = AgentRegistry.global(); + registry.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + sessionFile: "/tmp/x/__advisor.jsonl", + status: "parked", + }); + const bus = new IrcBus(registry); + + const receipt = await bus.send({ from: "Main", to: "Main/advisor", body: "hi" }); + expect(receipt.outcome).toBe("failed"); + expect(receipt.error).toContain("advisor"); + // It must still be parked — a refused send never revives an advisor transcript. + expect(registry.get("Main/advisor")?.status).toBe("parked"); + }); +}); diff --git a/packages/coding-agent/test/advisor/transcript-recorder.test.ts b/packages/coding-agent/test/advisor/transcript-recorder.test.ts new file mode 100644 index 000000000..23849266b --- /dev/null +++ b/packages/coding-agent/test/advisor/transcript-recorder.test.ts @@ -0,0 +1,163 @@ +/** + * Contracts: AdvisorTranscriptRecorder persists the advisor agent's turns to a + * subagent-style JSONL (`/__advisor.jsonl`) so the advisor model's usage + * is attributed in stats and its transcript shows in the Agent Hub. + * + * - Assistant turns land as `{type:"message", message:{role:"assistant", usage}}` + * entries — exactly the shape the stats parser reads for usage. + * - User deltas are persisted but flagged `synthetic`/agent-attributed so stats' + * user-message metrics skip them. + * - Non-conversational message kinds are not persisted. + * - The target follows the session file: a switch routes later turns to the new + * session's `__advisor.jsonl`, leaving the prior file intact. + */ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { + ADVISOR_TRANSCRIPT_FILENAME, + AdvisorTranscriptRecorder, +} from "@oh-my-pi/pi-coding-agent/advisor/transcript-recorder"; + +interface AdvisorEntry { + type?: string; + id?: unknown; + message?: { + role?: string; + model?: string; + usage?: { input?: number }; + synthetic?: boolean; + attribution?: string; + }; +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "advisor-recorder-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Parse the message entries (skipping the session header) from an advisor JSONL. */ +async function readMessageEntries(file: string): Promise { + const text = await Bun.file(file).text(); + // JSON.parse returns `any`; assigning to the typed array narrows reads below. + const entries: AdvisorEntry[] = text + .trim() + .split("\n") + .map(line => JSON.parse(line)); + return entries.filter(entry => entry.type === "message"); +} + +function assistantMessage(text: string, inputTokens: number): AgentMessage { + const message = { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages", + provider: "anthropic", + model: "test-advisor-model", + usage: { + input: inputTokens, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: inputTokens + 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: 1, + }; + return message as unknown as AgentMessage; +} + +function userMessage(text: string): AgentMessage { + const message = { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; + return message as unknown as AgentMessage; +} + +function developerMessage(text: string): AgentMessage { + const message = { role: "developer" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; + return message as unknown as AgentMessage; +} + +describe("AdvisorTranscriptRecorder", () => { + it("persists assistant turns with usage to /__advisor.jsonl", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(assistantMessage("reviewing", 42)); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages).toHaveLength(1); + expect(messages[0].message?.role).toBe("assistant"); + expect(messages[0].message?.model).toBe("test-advisor-model"); + expect(messages[0].message?.usage?.input).toBe(42); + // Stats keys on a non-empty entry id; SessionManager must assign one. + expect(typeof messages[0].id).toBe("string"); + expect(String(messages[0].id).length).toBeGreaterThan(0); + }); + }); + + it("marks advisor user deltas synthetic and agent-attributed", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(userMessage("### Session update")); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages).toHaveLength(1); + expect(messages[0].message?.role).toBe("user"); + expect(messages[0].message?.synthetic).toBe(true); + expect(messages[0].message?.attribution).toBe("agent"); + }); + }); + + it("skips non-conversational message kinds", async () => { + await withTempDir(async dir => { + const sessionFile = path.join(dir, "sess.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(developerMessage("noise")); + recorder.record(assistantMessage("kept", 1)); + await recorder.close(); + + const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME)); + expect(messages.map(m => m.message?.role)).toEqual(["assistant"]); + }); + }); + + it("routes later turns to the new session file after a switch", async () => { + await withTempDir(async dir => { + let sessionFile = path.join(dir, "first.jsonl"); + const recorder = new AdvisorTranscriptRecorder( + () => sessionFile, + () => dir, + ); + recorder.record(assistantMessage("before switch", 1)); + sessionFile = path.join(dir, "second.jsonl"); + recorder.record(assistantMessage("after switch", 2)); + await recorder.close(); + + const first = await readMessageEntries(path.join(dir, "first", ADVISOR_TRANSCRIPT_FILENAME)); + const second = await readMessageEntries(path.join(dir, "second", ADVISOR_TRANSCRIPT_FILENAME)); + expect(first).toHaveLength(1); + expect(first[0].message?.usage?.input).toBe(1); + expect(second).toHaveLength(1); + expect(second[0].message?.usage?.input).toBe(2); + }); + }); +}); diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts new file mode 100644 index 000000000..5a52a4d38 --- /dev/null +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -0,0 +1,171 @@ +/** + * Regression: the fullscreen transcript viewer must align the header, body, and + * footer on a single shared gutter. The transcript components carry their own + * 1-column left pad, so the viewer must NOT add a second outer gutter to body + * rows — doing so shifted the content one column right of the "Agent Hub" title + * (the reported "first char off / title shift"). Scrolling must also move the + * visible window. + */ +import { beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; + +const TS = new Date().toISOString(); + +function buildJsonl(): string { + const usage = { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const lines = [ + JSON.stringify({ type: "session", version: CURRENT_SESSION_VERSION, id: "adv", timestamp: TS, cwd: "/tmp" }), + ]; + lines.push( + JSON.stringify({ + type: "message", + id: "u0", + parentId: null, + timestamp: TS, + message: { role: "user", synthetic: true, attribution: "agent", content: "PROMPTMARKER", timestamp: 0 }, + }), + ); + for (let i = 0; i < 40; i++) { + lines.push( + JSON.stringify({ + type: "message", + id: `a${i}`, + parentId: null, + timestamp: TS, + message: { + role: "assistant", + content: [{ type: "text", text: `Reviewing step ${i}.` }], + api: "anthropic-messages", + provider: "anthropic", + model: "gpt-5.5", + usage, + stopReason: "stop", + timestamp: i, + }, + }), + ); + } + return `${lines.join("\n")}\n`; +} + +function makeViewer(file: string) { + const agents = new AgentRegistry(); + agents.register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + parentId: "Main", + session: null, + sessionFile: file, + status: "parked", + }); + return new AgentTranscriptViewer({ + agentId: "Main/advisor", + registry: agents, + ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + cwd: "/tmp", + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, + }); +} + +/** Leading-space count of a stripped line (its content gutter). */ +function gutter(line: string): number { + const stripped = Bun.stripANSI(line); + return stripped.length - stripped.trimStart().length; +} + +function withViewer(fn: (viewer: AgentTranscriptViewer) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildJsonl()); + try { + fn(makeViewer(file)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe("AgentTranscriptViewer", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + initTheme(); + }); + + it("aligns the title and body content on the same gutter", () => { + withViewer(viewer => { + viewer.render(80); // populate the scroll view before navigating + viewer.handleInput("g"); // scroll to top so the first message is visible + const lines = viewer.render(80).map(l => Bun.stripANSI(l)); + const titleLine = lines.find(l => l.includes("Agent Hub")); + const bodyLine = lines.find(l => l.includes("PROMPTMARKER")); + expect(titleLine).toBeDefined(); + expect(bodyLine).toBeDefined(); + // The body must not sit one column right of the title. + expect(gutter(bodyLine!)).toBe(gutter(titleLine!)); + }); + }); + + it("scrolls the visible window with j/k and g/G", () => { + withViewer(viewer => { + const atBottom = viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + viewer.handleInput("g"); + const atTop = viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + expect(atTop).not.toEqual(atBottom); + expect(atTop).toContain("PROMPTMARKER"); + expect(atBottom).not.toContain("PROMPTMARKER"); + }); + }); + + it("clears stale content when the transcript file is deleted while open", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildJsonl()); + const viewer = makeViewer(file); + const body = () => + viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + try { + viewer.render(80); + viewer.handleInput("g"); + expect(body()).toContain("PROMPTMARKER"); + + fs.rmSync(file); + // Poll until the viewer's own poll timer re-stats and clears (deadline-bounded). + const deadline = Date.now() + 5000; + while (body().includes("PROMPTMARKER") && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(body()).not.toContain("PROMPTMARKER"); + } finally { + viewer.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 25c425ea6..743bc9c7c 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -206,6 +206,54 @@ describe("AgentSession auto-compaction queue resume", () => { expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true); }); + it("has isCompacting true when the auto_compaction_start event fires", async () => { + // Defect 1: the compaction AbortController (which backs isCompacting) must be + // installed before auto_compaction_start is emitted. If it is installed after, + // a message typed the instant the loader appears is read while + // isCompacting === false and mis-routed into the core steering queue (which a + // later handoff reset would wipe) instead of the safe UI compaction queue. + let capturedIsCompacting: boolean | undefined; + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_start") { + capturedIsCompacting = session.isCompacting; + } else if (event.type === "auto_compaction_end") { + onCompactionDone(); + } + }); + + // Defensive: mirror the resume-drain stub so any queued continuation settles + // instead of spinning the drain (see the threshold test above). + vi.spyOn(session.agent, "continue").mockImplementation(async () => { + session.agent.clearAllQueues(); + }); + + const assistantMsg = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190000, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + + expect(capturedIsCompacting).toBe(true); + }); + it("forwards todo reminder lifecycle signals to extensions", async () => { const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 76c947997..4f847888a 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -153,6 +153,101 @@ describe("AgentSession handoff", () => { expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); + it("preserves queued steering and follow-up messages across the handoff reset", async () => { + // Defect 2: handoff() calls agent.reset(), which clears the core steering/follow-up + // queues. Steers/follow-ups already queued (the mis-routed first compaction message, + // or RPC/SDK steer()/followUp() issued during the handoff) must survive into the new + // session instead of being silently dropped. + vi.spyOn(compactionModule, "generateHandoff").mockResolvedValue("## Goal\nContinue"); + + const textOf = (message: AgentMessage): string => { + if (!("content" in message)) return ""; + const content = message.content; + if (typeof content === "string") return content; + const textBlock = content.find(block => block.type === "text"); + return textBlock?.type === "text" ? textBlock.text : ""; + }; + + const userMsg: AgentMessage = { + role: "user", + content: [{ type: "text", text: "keep-steer" }], + attribution: "user", + timestamp: Date.now(), + }; + // A hidden, user-attributed companion (e.g. an ultrathink notice). It is + // display:false, so isUserQueuedMessage(...) is false for it: preservation must + // keep it adjacent to its prompt rather than filter it out or reorder it. + const companionMsg: AgentMessage = { + role: "custom", + customType: "ultrathink-notice", + content: [{ type: "text", text: "companion" }], + attribution: "user", + display: false, + timestamp: Date.now(), + }; + const followUpMsg: AgentMessage = { + role: "user", + content: [{ type: "text", text: "keep-followup" }], + attribution: "user", + timestamp: Date.now(), + }; + session.agent.steer(userMsg); + session.agent.steer(companionMsg); + session.agent.followUp(followUpMsg); + expect(session.agent.hasQueuedMessages()).toBe(true); + + await session.handoff(); + + expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["keep-steer", "companion"]); + expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["keep-followup"]); + }); + + it("preserves steering and follow-up messages enqueued while the handoff is in flight", async () => { + // Defect 2 in-flight window: the queue snapshot must be captured immediately before + // agent.reset() (after generateHandoff resolves), NOT at handoff entry. A steer or + // follow-up issued WHILE the handoff document is still generating must survive the + // reset — proving capture happens late rather than at the start of handoff(). + const { promise: handoffDoc, resolve: releaseHandoff } = Promise.withResolvers(); + let generateHandoffCalled = false; + vi.spyOn(compactionModule, "generateHandoff").mockImplementation(async () => { + generateHandoffCalled = true; + return handoffDoc; + }); + + const textOf = (message: AgentMessage): string => { + if (!("content" in message)) return ""; + const content = message.content; + if (typeof content === "string") return content; + const textBlock = content.find(block => block.type === "text"); + return textBlock?.type === "text" ? textBlock.text : ""; + }; + + const handoffPromise = session.handoff(); + // Block until we are genuinely mid-handoff (document generation in flight). + await waitFor(() => generateHandoffCalled); + + // Enqueue AFTER generation started but BEFORE it resolves — the window where the old + // session is still live and agent.reset() has not yet fired. + session.agent.steer({ + role: "user", + content: [{ type: "text", text: "inflight-steer" }], + attribution: "user", + timestamp: Date.now(), + }); + session.agent.followUp({ + role: "user", + content: [{ type: "text", text: "inflight-followup" }], + attribution: "user", + timestamp: Date.now(), + }); + + releaseHandoff("## Goal\nContinue"); + await handoffPromise; + + expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["inflight-steer"]); + expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["inflight-followup"]); + }); + it("obfuscates custom instructions before generating a handoff", async () => { const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const generateHandoffSpy = vi diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 638c7ba3c..593bae4e0 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -4,6 +4,7 @@ import { type Api, type Context, clearCustomApis, + type ImageContent, type Message, type Model, type ModelSpec, @@ -122,6 +123,34 @@ describe("AgentSession message pipeline", () => { session.clearQueue(); }); + it("resolves image attachments from submitted messages, not tool-result images", () => { + const userImage: ImageContent = { type: "image", data: "user-image", mimeType: "image/png" }; + const toolImage: ImageContent = { type: "image", data: "tool-image", mimeType: "image/png" }; + const session = new AgentSession({ + agent: createAgent(), + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: {} as never, + }); + sessions.push(session); + + session.agent.appendMessage({ + role: "user", + content: [{ type: "text", text: "inspect this" }, userImage], + timestamp: Date.now(), + }); + session.agent.appendMessage({ + role: "toolResult", + toolCallId: "eval-1", + toolName: "eval", + content: [{ type: "text", text: "plot output" }, toolImage], + timestamp: Date.now(), + isError: false, + }); + + expect(session.getImageAttachments()).toEqual([{ label: "Image #1", uri: "attachment://1", image: userImage }]); + }); + it("keeps stored steering text raw while pre-LLM conversion wraps it", async () => { const session = new AgentSession({ agent: createAgent(), @@ -433,6 +462,7 @@ describe("AgentSession message pipeline", () => { settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry: {} as never, extensionRunner: { + hasHandlers: () => true, emit: extensionEmit, } as never, }); diff --git a/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts new file mode 100644 index 000000000..2dd557727 --- /dev/null +++ b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts @@ -0,0 +1,249 @@ +/** + * Regression test for issue #1246: "Approved plan file invisible to executor + * after compaction". + * + * After a plan is approved, the executor session delivers the plan reference + * (`plan-mode-reference`) exactly once, then marks `#planReferenceSent = true`. + * When auto-compaction later fires, it replaces the conversation history — + * dropping the delivered reference — but never clears that flag, so + * `#buildPlanReferenceMessage()` short-circuits to `null` forever and the + * executor permanently loses the plan it was working on. + * + * Contract: the auto-continuation turn that runs immediately after compaction + * MUST carry the approved plan reference again (re-read from disk). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import type { TextContent } from "@oh-my-pi/pi-ai"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { ModelRegistry } from "../src/config/model-registry"; +import { Settings } from "../src/config/settings"; +import { resolveLocalUrlToPath } from "../src/internal-urls"; +import { AgentSession } from "../src/session/agent-session"; +import { AuthStorage } from "../src/session/auth-storage"; +import { convertToLlm } from "../src/session/messages"; +import { SessionManager } from "../src/session/session-manager"; + +const CONTINUE_MARKER = "Resume work on the user's most recent intent"; + +type ObservedPromptCall = { messageTexts: string[] }; + +type Harness = { + session: AgentSession; + sessionManager: SessionManager; + observedCalls: ObservedPromptCall[]; + waitForCall: (predicate: (call: ObservedPromptCall) => boolean) => Promise; +}; + +function isTextContentBlock(value: unknown): value is TextContent { + if (!value || typeof value !== "object") return false; + return (value as TextContent).type === "text" && typeof (value as TextContent).text === "string"; +} + +function getMessageText(message: AgentMessage): string { + if (!("content" in message)) return ""; + if (typeof message.content === "string") return message.content; + if (!Array.isArray(message.content)) return ""; + return message.content + .filter(isTextContentBlock) + .map(content => content.text) + .join("\n"); +} + +function createAssistantResponse(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +/** Short-circuit the LLM summary so compaction completes without a network call. */ +function stubCompaction(): void { + vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "compacted", + shortSummary: undefined, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); +} + +/** Emit a high-usage assistant turn to drive threshold (context-full) auto-compaction. */ +function emitHighUsageTurn(session: AgentSession): void { + const assistantMsg = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); +} + +describe("AgentSession approved-plan reference re-injection after compaction (issue #1246)", () => { + let tempDir: TempDir; + const cleanups: Array<() => Promise> = []; + + beforeEach(() => { + tempDir = TempDir.createSync("@pi-agent-session-plan-ref-compaction-"); + cleanups.length = 0; + }); + + afterEach(async () => { + for (const cleanup of cleanups) await cleanup(); + cleanups.length = 0; + tempDir.removeSync(); + vi.restoreAllMocks(); + }); + + async function createHarness(): Promise { + const observedCalls: ObservedPromptCall[] = []; + const waiters: Array<{ + predicate: (call: ObservedPromptCall) => boolean; + resolve: (call: ObservedPromptCall) => void; + }> = []; + + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + + const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); + const settings = Settings.isolated({ + "compaction.enabled": true, + "compaction.autoContinue": true, + "compaction.strategy": "context-full", + "task.eager": "default", + "todo.enabled": false, + "todo.eager": "default", + "todo.reminders": false, + }); + const sessionManager = SessionManager.inMemory(tempDir.path()); + + let session: AgentSession; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + convertToLlm, + getToolChoice: () => session?.nextToolChoice(), + streamFn: (_model, context) => { + observedCalls.push({ messageTexts: context.messages.map(message => getMessageText(message)) }); + const call = observedCalls[observedCalls.length - 1]; + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]; + if (waiter?.predicate(call)) { + waiter.resolve(call); + waiters.splice(i, 1); + } + } + const response = createAssistantResponse("done"); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: response }); + stream.push({ type: "done", reason: "stop", message: response }); + }); + return stream; + }, + }); + + session = new AgentSession({ agent, sessionManager, settings, modelRegistry }); + + const waitForCall = (predicate: (call: ObservedPromptCall) => boolean) => { + const existing = observedCalls.find(predicate); + if (existing) return Promise.resolve(existing); + const { promise, resolve } = Promise.withResolvers(); + waiters.push({ predicate, resolve }); + return promise; + }; + + cleanups.push(async () => { + await session.dispose(); + authStorage.close(); + }); + return { session, sessionManager, observedCalls, waitForCall }; + } + + /** Write a plan file to the session-scoped local root the executor reads from. */ + function writePlanFile(sessionManager: SessionManager, url: string, content: string): void { + const resolved = resolveLocalUrlToPath(url, { + getArtifactsDir: () => sessionManager.getArtifactsDir(), + getSessionId: () => sessionManager.getSessionId(), + }); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, content); + } + + it("re-injects the approved plan reference on the auto-continuation turn", async () => { + const { session, sessionManager, observedCalls, waitForCall } = await createHarness(); + + const planUrl = "local://approved-plan.md"; + const planMarker = "PHASE-6-ARCHITECTURE-PLAN-MARKER"; + writePlanFile(sessionManager, planUrl, `# Approved Plan\n\n${planMarker}\n`); + + // Simulate the executor: plan mode is already exited and the plan reference + // has already been delivered once (the in-history copy that compaction drops). + session.setPlanReferencePath(planUrl); + session.markPlanReferenceSent(); + + stubCompaction(); + + // First executor turn: reference already sent, so it is NOT re-delivered here. + await session.prompt("continue executing the approved plan"); + const firstCall = observedCalls[0]; + expect(firstCall).toBeDefined(); + expect(firstCall.messageTexts.some(text => text.includes(planMarker))).toBe(false); + + // Auto-compaction fires, replacing history (dropping the delivered reference), + // then schedules the auto-continuation turn. + emitHighUsageTurn(session); + const continuation = await waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER))); + + // The post-compaction continuation MUST carry the plan reference again. + expect(continuation.messageTexts.some(text => text.includes(planMarker))).toBe(true); + expect(continuation.messageTexts.some(text => text.includes(``))).toBe(true); + }); + + // Blast-radius guard: clearing the flag on every compaction must NOT start + // injecting a plan reference into ordinary (non-plan) sessions, where the + // default `local://PLAN.md` path has no file on disk. + it("does not inject a plan reference after compaction when no plan file exists", async () => { + const { session, waitForCall } = await createHarness(); + stubCompaction(); + + await session.prompt("do some ordinary work"); + emitHighUsageTurn(session); + const continuation = await waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER))); + + expect(continuation.messageTexts.some(text => text.includes("## Existing Plan"))).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts index a41c658b5..8f96a86fe 100644 --- a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts +++ b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Agent } from "@oh-my-pi/pi-agent-core"; +import { Agent, isSoftToolRequirement } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -17,6 +17,7 @@ import { Snowflake } from "@oh-my-pi/pi-utils"; describe("AgentSession resolve reminder", () => { let session: AgentSession; + let toolSession: ToolSession; let tempDir: string; let mock: MockModel; let authStorage: AuthStorage | undefined; @@ -34,43 +35,17 @@ describe("AgentSession resolve reminder", () => { authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); - mock = createMockModel({ - handler: () => { - if (mock.calls.length === 1) { - queueResolveHandler( - { - getToolChoiceQueue: () => session.toolChoiceQueue, - buildToolChoice: (name: string) => buildNamedToolChoice(name, session.model!), - steer: (msg: { customType: string; content: string; details?: unknown }) => - session.agent.steer({ - role: "custom", - customType: msg.customType, - content: msg.content, - display: false, - details: msg.details, - attribution: "agent", - timestamp: Date.now(), - }), - } as unknown as ToolSession, - { - label: "AST Edit: 1 replacement in 1 file", - sourceToolName: "ast_edit", - apply: async () => ({ content: [{ type: "text", text: "Applied" }] }), - }, - ); - } - return { content: ["Done"] }; - }, - }); + mock = createMockModel({ handler: () => ({ content: ["Done"] }) }); + + toolSession = { + getToolChoiceQueue: () => session.toolChoiceQueue, + buildToolChoice: (name: string) => buildNamedToolChoice(name, session.model!), + } as unknown as ToolSession; const agent = new Agent({ - initialState: { - model, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, streamFn: mock.stream, + getToolChoice: () => session.nextToolChoiceDirective(), }); session = new AgentSession({ @@ -88,24 +63,31 @@ describe("AgentSession resolve reminder", () => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } - vi.restoreAllMocks(); }); - it("forces an immediate steering turn and injects resolve reminder before second assistant response", async () => { - await session.prompt("run preview"); + it("delivers the resolve reminder via a non-forcing soft requirement, not a steer or a forced tool_choice", () => { + queueResolveHandler(toolSession, { + label: "AST Edit: 1 replacement in 1 file", + sourceToolName: "ast_edit", + apply: async () => ({ content: [{ type: "text", text: "Applied" }] }), + }); - expect(mock.calls).toHaveLength(2); + // Forcing was removed — staging a preview never queues a hard tool_choice. + expect(session.nextToolChoice()).toBeUndefined(); - const messages = session.agent.state.messages; - const assistantIndices = messages - .map((message, index) => (message.role === "assistant" ? index : -1)) - .filter(index => index >= 0); - const reminderIndex = messages.findIndex( - message => message.role === "custom" && message.customType === "resolve-reminder", - ); + // The reminder now rides an agent-level soft requirement (delivered once by + // the agent loop) instead of a host-side steer that churned the prefix. + const directive = session.nextToolChoiceDirective(); + expect(isSoftToolRequirement(directive)).toBe(true); + if (!isSoftToolRequirement(directive)) throw new Error("expected soft requirement"); + expect(directive.toolName).toBe("resolve"); + const reminder = directive.reminder[0]; + expect(reminder?.role).toBe("custom"); + if (reminder?.role === "custom") { + expect(reminder.customType).toBe("resolve-reminder"); + } - expect(assistantIndices.length).toBe(2); - expect(reminderIndex).toBeGreaterThan(assistantIndices[0]!); - expect(reminderIndex).toBeLessThan(assistantIndices[1]!); + // Nothing was steered into the conversation — no prefix churn. + expect(session.agent.peekSteeringQueue()).toHaveLength(0); }); }); diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index 6e75d0059..84b97a42e 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -363,7 +363,7 @@ describe("AgentSession retry delay cap", () => { expect(last.stopReason).toBe("stop"); }); - it("does not auto-retry a timeout after streaming a write tool call", async () => { + it("does not auto-retry a timeout after streaming a complete write tool call", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected bundled Anthropic test model to exist"); @@ -414,6 +414,7 @@ describe("AgentSession retry delay cap", () => { delta: JSON.stringify(toolCall.arguments), partial, }); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial }); stream.push({ type: "error", reason: "error", @@ -456,9 +457,141 @@ describe("AgentSession retry delay cap", () => { expect(streamCalls).toBe(1); expect(retryStartEvents).toHaveLength(0); expect(retryEndEvents).toHaveLength(0); + expect(session.agent.state.messages.at(-1)?.role).toBe("toolResult"); + const lastError = [...session.agent.state.messages] + .reverse() + .find((message): message is AssistantMessage => message.role === "assistant"); + expect(lastError?.stopReason).toBe("error"); + expect(lastError?.errorMessage).toBe("The operation timed out."); + }); + + it("retries a transient socket close after partial text and thinking", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) { + throw new Error("Expected bundled Anthropic test model to exist"); + } + + let streamCalls = 0; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: requestedModel => { + streamCalls += 1; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const partial: AssistantMessage = { + role: "assistant", + content: [], + api: requestedModel.api, + provider: requestedModel.provider, + model: requestedModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + if (streamCalls === 1) { + const thinking = { type: "thinking" as const, thinking: "partial thought" }; + const text = { type: "text" as const, text: "partial text" }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tc-incomplete", + name: "bash", + arguments: { command: "bun probe-archive3.ts" }, + }; + partial.content.push(thinking, text, toolCall); + stream.push({ type: "start", partial }); + stream.push({ type: "thinking_start", contentIndex: 0, partial }); + stream.push({ type: "thinking_delta", contentIndex: 0, delta: thinking.thinking, partial }); + stream.push({ type: "thinking_end", contentIndex: 0, content: thinking.thinking, partial }); + stream.push({ type: "text_start", contentIndex: 1, partial }); + stream.push({ type: "text_delta", contentIndex: 1, delta: text.text, partial }); + stream.push({ type: "text_end", contentIndex: 1, content: text.text, partial }); + stream.push({ type: "toolcall_start", contentIndex: 2, partial }); + stream.push({ + type: "toolcall_delta", + contentIndex: 2, + delta: JSON.stringify(toolCall.arguments), + partial, + }); + stream.push({ + type: "error", + reason: "error", + error: { + ...partial, + stopReason: "error", + errorMessage: + "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + duration: 1000, + }, + }); + return; + } + + const recovered = { type: "text" as const, text: "recovered after partial socket close" }; + partial.content.push(recovered); + stream.push({ type: "start", partial }); + stream.push({ type: "text_start", contentIndex: 0, partial }); + stream.push({ type: "text_delta", contentIndex: 0, delta: recovered.text, partial }); + stream.push({ type: "text_end", contentIndex: 0, content: recovered.text, partial }); + stream.push({ + type: "done", + reason: "stop", + message: { + ...partial, + stopReason: "stop", + duration: 1000, + }, + }); + }); + return stream; + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxDelayMs": 5_000, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + const retryStartEvents: AutoRetryStartEvent[] = []; + const retryEndEvents: AutoRetryEndEvent[] = []; + session.subscribe(event => { + if (event.type === "auto_retry_start") retryStartEvents.push(event); + if (event.type === "auto_retry_end") retryEndEvents.push(event); + }); + + await session.prompt("Trigger partial socket close"); + await session.waitForIdle(); + + expect(streamCalls).toBe(2); + expect(retryStartEvents).toHaveLength(1); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true }); const last = lastAssistant(session); - expect(last.stopReason).toBe("error"); - expect(last.errorMessage).toBe("The operation timed out."); + expect(last.stopReason).toBe("stop"); + expect(last.content).toContainEqual({ type: "text", text: "recovered after partial socket close" }); }); it("retries on Bun HTTP/2 stream reset errors", async () => { diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index f66495b75..5bc86fe97 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1451,6 +1451,10 @@ describe("AgentSession retry fallback", () => { expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(true); expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:low")).toBe(true); + modelRegistry.suppressSelector("openai/gpt-4o:max", future); + expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:xhigh")).toBe(true); + expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:max")).toBe(true); + await modelRegistry.refresh("offline"); expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(false); }); diff --git a/packages/coding-agent/test/auth-broker-import.test.ts b/packages/coding-agent/test/auth-broker-import.test.ts index 8f21357bc..d5b2dd660 100644 --- a/packages/coding-agent/test/auth-broker-import.test.ts +++ b/packages/coding-agent/test/auth-broker-import.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; +import { type AuthBrokerServerHandle, startAuthBroker } from "@oh-my-pi/pi-ai/auth-broker"; import { runAuthBrokerCommand } from "@oh-my-pi/pi-coding-agent/cli/auth-broker-cli"; import { getAgentDbPath, setAgentDir } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts index 510f1b94c..9e43f52bc 100644 --- a/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts +++ b/packages/coding-agent/test/auth-broker-snapshot-cache.test.ts @@ -2,13 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai"; -import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent/sdk"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { + type AuthBrokerServerHandle, readAuthBrokerSnapshotCache, type SnapshotResponse, + startAuthBroker, writeAuthBrokerSnapshotCache, -} from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +} from "@oh-my-pi/pi-ai/auth-broker"; +import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent/sdk"; const ENV_KEYS = [ "OMP_AUTH_BROKER_URL", diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index 687e3e9ad..1e8141eba 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -15,6 +15,7 @@ import { AUTO_THINKING, clampAutoThinkingEffort, parseConfiguredThinkingLevel, + parseEffort, parseThinkingLevel, } from "@oh-my-pi/pi-coding-agent/thinking"; import type { TinyMemoryLocalModelKey } from "@oh-my-pi/pi-coding-agent/tiny/models"; @@ -130,4 +131,18 @@ describe("auto thinking classifier helpers", () => { expect(clampAutoThinkingEffort(model, Effort.XHigh)).toBe(Effort.High); expect(clampAutoThinkingEffort(model, Effort.Minimal)).toBe(Effort.Low); }); + + it("accepts max as the top configured thinking alias", () => { + expect(parseEffort("max")).toBe(Effort.XHigh); + expect(parseThinkingLevel("max")).toBeUndefined(); + expect(parseConfiguredThinkingLevel("max")).toBe(ThinkingLevel.XHigh); + }); + + it("rejects inherited object keys as thinking selectors", () => { + for (const selector of ["toString", "constructor", "__proto__"]) { + expect(parseEffort(selector)).toBeUndefined(); + expect(parseThinkingLevel(selector)).toBeUndefined(); + expect(parseConfiguredThinkingLevel(selector)).toBeUndefined(); + } + }); }); diff --git a/packages/coding-agent/test/cache-invalidation-marker.test.ts b/packages/coding-agent/test/cache-invalidation-marker.test.ts new file mode 100644 index 000000000..321484084 --- /dev/null +++ b/packages/coding-agent/test/cache-invalidation-marker.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { Usage } from "@oh-my-pi/pi-ai/types"; +import { + CacheInvalidationMarkerComponent, + detectCacheInvalidation, +} from "@oh-my-pi/pi-coding-agent/modes/components/cache-invalidation-marker"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +function usage(parts: { input?: number; cacheRead?: number; cacheWrite?: number; output?: number }): Usage { + const input = parts.input ?? 0; + const output = parts.output ?? 0; + const cacheRead = parts.cacheRead ?? 0; + const cacheWrite = parts.cacheWrite ?? 0; + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +describe("detectCacheInvalidation", () => { + it("does not flag the first turn (no prior cache footprint)", () => { + expect(detectCacheInvalidation(undefined, usage({ cacheWrite: 50_000, input: 2 }))).toBeUndefined(); + }); + + it("flags a cacheRead collapse after a warm turn and reports reprocessed tokens", () => { + // Mirrors the observed session: warm turn reads ~50k, next request reads + // nothing and re-creates the whole prefix. + const prev = usage({ cacheRead: 49_837, cacheWrite: 980, output: 79 }); + const current = usage({ cacheRead: 0, cacheWrite: 50_900, input: 99, output: 99 }); + expect(detectCacheInvalidation(prev, current)).toEqual({ reprocessedTokens: 50_999 }); + }); + + it("flags a second consecutive cold turn using the prior turn's cacheWrite as footprint", () => { + // The prior turn re-cached (cacheWrite) but read nothing; the next turn + // still reads nothing — a genuine repeat invalidation. + const prev = usage({ cacheRead: 0, cacheWrite: 50_900, input: 99 }); + const current = usage({ cacheRead: 0, cacheWrite: 51_113, input: 16 }); + expect(detectCacheInvalidation(prev, current)).toEqual({ reprocessedTokens: 51_129 }); + }); + + it("does not flag a turn that reused any cache", () => { + const prev = usage({ cacheRead: 0, cacheWrite: 51_113, input: 16 }); + const current = usage({ cacheRead: 50_900, cacheWrite: 3_459, input: 2 }); + expect(detectCacheInvalidation(prev, current)).toBeUndefined(); + }); + + it("does not flag implicit best-effort caches that report no cacheWrite", () => { + // Gemini/antigravity and Fireworks/glm report `cacheWrite: 0` and drop + // `cacheRead` to zero intermittently while the prefix is unchanged — a + // provider propagation race that self-heals next turn, not an invalidation. + // Mirrors the observed gemini-3.5-flash turn (warm 40.8k read, then a cold + // 43.1k reprocess with zero cacheWrite). + const prev = usage({ cacheRead: 40_789, input: 1_069, output: 353 }); + const current = usage({ cacheRead: 0, cacheWrite: 0, input: 43_102, output: 58 }); + expect(detectCacheInvalidation(prev, current)).toBeUndefined(); + }); + + it("ignores collapses when the prior footprint was below the cacheable floor", () => { + // No meaningful cache existed to invalidate (e.g. provider without prompt + // caching, or a tiny early context). + const prev = usage({ input: 500 }); + expect(detectCacheInvalidation(prev, usage({ input: 600 }))).toBeUndefined(); + }); + + it("ignores a cold turn that reprocessed only a trivial prompt", () => { + const prev = usage({ cacheRead: 40_000, cacheWrite: 1_000 }); + expect(detectCacheInvalidation(prev, usage({ cacheRead: 0, input: 12 }))).toBeUndefined(); + }); +}); + +describe("CacheInvalidationMarkerComponent", () => { + beforeAll(async () => { + // render() reads the global theme singleton (icons, rule glyph, colors). + await initTheme(); + }); + + it("renders a slim, left-aligned, partial-width divider padded by blank lines", () => { + const lines = new CacheInvalidationMarkerComponent({ reprocessedTokens: 50_999 }).render(80); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe(""); + expect(lines[2]).toBe(""); + // The divider spans only a short rule + label — well under the full width. + const dividerWidth = Bun.stringWidth(lines[1]); + expect(dividerWidth).toBeGreaterThan(0); + expect(dividerWidth).toBeLessThan(80); + }); +}); diff --git a/packages/coding-agent/test/cli-print-thoughts-flag.test.ts b/packages/coding-agent/test/cli-print-thoughts-flag.test.ts new file mode 100644 index 000000000..b529f92e7 --- /dev/null +++ b/packages/coding-agent/test/cli-print-thoughts-flag.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; + +describe("parseArgs — --print-thoughts flag", () => { + it("parses --print-thoughts as a boolean flag", () => { + const result = parseArgs(["--print-thoughts"]); + expect(result.printThoughts).toBe(true); + }); + + it("does not consume the next argument", () => { + const result = parseArgs(["--print", "--print-thoughts", "explain"]); + expect(result.print).toBe(true); + expect(result.printThoughts).toBe(true); + expect(result.messages).toEqual(["explain"]); + }); +}); diff --git a/packages/coding-agent/test/cli/ttsr-cli.test.ts b/packages/coding-agent/test/cli/ttsr-cli.test.ts index 5caf2540b..56320a0df 100644 --- a/packages/coding-agent/test/cli/ttsr-cli.test.ts +++ b/packages/coding-agent/test/cli/ttsr-cli.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { runTtsrCommand, @@ -11,6 +12,18 @@ import { import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; +let testTmpDir: string; + +beforeAll(() => { + testTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-ttsr-tests-")); +}); + +afterAll(() => { + if (testTmpDir && fs.existsSync(testTmpDir)) { + fs.rmSync(testTmpDir, { force: true, recursive: true }); + } +}); + // Capture stdout writes so assertions don't leak to the test runner. let stdout = ""; @@ -58,7 +71,7 @@ async function writeTempRule(condition: string, scope: string[], astCondition?: // Stable basename "test-rule.md" so buildRuleFromMarkdown derives name // "test-rule" — assertions rely on it. Each call uses a unique parent dir // to avoid collisions across tests. - const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`); + const dir = path.join(testTmpDir, `rule-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, "test-rule.md"); const fm: string[] = [`description: test rule`, `condition: "${condition.replace(/"/g, '\\"')}"`]; @@ -69,7 +82,7 @@ async function writeTempRule(condition: string, scope: string[], astCondition?: } async function writeTempSnippet(content: string, ext: string): Promise { - const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`); + const dir = path.join(testTmpDir, `snippet-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `snippet.${ext}`); await Bun.write(tmp, content); @@ -77,10 +90,9 @@ async function writeTempSnippet(content: string, ext: string): Promise { } function cleanupTmp(): void { - for (const entry of fs.readdirSync(import.meta.dir)) { - if (entry.startsWith(".tmp-ttsr-")) { - fs.rmSync(path.join(import.meta.dir, entry), { force: true, recursive: true }); - } + if (!testTmpDir || !fs.existsSync(testTmpDir)) return; + for (const entry of fs.readdirSync(testTmpDir)) { + fs.rmSync(path.join(testTmpDir, entry), { force: true, recursive: true }); } } @@ -208,7 +220,7 @@ describe("omp ttsr", () => { captureStreams(); // 1. Create a temporary project root directory - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); // 2. Set project root @@ -239,7 +251,7 @@ describe("omp ttsr", () => { it("keeps default scan output summary-only", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -262,7 +274,7 @@ describe("omp ttsr", () => { it("outputs json format correctly when json flag is set", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -290,7 +302,7 @@ describe("omp ttsr", () => { it("reports no matches when files do not match rule conditions", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -313,7 +325,7 @@ describe("omp ttsr", () => { it("respects project gitignore when scanning a subdirectory", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/src"), { recursive: true }); @@ -342,7 +354,7 @@ describe("omp ttsr", () => { it("honors ttsr.enabled=false for project scans", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); fs.mkdirSync(getProjectAgentDir(projectDir), { recursive: true }); setProjectDir(projectDir); @@ -362,7 +374,7 @@ describe("omp ttsr", () => { it("includes ignored files when gitignore filtering is disabled", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); setProjectDir(projectDir); @@ -388,7 +400,7 @@ describe("omp ttsr", () => { it("includes hidden source files like GitHub workflows", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, ".github/workflows"), { recursive: true }); setProjectDir(projectDir); @@ -411,7 +423,7 @@ describe("omp ttsr", () => { it("keeps AST-only matches in default summary when type assertion spans whitespace", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -436,7 +448,7 @@ describe("omp ttsr", () => { it("skips files larger than the scan byte limit", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); @@ -463,7 +475,7 @@ describe("omp ttsr", () => { it("skips binary-looking files before text matching", async () => { captureStreams(); - const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + const projectDir = path.join(testTmpDir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); setProjectDir(projectDir); diff --git a/packages/coding-agent/test/collab/crypto.test.ts b/packages/coding-agent/test/collab/crypto.test.ts index 68f5bd181..9854ba566 100644 --- a/packages/coding-agent/test/collab/crypto.test.ts +++ b/packages/coding-agent/test/collab/crypto.test.ts @@ -123,6 +123,47 @@ describe("collab link format", () => { } }); + it("wraps custom relay links in an explicit web UI origin", () => { + const webLink = formatCollabWebLink( + "wss://relay.example.com:8443", + roomId, + key, + undefined, + "https://web.example", + ); + expect(webLink.startsWith("https://web.example/#relay.example.com:8443/r/")).toBe(true); + const parsed = parseCollabLink(webLink); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses non-local http web UI wrappers by their relay fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", roomId, key); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + it("parses the scheme-less display form of web deep links", () => { const parsed = parseCollabLink(`my.omp.sh/#${formatCollabLink(DEFAULT_RELAY_URL, roomId, key)}`); if ("error" in parsed) throw new Error(parsed.error); @@ -164,6 +205,46 @@ describe("collab link format", () => { expect(url.search).toBe(""); expect(url.hash).toBe(`#${roomId}.${Buffer.from(key).toString("base64url")}`); }); + + it("normalizes explicit web UI roots, paths, trailing slashes, and ports", () => { + const rootLink = formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, " https://web.example/ "); + expect(rootLink.startsWith("https://web.example/#")).toBe(true); + + const pathLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "https://web.example:8443/collab///", + ); + expect(pathLink.startsWith("https://web.example:8443/collab/#")).toBe(true); + + const localHttpLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "http://localhost:5173/app/", + ); + expect(localHttpLink.startsWith("http://localhost:5173/app/#")).toBe(true); + }); + + it("rejects web UI URLs without an http or https protocol", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "ftp://web.example")).toThrow( + "collab.webUrl must start with http:// or https://", + ); + }); + + it("rejects non-local plain-http web UI URLs", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "http://web.example")).toThrow( + "collab.webUrl must use https:// unless it targets localhost", + ); + }); + it("rejects web UI URLs with query strings or fragments", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "https://web.example/?x=1")).toThrow( + "collab.webUrl must not include a query string or fragment", + ); + }); }); describe("collab wire envelope", () => { diff --git a/packages/coding-agent/test/compact-modes.test.ts b/packages/coding-agent/test/compact-modes.test.ts new file mode 100644 index 000000000..ea179d993 --- /dev/null +++ b/packages/coding-agent/test/compact-modes.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { findCompactMode, parseCompactArgs } from "@oh-my-pi/pi-coding-agent/session/compact-modes"; + +describe("compact mode registry", () => { + it("maps each mode to the settings overrides the engine relies on", () => { + // These override values are load-bearing: the engine merges them over the + // configured compaction.* settings, so a regression here silently changes + // what `/compact ` does. + expect(findCompactMode("soft")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: false }); + expect(findCompactMode("remote")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: true }); + expect(findCompactMode("snapcompact")?.overrides).toEqual({ strategy: "snapcompact" }); + }); + + it("flags remote as remote-requiring and snapcompact as focus-rejecting", () => { + expect(findCompactMode("remote")?.requiresRemote).toBe(true); + expect(findCompactMode("snapcompact")?.rejectsFocus).toBe(true); + // soft is a plain local summary: neither flag. + expect(findCompactMode("soft")?.requiresRemote).toBeUndefined(); + expect(findCompactMode("soft")?.rejectsFocus).toBeUndefined(); + }); + + it("resolves mode names case-insensitively and rejects unknowns", () => { + expect(findCompactMode("SOFT")?.name).toBe("soft"); + expect(findCompactMode(" Remote ")?.name).toBe("remote"); + expect(findCompactMode("bogus")).toBeUndefined(); + expect(findCompactMode("")).toBeUndefined(); + }); +}); + +describe("parseCompactArgs", () => { + it("returns no mode and no instructions for empty args", () => { + expect(parseCompactArgs("")).toEqual({}); + expect(parseCompactArgs(" ")).toEqual({}); + }); + + it("detects a leading mode token", () => { + expect(parseCompactArgs("soft")).toEqual({ mode: "soft" }); + expect(parseCompactArgs("remote")).toEqual({ mode: "remote" }); + expect(parseCompactArgs("snapcompact")).toEqual({ mode: "snapcompact" }); + }); + + it("splits a mode from its trailing focus instructions", () => { + expect(parseCompactArgs("soft focus on the parser bug")).toEqual({ + mode: "soft", + instructions: "focus on the parser bug", + }); + expect(parseCompactArgs("remote keep auth details")).toEqual({ + mode: "remote", + instructions: "keep auth details", + }); + }); + + it("treats a non-mode first token as plain focus instructions (backward compatible)", () => { + expect(parseCompactArgs("summarize the auth flow")).toEqual({ instructions: "summarize the auth flow" }); + // A bare word that is not a mode is still focus text, not an error. + expect(parseCompactArgs("everything")).toEqual({ instructions: "everything" }); + }); + + it("rejects focus instructions for modes that produce no summary", () => { + const result = parseCompactArgs("snapcompact keep the diffs"); + expect(result).toHaveProperty("error"); + expect("error" in result && result.error).toContain("snapcompact"); + // Bare snapcompact is fine. + expect(parseCompactArgs("snapcompact")).toEqual({ mode: "snapcompact" }); + }); +}); diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index bcd7f7b96..4ea13601e 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -107,4 +107,18 @@ describe("config CLI schema coverage", () => { value: 600, }); }); + + it("accepts max as a persisted default thinking level", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runConfigCommand({ action: "set", key: "defaultThinkingLevel", value: "max", flags: { json: true } }); + await runConfigCommand({ action: "get", key: "defaultThinkingLevel", flags: { json: true } }); + + const payload = logSpy.mock.calls.at(-1)?.[0]; + expect(typeof payload).toBe("string"); + const parsed = JSON.parse(String(payload)) as { key: string; value: unknown; type: string }; + expect(parsed.key).toBe("defaultThinkingLevel"); + expect(parsed.type).toBe("enum"); + expect(parsed.value).toBe("max"); + }); }); diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts deleted file mode 100644 index c944fa816..000000000 --- a/packages/coding-agent/test/config-spacing.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; -import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; - -describe("indentation resolver", () => { - let settingsState: SettingsTestState | undefined; - let tempDir = ""; - - beforeEach(async () => { - settingsState = beginSettingsTest(); - setDefaultTabWidth(3); - tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - }); - - afterEach(async () => { - restoreSettingsTestState(settingsState); - settingsState = undefined; - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - } - tempDir = ""; - }); - - it("applies current display tab width during initial settings load", async () => { - await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } }); - expect(getDefaultTabWidth()).toBe(7); - expect(getIndentation()).toBe(7); - }); - - it("applies nearest editorconfig rules for the target file", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, "\tconst x = 1;\n"); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(getIndentation(filePath)).toBe(2); - expect(getIndentation(path.join(tempDir, "README.md"))).toBe(4); - }); - - it("merges editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - await fs.writeFile(filePath, "\tconst y = 2;\n"); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(getIndentation(filePath)).toBe(6); - }); -}); diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index 914f72b09..f0886a915 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -6,6 +6,7 @@ import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context- import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup @@ -365,13 +366,13 @@ describe("executeJs", () => { expect(execute).toHaveBeenNthCalledWith( 1, expect.stringMatching(/^js-read-/), - { path: "artifact://15:raw:1-1400", _i: "js prelude" }, + { path: "artifact://15:raw:1-1400", [INTENT_FIELD]: "js prelude" }, expect.any(AbortSignal), ); expect(execute).toHaveBeenNthCalledWith( 2, expect.stringMatching(/^js-read-/), - { path: "artifact://15:raw:1-2", _i: "js prelude" }, + { path: "artifact://15:raw:1-2", [INTENT_FIELD]: "js prelude" }, expect.any(AbortSignal), ); }); @@ -422,8 +423,8 @@ describe("executeJs", () => { agentOutput: "from-agent", }); expect(execute).toHaveBeenCalledTimes(2); - expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" }); - expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" }); + expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", [INTENT_FIELD]: "js prelude" }); + expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", [INTENT_FIELD]: "js prelude" }); }); it("auto-displays the final awaited expression result", async () => { diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index df6c75512..fb1f3809b 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -3,6 +3,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; function createTool( @@ -53,7 +54,7 @@ describe("callSessionTool", () => { expect(result).toBe("hello"); expect(execute).toHaveBeenCalledWith( expect.stringMatching(/^js-read-/), - { path: "/tmp/demo.txt", _i: "js prelude" }, + { path: "/tmp/demo.txt", [INTENT_FIELD]: "js prelude" }, undefined, ); expect(statuses).toEqual([expect.objectContaining({ op: "read", path: "/tmp/demo.txt", chars: 5 })]); diff --git a/packages/coding-agent/test/core/python-tool-bridge.test.ts b/packages/coding-agent/test/core/python-tool-bridge.test.ts index 67d158356..2bb4ee099 100644 --- a/packages/coding-agent/test/core/python-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/python-tool-bridge.test.ts @@ -6,6 +6,7 @@ import { registerPyToolBridge, } from "@oh-my-pi/pi-coding-agent/eval/py/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; interface FakeCall { id: string; @@ -64,14 +65,14 @@ describe("Python tool bridge HTTP server", () => { session: "test-session-1", run: "run-1", name: "read", - args: { path: "foo.ts", _i: "py prelude" }, + args: { path: "foo.ts", [INTENT_FIELD]: "py prelude" }, }); const body = await res.json(); expect(res.status).toBe(200); expect(body).toEqual({ ok: true, value: "file body" }); expect(calls).toHaveLength(1); // `_i` survives the bridge round trip so transcript renderers have a label. - expect((calls[0]!.args as { _i?: string })._i).toBe("py prelude"); + expect((calls[0]!.args as Record)[INTENT_FIELD]).toBe("py prelude"); } finally { unregister(); } diff --git a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts index ad5e66b04..f393e2feb 100644 --- a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts @@ -96,4 +96,78 @@ describe("RawSseDebugBuffer", () => { expect(buffer.toRawText()).toContain("requestId=req_pre_viewer"); expect(buffer.toRawText()).toContain("event: message_stop"); }); + + it("keeps oldest-first order and exact droppedRecords well past MAX_RAW_SSE_EVENTS", () => { + const buffer = new RawSseDebugBuffer(); + // > 2x MAX_RAW_SSE_EVENTS (1000) so the head-index ring compacts at least + // once; a corrupt slice would scramble order or counts. + const APPENDS = 2_300; + for (let i = 1; i <= APPENDS; i++) { + buffer.recordEvent({ event: null, data: "{}", raw: [`data: ${i}`] }, model); + } + + const snapshot = buffer.snapshot(); + // 1000 newest survive (sequences 1301..2300); the oldest 1300 are evicted. + expect(snapshot.records).toHaveLength(1_000); + expect(snapshot.droppedRecords).toBe(1_300); + expect(snapshot.totalEvents).toBe(APPENDS); + const last = snapshot.records[snapshot.records.length - 1]; + expect(snapshot.records[0].sequence).toBe(1_301); + expect(last.sequence).toBe(2_300); + // Contiguous + oldest-first across the compaction boundary. + expect(snapshot.records.every((record, idx) => record.sequence === 1_301 + idx)).toBe(true); + expect(rawSseRecordLines(snapshot.records[0])[0]).toBe("data: 1301"); + expect(rawSseRecordLines(last)[0]).toBe("data: 2300"); + }); + + it("evicts by char budget with exact droppedChars and live char total", () => { + const buffer = new RawSseDebugBuffer(); + // One 63_998-char line → originalChars 63_999 (≤ the 64_000 per-event cap, + // so no trim) → 64_000 chars per record. 8 records exactly fill + // MAX_RAW_SSE_CHARS (512_000); each further append evicts the oldest. + const PER_RECORD_CHARS = 64_000; + const LINE_LEN = 63_998; + const APPENDS = 12; + for (let i = 1; i <= APPENDS; i++) { + const line = `data: ${i}`.padEnd(LINE_LEN, "x"); + buffer.recordEvent({ event: null, data: "{}", raw: [line] }, model); + } + + const snapshot = buffer.snapshot(); + // 12 appended, 8 fit the budget → the oldest 4 (sequences 1..4) drop. + expect(snapshot.records).toHaveLength(8); + expect(snapshot.droppedRecords).toBe(4); + expect(snapshot.droppedChars).toBe(4 * PER_RECORD_CHARS); + expect(snapshot.records[0].sequence).toBe(5); + expect(snapshot.records[snapshot.records.length - 1].sequence).toBe(12); + // #totalChars is not exposed; derive live chars from the conservation + // invariant (sum appended − droppedChars) and confirm eviction stopped + // exactly at budget rather than over- or under-evicting. + const liveChars = APPENDS * PER_RECORD_CHARS - snapshot.droppedChars; + expect(liveChars).toBe(8 * PER_RECORD_CHARS); + expect(liveChars).toBeLessThanOrEqual(512_000); + }); + + it("emits the dropped header and oldest-first body in toRawText after eviction", () => { + const buffer = new RawSseDebugBuffer(); + const APPENDS = 1_005; // 5 past MAX_RAW_SSE_EVENTS → oldest 5 drop + for (let i = 1; i <= APPENDS; i++) { + buffer.recordEvent({ event: null, data: "{}", raw: [`data: ${i}`] }, model); + } + + // Dropped records 1..5: each "data: N" is 7 chars → 9 chars/record → 45. + const text = buffer.toRawText(); + expect(text.startsWith(": omp-debug-dropped records=5 chars=45\n\n")).toBe(true); + + // Body data lines, oldest-first, are exactly records 6..1005 — no dropped + // record leaks and order survives the head-index window. + const dataLines = text + .split("\n") + .filter(line => line.startsWith("data: ")) + .map(line => Number(line.slice("data: ".length))); + expect(dataLines).toHaveLength(1_000); + expect(dataLines[0]).toBe(6); + expect(dataLines.at(-1)).toBe(1_005); + expect(dataLines.every((n, idx) => n === 6 + idx)).toBe(true); + }); }); diff --git a/packages/coding-agent/test/edit/file-snapshot-store.test.ts b/packages/coding-agent/test/edit/file-snapshot-store.test.ts index bd950ee82..eb829e4ee 100644 --- a/packages/coding-agent/test/edit/file-snapshot-store.test.ts +++ b/packages/coding-agent/test/edit/file-snapshot-store.test.ts @@ -72,17 +72,15 @@ describe("snapshot store fusion via canonical keys", () => { describe("parseSeenLinesFromHashlineBody", () => { it("collects single NN: line numbers and skips the header and footer rows", () => { - const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[2 lines elided; …]"].join( - "\n", - ); + const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[…2ln elided; …]"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([300, 301, 302]); }); it("adds only the boundary lines of a collapsed NN-MM: summary row, never the interior", () => { const body = [ - "30-39:export interface Snapshot { .. }", + "30-39:export interface Snapshot { … }", "40:", - "46-61:export abstract class SnapshotStore { .. }", + "46-61:export abstract class SnapshotStore { … }", ].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([30, 39, 40, 46, 61]); }); @@ -92,7 +90,7 @@ describe("parseSeenLinesFromHashlineBody", () => { }); it("tolerates grep `*`/space match markers before the line number (search/ast-grep output)", () => { - const body = ["*73:matched line", " 74:context line", "...", " 75:more context"].join("\n"); + const body = ["*73:matched line", " 74:context line", "…", " 75:more context"].join("\n"); expect(parseSeenLinesFromHashlineBody(body)).toEqual([73, 74, 75]); }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index fa93d91b8..f09199887 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -1352,6 +1352,54 @@ describe("ExtensionRunner", () => { }); }); + describe("zero-handler fast path", () => { + it("skips context allocation and handler machinery for an unsubscribed event type, but still fires when subscribed", async () => { + // The fast path in ExtensionRunner.emit is event-type agnostic; the hot + // streaming events (message_update / tool_execution_*) traverse the same + // path. `turn_start` stands in as a subscribed event with a trivial payload. + const extCode = ` + export default function(pi) { + pi.on("turn_start", async () => { + throw new Error("turn_start handler ran"); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "turn-start-handler.ts"), extCode); + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + + // createContext is the per-event allocation the fast path defers; spying on + // it (call-through preserved) proves the slow path is entered only when a + // handler exists for the emitted event type. + const createContextSpy = vi.spyOn(runner, "createContext"); + const errors: Array<{ event: string; error: string }> = []; + runner.onError(err => { + errors.push({ event: err.event, error: err.error }); + }); + + // No extension subscribes to `agent_start`: no context allocation, and the + // handler-timeout machinery is never entered. + await runner.emit({ type: "agent_start" }); + expect(createContextSpy).not.toHaveBeenCalled(); + expect(errors).toHaveLength(0); + + // `turn_start` has a handler: context is allocated once and the handler runs + // (its throw surfaces via onError, proving #runHandlerWithTimeout executed). + await runner.emit({ type: "turn_start", turnIndex: 0, timestamp: Date.now() }); + expect(createContextSpy).toHaveBeenCalledTimes(1); + expect(errors).toHaveLength(1); + expect(errors[0]?.event).toBe("turn_start"); + expect(errors[0]?.error).toContain("turn_start handler ran"); + }); + }); + describe("credential_disabled", () => { it("delivers credential_disabled events to subscribed extensions with the typed payload", async () => { const eventsPath = path.join(tempDir.path(), "credential-disabled-events.jsonl"); diff --git a/packages/coding-agent/test/file-mentions.test.ts b/packages/coding-agent/test/file-mentions.test.ts index 8700ced2d..4437cb497 100644 --- a/packages/coding-agent/test/file-mentions.test.ts +++ b/packages/coding-agent/test/file-mentions.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { generateFileMentionMessages } from "@oh-my-pi/pi-coding-agent/utils/file-mentions"; +import { extractFileMentions, generateFileMentionMessages } from "@oh-my-pi/pi-coding-agent/utils/file-mentions"; const tempDirs: string[] = []; @@ -79,4 +79,22 @@ describe("generateFileMentionMessages path resolution", () => { expect(message.files[0]?.path).toBe("real.txt"); expect(message.files[0]?.content).toContain("present"); }); + + test("resolves quoted paths containing spaces", async () => { + const cwd = await createTempDir(); + await fs.mkdir(path.join(cwd, "My Folder"), { recursive: true }); + await Bun.write(path.join(cwd, "My Folder", "my file.png"), "image content"); + + const mentions = extractFileMentions("Please see @\"My Folder/my file.png\" and @'My Folder/my file.png'"); + expect(mentions).toEqual(["My Folder/my file.png"]); + + const messages = await generateFileMentionMessages(mentions, cwd); + expect(messages).toHaveLength(1); + const message = messages[0]; + if (message?.role !== "fileMention") { + throw new Error("expected file mention message"); + } + expect(message.files).toHaveLength(1); + expect(message.files[0]?.path).toBe("My Folder/my file.png"); + }); }); diff --git a/packages/coding-agent/test/flag-tables.test.ts b/packages/coding-agent/test/flag-tables.test.ts index 70db464c9..386996f19 100644 --- a/packages/coding-agent/test/flag-tables.test.ts +++ b/packages/coding-agent/test/flag-tables.test.ts @@ -89,3 +89,20 @@ describe("parseArgs end-of-options (--)", () => { expect(result.messages).toEqual(["hello", "--no-tools"]); }); }); + +describe("parseArgs @file parsing with quotes", () => { + it("parses unquoted @file arguments normally", () => { + const result = parseArgs(["@foo.png"]); + expect(result.fileArgs).toEqual(["foo.png"]); + }); + + it('parses double-quoted @"file" arguments', () => { + const result = parseArgs(['@"foo bar.png"']); + expect(result.fileArgs).toEqual(["foo bar.png"]); + }); + + it("parses single-quoted @'file' arguments", () => { + const result = parseArgs(["@'foo bar.png'"]); + expect(result.fileArgs).toEqual(["foo bar.png"]); + }); +}); diff --git a/packages/coding-agent/test/gallery-cli.test.ts b/packages/coding-agent/test/gallery-cli.test.ts index c88eeaf11..9eaf0b5d7 100644 --- a/packages/coding-agent/test/gallery-cli.test.ts +++ b/packages/coding-agent/test/gallery-cli.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { GALLERY_STATES, renderGalleryState, resolveFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-cli"; import type { GalleryFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-fixtures"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; beforeAll(async () => { @@ -63,7 +63,7 @@ describe("gallery harness", () => { const lines = await renderGalleryState("task", task, "error", 100); const stripped = lines.map(line => Bun.stripANSI(line).trim()); // The framed result header carries the label inside the box border... - expect(stripped.some(line => line.startsWith("┌") && line.includes("Task"))).toBe(true); + expect(stripped.some(line => line.startsWith(theme.boxRound.topLeft) && line.includes("Task"))).toBe(true); // ...but no standalone "Task" label line precedes it. expect(stripped).not.toContain("Task"); }); diff --git a/packages/coding-agent/test/goals/goal-runtime.test.ts b/packages/coding-agent/test/goals/goal-runtime.test.ts index 275194067..d4dd510a3 100644 --- a/packages/coding-agent/test/goals/goal-runtime.test.ts +++ b/packages/coding-agent/test/goals/goal-runtime.test.ts @@ -80,6 +80,9 @@ function createHarness(initial: { state?: GoalModeState; usage?: GoalTokenUsage; return { runtime: new GoalRuntime(host), getState: () => cloneState(state), + setState: (next: GoalModeState | undefined) => { + state = cloneState(next); + }, setUsage: (next: Partial) => { usage = createUsage(next); }, @@ -153,6 +156,65 @@ describe("goal runtime", () => { expect(harness.persists).toHaveLength(0); }); + it("persists wall-clock-only usage before internal compaction or session-switch aborts", async () => { + const harness = createHarness({ + state: { enabled: true, mode: "active", goal: createGoal() }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.advance(2_500); + await harness.runtime.onTaskAborted({ reason: "internal" }); + + expect(harness.getState()?.enabled).toBe(true); + expect(harness.getState()?.goal.status).toBe("active"); + expect(harness.getState()?.goal.timeUsedSeconds).toBe(2); + expect(harness.persists).toHaveLength(1); + expect(harness.persists[0]).toMatchObject({ + mode: "goal", + state: { goal: { timeUsedSeconds: 2 } }, + }); + }); + + it("resets wall-clock baseline when preserving an active goal after a no-goal switch", async () => { + const goal = createGoal(); + const harness = createHarness({ + state: { enabled: true, mode: "active", goal }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.setState(undefined); + harness.advance(10_000); + harness.setState({ enabled: true, mode: "active", goal }); + + const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + harness.advance(1_000); + await harness.runtime.flushUsage("suppressed"); + + expect(resumed?.goal.status).toBe("active"); + expect(harness.getState()?.goal.timeUsedSeconds).toBe(1); + expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(11_000); + }); + + it("clears stale accounting when reconciling to a no-goal session", async () => { + const goal = createGoal(); + const harness = createHarness({ + state: { enabled: true, mode: "active", goal }, + }); + + harness.runtime.onTurnStart("turn-1", createUsage()); + harness.setState(undefined); + harness.runtime.clearAccounting(); + harness.advance(10_000); + harness.setState({ enabled: true, mode: "active", goal }); + + await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + harness.advance(1_000); + await harness.runtime.flushUsage("suppressed"); + + expect(harness.getState()?.goal.timeUsedSeconds).toBe(1); + expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(11_000); + }); + it("steers only once until a budget mutation resets the cycle", async () => { const harness = createHarness({ state: { @@ -219,6 +281,20 @@ describe("goal runtime", () => { expect(harness.persists.at(-1)?.mode).toBe("goal_paused"); }); + it("preserves an active goal during internal session-switch reconciliation", async () => { + const harness = createHarness({ + state: { enabled: true, mode: "active", goal: createGoal() }, + }); + + const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true }); + + expect(resumed?.enabled).toBe(true); + expect(resumed?.goal.status).toBe("active"); + expect(harness.getState()?.enabled).toBe(true); + expect(harness.getState()?.goal.status).toBe("active"); + expect(harness.persists).toHaveLength(0); + }); + it("escapes XML in goal helpers and rendered prompts", () => { const objective = "Fix &keep>safe"; const goal = createGoal({ objective }); diff --git a/packages/coding-agent/test/helpers/settings-test-state.ts b/packages/coding-agent/test/helpers/settings-test-state.ts index e4825c4c7..44928c099 100644 --- a/packages/coding-agent/test/helpers/settings-test-state.ts +++ b/packages/coding-agent/test/helpers/settings-test-state.ts @@ -1,20 +1,12 @@ import { vi } from "bun:test"; import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { isTuiTight, setTuiTight } from "@oh-my-pi/pi-tui"; -import { - getAgentDir, - getDefaultTabWidth, - getProjectDir, - setAgentDir, - setDefaultTabWidth, - setProjectDir, -} from "@oh-my-pi/pi-utils"; +import { getAgentDir, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils"; export interface SettingsTestState { agentDir: string; env: Record; projectDir: string; - tabWidth: number; tuiTight: boolean; } @@ -30,7 +22,6 @@ export function beginSettingsTest(): SettingsTestState { agentDir: getAgentDir(), env, projectDir: getProjectDir(), - tabWidth: getDefaultTabWidth(), tuiTight: isTuiTight(), }; resetSettingsForTest(); @@ -43,7 +34,6 @@ export function restoreSettingsTestState(state: SettingsTestState | undefined): if (!state) return; restoreEnv(state.env); - setDefaultTabWidth(state.tabWidth); setProjectDir(state.projectDir); setAgentDir(state.agentDir); setTuiTight(state.tuiTight); diff --git a/packages/coding-agent/test/hindsight-mental-models.test.ts b/packages/coding-agent/test/hindsight-mental-models.test.ts index 14809629f..3187080ce 100644 --- a/packages/coding-agent/test/hindsight-mental-models.test.ts +++ b/packages/coding-agent/test/hindsight-mental-models.test.ts @@ -319,7 +319,7 @@ describe("diffMentalModelContent", () => { const out = diffMentalModelContent(null, big, 50); const lines = out.split("\n"); expect(lines.length).toBe(51); // 50 diff lines + 1 elision marker - expect(lines[lines.length - 1]).toMatch(/more lines? elided$/); + expect(lines[lines.length - 1]).toMatch(/\[…\d+ln elided…\]$/); }); it("caps LCS input lines so a huge curated model cannot hang the diff", () => { diff --git a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts index f5bf6c6a5..c8383d0c6 100644 --- a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts +++ b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts @@ -9,7 +9,7 @@ describe("InputController thinking visibility", () => { const loadingIndicator = { kind: "loading" }; const assistant = new AssistantMessageComponent(); const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); - const invalidate = vi.spyOn(assistant, "invalidate"); + const resetDisplay = vi.fn(); const clear = vi.fn(); const addChild = vi.fn(); const rebuildChatFromMessages = vi.fn(); @@ -26,6 +26,7 @@ describe("InputController thinking visibility", () => { streamingMessage: undefined, rebuildChatFromMessages, showStatus, + ui: { resetDisplay }, } as unknown as InteractiveModeContext; new InputController(ctx).toggleThinkingBlockVisibility(); @@ -38,7 +39,7 @@ describe("InputController thinking visibility", () => { expect(addChild).not.toHaveBeenCalled(); expect(rebuildChatFromMessages).not.toHaveBeenCalled(); expect(setHideThinkingBlock).toHaveBeenCalledWith(true); - expect(invalidate).toHaveBeenCalledTimes(1); + expect(resetDisplay).toHaveBeenCalledTimes(1); expect(showStatus).toHaveBeenCalledWith("Thinking blocks: hidden"); }); }); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 0022e3b2f..cd5db7850 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -957,7 +957,7 @@ describe("InteractiveMode plan review rendering", () => { let modelAtFlushTime: string | undefined; // Mirror executeCompaction's ordering: invoke beforeFlush, THEN observe the // model the queue would flush on. - vi.spyOn(mode, "handleCompactCommand").mockImplementation(async (_instructions, beforeFlush) => { + vi.spyOn(mode, "handleCompactCommand").mockImplementation(async (_instructions, _mode, beforeFlush) => { hookWasFunction = typeof beforeFlush === "function"; if (beforeFlush) await beforeFlush("ok"); modelAtFlushTime = session.model?.id; diff --git a/packages/coding-agent/test/internal-urls/docs-index.test.ts b/packages/coding-agent/test/internal-urls/docs-index.test.ts new file mode 100644 index 000000000..980727ba1 --- /dev/null +++ b/packages/coding-agent/test/internal-urls/docs-index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { gzipSync } from "node:zlib"; +import { decodeDocsIndex } from "@oh-my-pi/pi-coding-agent/internal-urls/docs-index"; + +// The embed path only runs in compiled binaries / the npm bundle; dev tests +// otherwise exercise the disk fallback (empty placeholder), so a regression in +// the two-line `\n` parsing would ship broken `omp://` +// docs undetected. These cover the populated-embed decode directly. +describe("decodeDocsIndex (embedded docs path)", () => { + const files = ["agent.md", "tools/read.md"]; + const bodies = ["agent body", "read body"]; + const embed = `${JSON.stringify(files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)))).toString("base64")}`; + + it("lists filenames from the first line without inflating the blob", () => { + // A deliberately corrupt blob: filenames must resolve anyway, proving the + // listing path never decodes the gzip body. + const index = decodeDocsIndex(`${JSON.stringify(files)}\n@@@not-a-valid-gzip-blob@@@`); + expect(index?.filenames).toEqual(files); + }); + + it("resolves bodies by index-aligned path, lazily, on first read", async () => { + const index = decodeDocsIndex(embed); + expect(index).not.toBeNull(); + expect(await index?.getBody("agent.md")).toBe("agent body"); + expect(await index?.getBody("tools/read.md")).toBe("read body"); + expect(await index?.getBody("missing.md")).toBeUndefined(); + }); + + it("returns null when there is no newline separator (empty placeholder)", () => { + expect(decodeDocsIndex("")).toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/internal-urls/history-protocol.test.ts b/packages/coding-agent/test/internal-urls/history-protocol.test.ts index 503170f41..348b4f7c7 100644 --- a/packages/coding-agent/test/internal-urls/history-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/history-protocol.test.ts @@ -13,6 +13,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls"; +import { HistoryProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/history-protocol"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; @@ -186,4 +187,67 @@ describe("history:// protocol", () => { expect(error?.message).toContain("no transcript"); }); + + it("hides advisor transcripts from the index and direct lookup", async () => { + AgentRegistry.global().register({ + id: "HubAgent", + displayName: "task", + kind: "sub", + session: fakeLiveSession([]), + status: "idle", + }); + AgentRegistry.global().register({ + id: "Main/advisor", + displayName: "advisor", + kind: "advisor", + session: fakeLiveSession([{ role: "user", content: "should stay hidden", timestamp: 1 }]), + status: "parked", + }); + AgentRegistry.global().register({ + id: "AdvisorProbe", + displayName: "advisor", + kind: "advisor", + session: fakeLiveSession([{ role: "user", content: "should stay hidden", timestamp: 1 }]), + status: "parked", + }); + + // Index lists the subagent but never the advisor. + const index = await InternalUrlRouter.instance().resolve("history://"); + expect(index.content).toContain("HubAgent"); + expect(index.content).not.toContain("advisor"); + + // Direct lookup of an advisor-kind ref is reported as unknown — the driving + // agent must not be able to read it via history://. + const error = await InternalUrlRouter.instance() + .resolve("history://AdvisorProbe") + .then( + () => null, + err => err as Error, + ); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain("Unknown agent"); + }); + + it("omits advisor refs from history:// completions", async () => { + AgentRegistry.global().register({ + id: "HubAgent", + displayName: "task", + kind: "sub", + session: fakeLiveSession([]), + status: "idle", + }); + AgentRegistry.global().register({ + id: "AdvisorProbe", + displayName: "advisor", + kind: "advisor", + session: null, + sessionFile: "/tmp/x/__advisor.jsonl", + status: "parked", + }); + + const completions = await new HistoryProtocolHandler().complete(); + const values = completions.map(c => c.value); + expect(values).toContain("HubAgent"); + expect(values).not.toContain("AdvisorProbe"); + }); }); diff --git a/packages/coding-agent/test/issue-2375-repro.test.ts b/packages/coding-agent/test/issue-2375-repro.test.ts index 3200f0279..f4e8f834a 100644 --- a/packages/coding-agent/test/issue-2375-repro.test.ts +++ b/packages/coding-agent/test/issue-2375-repro.test.ts @@ -13,7 +13,10 @@ * text paste; the user must see an SSH-aware diagnostic so they know to * paste image bytes directly instead. */ + import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -162,7 +165,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { readText: async () => "", }); // This test file itself: resolvable, readable, but not an image. - const nonImage = import.meta.path.replace(/\.ts$/, ".png"); + const nonImage = path.join(os.tmpdir(), `issue-2375-not-png-${Math.random().toString(36).slice(2)}.png`); await Bun.write(nonImage, "not really a png"); try { await controller.handleImagePathPaste(nonImage); diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 6cca930c5..53c8727d1 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -55,6 +55,43 @@ describe("KeybindingsManager.create", () => { } }); + it("migrates legacy keybinding JSON with comments to YAML during create", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-")); + const jsonPath = path.join(agentDir, "keybindings.json"); + const ymlPath = path.join(agentDir, "keybindings.yml"); + + await Bun.write( + jsonPath, + `{ + // Legacy config files may contain comments from hand-edited examples. + "fork": "ctrl+f", + "selectConfirm": "enter", + "cursorUp": "ctrl+p", + "app.clipboard.copyPrompt": ["alt+c", "ctrl+shift+c"] +} +`, + ); + + try { + const manager = KeybindingsManager.create(agentDir); + const writtenConfig = YAML.parse(await Bun.file(ymlPath).text()); + + expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]); + expect(manager.getKeys("tui.select.confirm")).toEqual(["enter"]); + expect(manager.getKeys("tui.editor.cursorUp")).toEqual(["ctrl+p"]); + expect(manager.getKeys("app.clipboard.copyPrompt")).toEqual(["alt+c", "ctrl+shift+c"]); + expect(writtenConfig).toEqual({ + "app.clipboard.copyPrompt": ["alt+c", "ctrl+shift+c"], + "app.session.fork": "ctrl+f", + "tui.editor.cursorUp": "ctrl+p", + "tui.select.confirm": "enter", + }); + expect(await Bun.file(jsonPath).exists()).toBe(true); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("loads keybindings.yml directly", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-")); const configPath = path.join(agentDir, "keybindings.yml"); diff --git a/packages/coding-agent/test/main-interactive-input.test.ts b/packages/coding-agent/test/main-interactive-input.test.ts index 7d16608f1..ba7e6adcc 100644 --- a/packages/coding-agent/test/main-interactive-input.test.ts +++ b/packages/coding-agent/test/main-interactive-input.test.ts @@ -2,8 +2,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main"; +import { applyResolvedSystemPromptInputs, submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main"; import type { SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { CreateAgentSessionOptions } from "@oh-my-pi/pi-coding-agent/sdk"; import { discoverTitleSystemPromptFile } from "@oh-my-pi/pi-coding-agent/system-prompt"; const cleanupDirs: string[] = []; @@ -35,6 +36,18 @@ describe("discoverTitleSystemPromptFile", () => { }); }); +describe("applyResolvedSystemPromptInputs", () => { + it("routes SYSTEM.md content through template-aware session options", () => { + const options: CreateAgentSessionOptions = {}; + + applyResolvedSystemPromptInputs(options, "project system prompt", "append prompt"); + + expect(options.customSystemPrompt).toBe("project system prompt"); + expect(options.appendSystemPrompt).toBe("append prompt"); + expect(options.systemPrompt).toBeUndefined(); + }); +}); + describe("submitInteractiveInput", () => { it("routes already-started synthetic continue submissions to a hidden developer prompt", async () => { const mode = { diff --git a/packages/coding-agent/test/markit-converters.test.ts b/packages/coding-agent/test/markit-converters.test.ts new file mode 100644 index 000000000..e1ec31717 --- /dev/null +++ b/packages/coding-agent/test/markit-converters.test.ts @@ -0,0 +1,160 @@ +/** + * Runtime coverage for the in-house markit document engine (src/markit), which + * replaced the `markit-ai` package. Each format is generated in-memory via the + * shared zip util (src/utils/zip) — no external fixtures — and converted through + * the public wrapper (src/utils/markit), locking: docx text, xlsx tables, pptx + * slides, epub metadata+spine, shared HTML-table normalization, image + * extraction, the nested/relative zip path resolution regression surface, and + * the unsupported-format error contract. + */ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { convertBufferWithMarkit, convertFileWithMarkit } from "@oh-my-pi/pi-coding-agent/utils/markit"; +import { zip } from "@oh-my-pi/pi-coding-agent/utils/zip"; + +const enc = (s: string): Uint8Array => new TextEncoder().encode(s); +const WML = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +function makeDocx(bodyXml: string): Uint8Array { + return zip({ + "[Content_Types].xml": enc( + ``, + ), + "_rels/.rels": enc( + ``, + ), + "word/document.xml": enc( + `${bodyXml}`, + ), + }); +} + +describe("markit converters", () => { + it("converts docx paragraphs to markdown", async () => { + const docx = makeDocx( + `First paragraph.Second paragraph.`, + ); + const result = await convertBufferWithMarkit(docx, ".docx"); + expect(result.ok).toBe(true); + expect(result.content).toBe("First paragraph.\n\nSecond paragraph."); + }); + + it("converts xlsx sheets to markdown tables", async () => { + const xlsx = zip({ + "xl/workbook.xml": enc( + ``, + ), + "xl/_rels/workbook.xml.rels": enc( + ``, + ), + "xl/worksheets/sheet1.xml": enc( + `NameAgeAlice30`, + ), + }); + const result = await convertBufferWithMarkit(xlsx, ".xlsx"); + expect(result.ok).toBe(true); + expect(result.content).toContain("## People"); + expect(result.content).toContain("| Name | Age |"); + expect(result.content).toContain("| --- | --- |"); + expect(result.content).toContain("| Alice | 30 |"); + }); + + it("reads an xlsx worksheet through an absolute (/-prefixed) rel target", async () => { + const xlsx = zip({ + "xl/workbook.xml": enc( + ``, + ), + "xl/_rels/workbook.xml.rels": enc( + ``, + ), + "xl/worksheets/sheet1.xml": enc( + `Header42`, + ), + }); + const result = await convertBufferWithMarkit(xlsx, ".xlsx"); + expect(result.ok).toBe(true); + expect(result.content).toContain("| Header |"); + expect(result.content).toContain("| 42 |"); + }); + + it("converts pptx slides with a title heading and body text", async () => { + const pptx = zip({ + "ppt/presentation.xml": enc( + ``, + ), + "ppt/_rels/presentation.xml.rels": enc( + ``, + ), + "ppt/slides/slide1.xml": enc( + `The TitleBody line`, + ), + }); + const result = await convertBufferWithMarkit(pptx, ".pptx"); + expect(result.ok).toBe(true); + expect(result.content).toContain("# The Title"); + expect(result.content).toContain("Body line"); + }); + + it("extracts a pptx image through a ../media relative rel target into imageDir", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "markit-pptx-")); + try { + const pptx = zip({ + "ppt/presentation.xml": enc( + ``, + ), + "ppt/_rels/presentation.xml.rels": enc( + ``, + ), + "ppt/slides/slide1.xml": enc( + ``, + ), + "ppt/slides/_rels/slide1.xml.rels": enc( + ``, + ), + "ppt/media/image1.png": new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + }); + const pptxPath = path.join(dir, "deck.pptx"); + const imageDir = path.join(dir, "imgs"); + await Bun.write(pptxPath, pptx); + const result = await convertFileWithMarkit(pptxPath, undefined, { imageDir }); + expect(result.ok).toBe(true); + const written = await fs.readdir(imageDir); + expect(written).toHaveLength(1); + expect(result.content).toContain(`](${path.join(imageDir, written[0]!)})`); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("converts epub spine, normalizes HTML tables, and resolves a non-root OPF basePath", async () => { + const epub = zip({ + "META-INF/container.xml": enc( + ``, + ), + "OEBPS/content.opf": enc( + `Nested BookAda`, + ), + "OEBPS/text/ch1.xhtml": enc( + `

Chapter One

Body text.

AB
12
`, + ), + }); + const result = await convertBufferWithMarkit(epub, ".epub"); + expect(result.ok).toBe(true); + expect(result.content).toContain("**Title:** Nested Book"); + expect(result.content).toContain("**Authors:** Ada"); + expect(result.content).toContain("## Chapter One"); + expect(result.content).toContain("Body text."); + // normalizeTablesHtml promotes the first row to a header so GFM renders a table. + expect(result.content).toContain("| A | B |"); + expect(result.content).toContain("| --- | --- |"); + }); + + it("reports an unsupported format instead of emitting garbage", async () => { + const rtf = enc("{\\rtf1\\ansi binary-ish}"); + const result = await convertBufferWithMarkit(rtf, ".rtf"); + expect(result.ok).toBe(false); + expect(result.error).toContain("Unsupported format"); + }); +}); diff --git a/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts b/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts new file mode 100644 index 000000000..eecff1787 --- /dev/null +++ b/packages/coding-agent/test/mcp-dispose-disconnect-bounded.test.ts @@ -0,0 +1,84 @@ +/** + * Regression guard for the dispose-path MCP teardown (PR #2839). + * + * `AgentSession.dispose()` disconnects the MCP manager it OWNS so stdio + * subprocesses are reaped instead of orphaned. That disconnect MUST be + * BOUNDED: an owned manager may hold an HTTP/SSE server whose + * session-termination DELETE blocks up to the MCP request timeout (30s + * default, unbounded when `OMP_MCP_TIMEOUT_MS=0`). `dispose()` wraps the + * disconnect in `withTimeout(...)`; this test proves that when the underlying + * `MCPManager.disconnectAll()` stalls on a stuck transport close, the bound + * returns promptly so `/exit` and print-mode shutdown are never gated on a + * broken remote endpoint. + * + * Shutdown-side analog of the startup bound defended by + * `mcp-startup-no-block.test.ts` (issue #2100): the same unbounded + * `Promise.allSettled` over MCP transports must never gate a lifecycle + * transition. Like that test, this is an integration test against a real MCP + * subprocess and the real `withTimeout` clock, so it asserts elapsed wall time + * rather than driving fake timers — fake timers cannot advance the subprocess + * transport and would neutralize the very bound under test. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { withTimeout } from "@oh-my-pi/pi-utils"; +import { MCPManager } from "../src/mcp/manager"; +import type { MCPStdioServerConfig } from "../src/mcp/types"; + +const FIXTURE_PATH = path.join(import.meta.dir, "fixtures", "instructions-mcp.ts"); +const BUN_EXEC = process.execPath; + +describe("owned-manager dispose disconnect is bounded (PR #2839)", () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mcp-dispose-")); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it("bounds the owned disconnect when a transport close stalls", async () => { + const manager = new MCPManager(workDir); + const config: MCPStdioServerConfig = { type: "stdio", command: BUN_EXEC, args: [FIXTURE_PATH] }; + const result = await manager.connectServers({ instr: config }, {}); + expect(result.errors.has("instr")).toBe(false); + expect(manager.getConnectedServers()).toContain("instr"); + + const connection = manager.getConnection("instr"); + if (!connection) throw new Error("expected a live connection to the fixture server"); + + // Stand in for an HTTP/SSE transport whose termination DELETE never + // returns. A controllable gate keeps cleanup deterministic — no + // forever-pending promise and no orphaned subprocess once the test ends. + const realClose = connection.transport.close.bind(connection.transport); + let releaseClose: () => void = () => {}; + const closeGate = new Promise(resolve => { + releaseClose = resolve; + }); + connection.transport.close = () => closeGate; + + try { + // `disconnectAll()` is exactly what `dispose()` invokes; with a stuck + // close it never settles on its own. The dispose bound (`withTimeout`) + // MUST reject within its deadline — a rejection here proves the + // disconnect did not settle in time and that shutdown is not blocked + // on the 30s request timeout. + const disconnect = manager.disconnectAll(); + const start = performance.now(); + await expect(withTimeout(disconnect, 250, "owned MCP disconnect timed out during dispose")).rejects.toThrow( + /timed out/i, + ); + expect(performance.now() - start).toBeLessThan(3_000); + + // Release the gate so the detached disconnect finishes cleanly. + releaseClose(); + await disconnect; + } finally { + await realClose(); + } + }, 20_000); +}); diff --git a/packages/coding-agent/test/model-registry-create.test.ts b/packages/coding-agent/test/model-registry-create.test.ts index 2f809a66a..0253a3efc 100644 --- a/packages/coding-agent/test/model-registry-create.test.ts +++ b/packages/coding-agent/test/model-registry-create.test.ts @@ -69,4 +69,23 @@ describe("ModelRegistry.create() factory (F6)", () => { const mtime2 = fs.statSync(yml).mtimeMs; expect(mtime2).toBe(mtime1); }); + + test("ConfigFile migrates legacy models.json containing comments", async () => { + const yml = path.join(tempDir.path(), "models.yml"); + const json = path.join(tempDir.path(), "models.json"); + await Bun.write( + json, + `{ + // Custom models comment + "providers": { + /* Block comment */ + } + }`, + ); + + const cf = new ConfigFile("models", ModelsConfigSchema, yml); + const result = cf.tryLoad(); + expect(result.status).toBe("ok"); + expect(fs.existsSync(yml)).toBe(true); + }); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 47e8df17a..bbc6006fc 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -6,6 +6,7 @@ import type { CanonicalModelVariant } from "@oh-my-pi/pi-coding-agent/config/mod import { type CanonicalModelRegistry, expandRoleAlias, + extractExplicitThinkingSelector, filterAvailableModelsByEnabledPatterns, parseModelPattern, parseModelString, @@ -111,6 +112,37 @@ const mockOpenRouterModels: Model[] = [ }), ]; +const mockMaxSuffixModels: Model[] = [ + buildModel({ + id: "coding-router", + name: "NanoGPT Coding Router", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: true, + thinking: { + mode: "effort", + efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + }, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), + buildModel({ + id: "coding-router:max", + name: "NanoGPT Coding Router Max", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), +]; + const mockProviderOverlapModels: Model<"anthropic-messages">[] = [ buildModel({ id: "kimi-k2.5", @@ -403,6 +435,21 @@ describe("parseModelPattern", () => { expect(result.warning).toBeUndefined(); } }); + test("max aliases the highest thinking level after the literal pattern misses", () => { + const result = parseModelPattern("gpt-5.3-codex:max", allModels); + expect(result.model?.id).toBe("gpt-5.3-codex"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + test("literal model ids ending in max win over the thinking alias", () => { + const result = parseModelPattern("nanogpt/coding-router:max", mockMaxSuffixModels); + expect(result.model?.id).toBe("coding-router:max"); + expect(result.thinkingLevel).toBeUndefined(); + expect(result.explicitThinkingLevel).toBe(false); + expect(result.warning).toBeUndefined(); + }); }); describe("patterns with invalid thinking levels", () => { @@ -484,6 +531,16 @@ describe("parseModelPattern", () => { expect(result.explicitThinkingLevel).toBe(true); expect(result.warning).toBeUndefined(); }); + + test("openrouter/:max applies xhigh through the exact-selector path, not an OpenRouter route", () => { + // `max` is a thinking alias, never an OpenRouter route suffix: the request must + // resolve the base model and carry xhigh, not clone a literal `z-ai/glm-4.7:max`. + const result = parseModelPattern("openrouter/z-ai/glm-4.7:max", allModels); + expect(result.model?.provider).toBe("openrouter"); + expect(result.model?.id).toBe("z-ai/glm-4.7"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + }); }); describe("invalid thinking levels with OpenRouter models", () => { @@ -571,6 +628,19 @@ describe("resolveModelRoleValue", () => { expect(result.explicitThinkingLevel).toBe(true); }); + test("resolves pi/:max by expanding role alias before parsing thinking", () => { + const settings = { + getModelRole: (role: string) => (role === "smol" ? "openai-codex/gpt-5.3-codex" : undefined), + } as NonNullable[2]>["settings"]; + + const result = resolveModelRoleValue("pi/smol:max", allModels, { settings }); + + expect(result.model?.provider).toBe("openai-codex"); + expect(result.model?.id).toBe("gpt-5.3-codex"); + expect(result.thinkingLevel).toBe(Effort.XHigh); + expect(result.explicitThinkingLevel).toBe(true); + }); + test("resolves pi/default through configured default role alias", () => { const settings = { getModelRole: (role: string) => (role === "default" ? "openrouter/qwen/qwen3-coder:exacto" : undefined), @@ -883,6 +953,51 @@ describe("resolveCliModel", () => { expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro"); }); + test("accepts Bedrock inference profile ARNs and preserves thinking suffixes", () => { + const defaultBedrockModel = buildModel({ + id: "us.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, + }); + const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48"; + + const baseResult = resolveCliModel({ + cliProvider: "amazon-bedrock", + cliModel: profileArn, + modelRegistry: { + getAll: () => [defaultBedrockModel], + }, + }); + const offResult = resolveCliModel({ + cliProvider: "amazon-bedrock", + cliModel: `${profileArn}:off`, + modelRegistry: { + getAll: () => [defaultBedrockModel], + }, + }); + + expect(baseResult.error).toBeUndefined(); + expect(baseResult.model?.provider).toBe("amazon-bedrock"); + expect(baseResult.model?.api).toBe("bedrock-converse-stream"); + expect(baseResult.model?.id).toBe(profileArn); + expect(baseResult.model?.name).toBe("Bedrock inference profile"); + expect(baseResult.model?.reasoning).toBe(false); + expect(baseResult.model?.thinking).toBeUndefined(); + expect(baseResult.model?.contextWindow).toBeNull(); + expect(baseResult.model?.maxTokens).toBeNull(); + expect(baseResult.thinkingLevel).toBeUndefined(); + expect(offResult.error).toBeUndefined(); + expect(offResult.model?.id).toBe(profileArn); + expect(offResult.thinkingLevel).toBe("off"); + }); + test("returns a clear error when there are no models", () => { const registry = { getAll: () => [], @@ -981,6 +1096,49 @@ describe("resolveModelScope", () => { expect(scoped[0].model.provider).toBe("openai"); expect(scoped[0].model.id).toBe("gpt-5.5"); }); + + test("expands exact canonical ids with max thinking aliases", async () => { + const scoped = await resolveModelScope(["claude-sonnet-4-5:max"], { + getAvailable: () => canonicalVariantModels, + getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => + canonicalRegistry.getCanonicalVariants!(canonicalId, options), + } as unknown as Parameters[1]); + + expect(scoped).toHaveLength(2); + expect(scoped.map(entry => `${entry.model.provider}/${entry.model.id}`).sort()).toEqual([ + "anthropic/claude-sonnet-4-5", + "github-copilot/anthropic/claude-sonnet-4.5", + ]); + expect(scoped.map(entry => entry.thinkingLevel)).toEqual([Effort.High, Effort.High]); + expect(scoped.every(entry => entry.explicitThinkingLevel)).toBe(true); + }); + + test("applies max thinking aliases to glob scopes when no literal max ids match", async () => { + const registry = { + getAvailable: () => mockCodexOverlapModels, + getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [], + }; + + const scoped = await resolveModelScope(["openai-codex/*:max"], registry); + + expect(scoped).toHaveLength(2); + expect(scoped.map(entry => entry.thinkingLevel)).toEqual([Effort.XHigh, Effort.XHigh]); + expect(scoped.every(entry => entry.explicitThinkingLevel)).toBe(true); + }); + + test("preserves literal :max in scoped-model globs", async () => { + const registry = { + getAvailable: () => mockMaxSuffixModels, + getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [], + }; + + const scoped = await resolveModelScope(["nanogpt/*:max"], registry); + + expect(scoped).toHaveLength(1); + expect(scoped[0].model.id).toBe("coding-router:max"); + expect(scoped[0].thinkingLevel).toBeUndefined(); + expect(scoped[0].explicitThinkingLevel).toBe(false); + }); }); describe("parseModelString", () => { @@ -1029,6 +1187,23 @@ describe("parseModelString", () => { }); }); + test("extracts max when explicitly enabled for provider id selectors", () => { + const result = parseModelString("deepseek/deepseek-v4-pro:max", { allowMaxAlias: true }); + expect(result).toEqual({ provider: "deepseek", id: "deepseek-v4-pro", thinkingLevel: Effort.XHigh }); + }); + + test("preserves literal max model ids when the caller can prove they exist", () => { + const result = parseModelString("nanogpt/coding-router:max", { + allowMaxAlias: true, + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toEqual({ provider: "nanogpt", id: "coding-router:max" }); + }); + + test("does not strip inherited object keys as thinking suffixes", () => { + const result = parseModelString("anthropic/claude-sonnet-4-5:constructor"); + expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5:constructor" }); + }); test("does not extract thinking level from model ID with invalid suffix", () => { const result = parseModelString("openrouter/openai/gpt-4o:extended"); // :extended is not a valid thinking level, so it stays as part of the ID @@ -1043,6 +1218,20 @@ describe("parseModelString", () => { }); }); +describe("resolveModelFromString", () => { + test("applies max as a provider model selector alias after literal lookup misses", () => { + const result = resolveModelFromString("nanogpt/coding-router:max", [mockMaxSuffixModels[0]]); + expect(result?.provider).toBe("nanogpt"); + expect(result?.id).toBe("coding-router"); + }); + + test("preserves literal max provider model ids before alias parsing", () => { + const result = resolveModelFromString("nanogpt/coding-router:max", mockMaxSuffixModels); + expect(result?.provider).toBe("nanogpt"); + expect(result?.id).toBe("coding-router:max"); + }); +}); + describe("expandRoleAlias", () => { test("expands pi/vision to configured vision role", () => { const settings = Settings.isolated(); @@ -1059,6 +1248,31 @@ describe("expandRoleAlias", () => { }); }); +describe("extractExplicitThinkingSelector", () => { + test("does not carry max from literal role model ids", () => { + const result = extractExplicitThinkingSelector("nanogpt/coding-router:max", undefined, { + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toBeUndefined(); + }); + + test("treats max as an explicit selector when the model id is not literal", () => { + const result = extractExplicitThinkingSelector("nanogpt/coding-router:max", undefined, { + isLiteralModelId: () => false, + }); + expect(result).toBe(Effort.XHigh); + }); + + test("treats max on pi role aliases as an explicit selector before expansion", () => { + const settings = Settings.isolated(); + settings.setModelRole("smol", "nanogpt/coding-router:max"); + const result = extractExplicitThinkingSelector("pi/smol:max", settings, { + isLiteralModelId: (provider, id) => provider === "nanogpt" && id === "coding-router:max", + }); + expect(result).toBe(Effort.XHigh); + }); +}); + describe("provider routing selector (@upstream)", () => { const openRouterOnly = (model: Model | undefined): string[] | undefined => (model?.compat as { openRouterRouting?: { only?: string[] } } | undefined)?.openRouterRouting?.only; @@ -1210,6 +1424,22 @@ describe("filterAvailableModelsByEnabledPatterns", () => { expect(result[0].id).toBe("claude-sonnet-4-5"); }); + test("expands canonical enabledModels entries with max thinking aliases", () => { + const registry = { + getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => + canonicalRegistry.getCanonicalVariants!(canonicalId, options), + }; + const result = filterAvailableModelsByEnabledPatterns( + canonicalVariantModels, + ["claude-sonnet-4-5:max"], + registry, + ); + expect(result.map(model => `${model.provider}/${model.id}`).sort()).toEqual([ + "anthropic/claude-sonnet-4-5", + "github-copilot/anthropic/claude-sonnet-4.5", + ]); + }); + test("strips :thinkingLevel suffix before matching", () => { const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/claude-sonnet-4-5:high"], registry); expect(result).toHaveLength(1); @@ -1240,6 +1470,11 @@ describe("filterAvailableModelsByEnabledPatterns", () => { expect(result).toHaveLength(1); expect(result[0].provider).toBe("anthropic"); }); + test("preserves literal :max in enabledModels globs", () => { + const result = filterAvailableModelsByEnabledPatterns(mockMaxSuffixModels, ["nanogpt/*:max"], registry); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("coding-router:max"); + }); test("evaluates glob patterns against bare model id", () => { const result = filterAvailableModelsByEnabledPatterns(models, ["claude-*"], registry); diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 2732220b9..95e65d7f5 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -67,7 +67,8 @@ describe("AssistantMessageComponent mermaid markdown", () => { }); it("aligns box borders for CJK labels in display columns", () => { - // Defends the beautiful-mermaid patchedDependencies entry: Hangul is 2 + // Defends the first-party vendored Mermaid ASCII renderer's CJK/East-Asian + // display-width handling (packages/utils/src/vendor/mermaid-ascii): Hangul is 2 // terminal columns wide, so every row of a single-node diagram must // measure the same display width or the right border drifts. const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```"); @@ -97,6 +98,90 @@ describe("AssistantMessageComponent mermaid markdown", () => { }); }); +describe("AssistantMessageComponent reflowing-markdown commit stability", () => { + // A streaming reply is built empty then fed via updateContent (the live path); + // passing a message to the constructor would mark it finalized. + it("is commit-unstable while a streaming reply still carries a mermaid fence", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("Here is the flow:\n\n```mermaid\nflowchart TD\n A-->B")); + expect(component.isTranscriptBlockCommitStable()).toBe(false); + }); + + it("becomes commit-stable once the mermaid reply finalizes", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("```mermaid\nflowchart TD\n A-->B\n```")); + expect(component.isTranscriptBlockCommitStable()).toBe(false); + component.markTranscriptBlockFinalized(); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("stays commit-stable for a streaming reply without a mermaid fence", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("A long normal reply.\n\n- one\n- two\n\nMore prose follows.")); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("does not trip on prose that mentions a mermaid fence inline", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("Wrap the diagram in a ```mermaid block to render it.")); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("is commit-unstable for intro prose followed by a streaming mermaid tail", () => { + const component = new AssistantMessageComponent(); + component.updateContent( + createAssistantMessage("Intro prose above the diagram.\n\n```mermaid\nflowchart TD\n A-->B"), + ); + expect(component.isTranscriptBlockCommitStable()).toBe(false); + }); + + it("is commit-unstable while a streaming reply still renders a GFM table", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("Results:\n\n| Name | Score |\n| --- | --- |\n| a | 1 |")); + expect(component.isTranscriptBlockCommitStable()).toBe(false); + }); + + it("becomes commit-stable once a table reply finalizes", () => { + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("| Name | Score |\n| --- | --- |\n| a | 1 |\n| b | 2 |")); + expect(component.isTranscriptBlockCommitStable()).toBe(false); + component.markTranscriptBlockFinalized(); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("stays commit-stable for pipe-heavy prose with no table delimiter row", () => { + const component = new AssistantMessageComponent(); + component.updateContent( + createAssistantMessage("Weigh cost | benefit | risk before deciding, and note `a || b` short-circuits."), + ); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("stays commit-stable for a streaming table header before its delimiter row arrives", () => { + // Header alone is just a paragraph with pipes — Markdown lays out no table, + // and nothing re-flows, until the delimiter row streams in. + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("| Name | Score |")); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("stays commit-stable for a fenced code block containing a table delimiter", () => { + // A shell snippet with pipes and dashes inside a code fence is literal text, + // not a reflowing table, so a long code-heavy reply still commits normally. + const component = new AssistantMessageComponent(); + component.updateContent(createAssistantMessage("Run it:\n\n```sh\necho '| --- | --- |'\ncat data | sort\n```")); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); + + it("stays commit-stable for a mermaid fence shown as example content inside a code block", () => { + const component = new AssistantMessageComponent(); + component.updateContent( + createAssistantMessage("Example:\n\n````md\n```mermaid\nflowchart TD\n A-->B\n```\n````"), + ); + expect(component.isTranscriptBlockCommitStable()).toBe(true); + }); +}); + describe("AssistantMessageComponent thinking renderers", () => { it("renders all extension outputs below visible thinking blocks in registration order", () => { const contexts: Array<{ contentIndex: number; thinkingIndex: number; text: string }> = []; diff --git a/packages/coding-agent/test/modes/components/copy-selector.test.ts b/packages/coding-agent/test/modes/components/copy-selector.test.ts index 7b09f17be..83cf0e94f 100644 --- a/packages/coding-agent/test/modes/components/copy-selector.test.ts +++ b/packages/coding-agent/test/modes/components/copy-selector.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun: import { stripVTControlCharacters } from "node:util"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { CopySelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/copy-selector"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { CopyTarget } from "@oh-my-pi/pi-coding-agent/modes/utils/copy-targets"; import { setKeybindings } from "@oh-my-pi/pi-tui"; @@ -77,7 +77,7 @@ describe("CopySelectorComponent", () => { it("renders an outlined tree with code blocks nested under their message", () => { const out = render(new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel: vi.fn() })); - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); expect(out).toContain("Copy to clipboard"); // Messages and their nested blocks are all visible (always expanded), diff --git a/packages/coding-agent/test/modes/components/oauth-selector.test.ts b/packages/coding-agent/test/modes/components/oauth-selector.test.ts index edfd5677a..0b161d66a 100644 --- a/packages/coding-agent/test/modes/components/oauth-selector.test.ts +++ b/packages/coding-agent/test/modes/components/oauth-selector.test.ts @@ -1,5 +1,6 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { OAuthSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -102,4 +103,88 @@ describe("OAuthSelectorComponent", () => { component.handleInput("\n"); expect(selected).toEqual(["opencode-go"]); }); + + describe("disabledProviders", () => { + afterEach(() => { + resetSettingsForTest(); + }); + + it("hides disabled providers from the login list even when searched", async () => { + const providers = getOAuthProviders(); + const victim = + providers.find(provider => provider.available && provider.id === "vllm") ?? + providers.find(provider => provider.available) ?? + providers[0]; + expect(victim).toBeDefined(); + if (!victim) return; + + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: [victim.id] } }); + + const component = new OAuthSelectorComponent( + "login", + authStorage, + () => {}, + () => {}, + ); + for (const char of victim.id) { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).not.toContain(victim.name); + }); + + it("hides alias logins whose storeCredentialsAs target is disabled", async () => { + const alias = getOAuthProviders().find(provider => provider.storeCredentialsAs === "openai-codex"); + expect(alias).toBeDefined(); + if (!alias) return; + expect(alias.id).not.toBe("openai-codex"); + + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: ["openai-codex"] } }); + + const component = new OAuthSelectorComponent( + "login", + authStorage, + () => {}, + () => {}, + ); + for (const char of alias.id) { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).not.toContain(alias.name); + }); + + it("keeps disabled providers as logout targets", async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { disabledProviders: ["opencode-go"] } }); + + const selected: string[] = []; + const component = new OAuthSelectorComponent( + "logout", + { + has: (providerId: string) => providerId === "opencode-go", + hasAuth: (providerId: string) => providerId === "opencode-go", + getCredentialOrigin: (_providerId: string) => undefined, + } as unknown as AuthStorage, + providerId => selected.push(providerId), + () => {}, + ); + for (const char of "opencode-go") { + component.handleInput(char); + } + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain("OpenCode Go"); + }); + }); }); diff --git a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts index 609a17111..ef9841fd4 100644 --- a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts +++ b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts @@ -58,9 +58,9 @@ describe("PlanReviewOverlay", () => { for (const option of APPROVAL_OPTIONS) expect(out).toContain(option); expect(out).toContain("esc cancel"); // Outlined like the /copy overlay. - expect(out).toContain("┌"); + expect(out).toContain(theme.boxRound.topLeft); expect(out).toContain("│"); - expect(out).toContain("└"); + expect(out).toContain(theme.boxRound.bottomLeft); }); it("confirms the highlighted option on Enter", () => { diff --git a/packages/coding-agent/test/modes/components/settings-layout.test.ts b/packages/coding-agent/test/modes/components/settings-layout.test.ts index 1552953e6..f9fec2699 100644 --- a/packages/coding-agent/test/modes/components/settings-layout.test.ts +++ b/packages/coding-agent/test/modes/components/settings-layout.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SETTING_TABS, SETTINGS_SCHEMA, + type SettingPath, type SettingTab, TAB_GROUPS, } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; @@ -13,6 +15,15 @@ interface UiShape { } describe("settings layout", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + afterEach(() => { + resetSettingsForTest(); + }); + it("every UI setting declares a group registered in TAB_GROUPS for its tab", () => { const violations: string[] = []; for (const path in SETTINGS_SCHEMA) { @@ -48,4 +59,21 @@ describe("settings layout", () => { expect(grouped).toEqual(expected); } }); + + it("hides advisor dependent settings when advisor is disabled", () => { + const advisorDependentPaths: SettingPath[] = ["advisor.subagents", "advisor.syncBacklog", "advisor.immuneTurns"]; + const advisorDependentPathSet = new Set(advisorDependentPaths); + const defs = getSettingsForTab("model").filter(def => advisorDependentPathSet.has(def.path)); + + expect(defs.map(def => def.path)).toEqual(advisorDependentPaths); + for (const def of defs) { + expect(def.condition?.()).toBe(false); + } + + Settings.instance.set("advisor.enabled", true); + + for (const def of defs) { + expect(def.condition?.()).toBe(true); + } + }); }); diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 2b3734869..021bddbc3 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -329,6 +329,63 @@ describe("TranscriptContainer", () => { // blockStart 2 + (7 rows - TAIL_VOLATILITY_ROWS holdback of 4) = 5. expect(container.getNativeScrollbackCommitSafeEnd()).toBe(5); }); + + it("withholds the durable snapshot commit for a streaming mermaid reply, then promotes it on finalize", () => { + // A fenced mermaid diagram re-lays-out its whole body every frame as the + // reply streams. If its scrolled-off rows were committed to immutable + // native scrollback (the durable-snapshot path) the later re-layout would + // strand a stale diagram fragment in history that only Ctrl+L clears, so + // the live mermaid reply must advertise no durable snapshot end. + const container = new TranscriptContainer(); + container.addChild(new StreamingBlock(["earlier turn"], true)); + const assistant = new AssistantMessageComponent(); + assistant.updateContent( + makeAssistantMessage({ content: [{ type: "text", text: "```mermaid\nflowchart TD\n A-->B\n```" }] }), + ); + container.addChild(assistant); + + for (let frame = 0; frame < 4; frame++) container.render(60); + expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined(); + + // Finalized: the final layout is permanent and commits like any block. + assistant.markTranscriptBlockFinalized(); + container.render(60); + expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0); + }); + + it("withholds the durable snapshot commit for a streaming GFM table, then promotes it on finalize", () => { + // A streaming table re-aligns its columns as rows arrive; its scrolled-off + // rows must not commit an intermediate alignment to immutable scrollback. + const container = new TranscriptContainer(); + container.addChild(new StreamingBlock(["earlier turn"], true)); + const assistant = new AssistantMessageComponent(); + assistant.updateContent( + makeAssistantMessage({ content: [{ type: "text", text: "| Name | Score |\n| --- | --- |\n| a | 1 |" }] }), + ); + container.addChild(assistant); + + for (let frame = 0; frame < 4; frame++) container.render(60); + expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined(); + + assistant.markTranscriptBlockFinalized(); + container.render(60); + expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0); + }); + + it("still commits the durable snapshot of a streaming reply without a mermaid fence", () => { + // Guard the narrow scope: an ordinary streaming reply stays commit-stable, + // so its settled rows still reach native scrollback while it streams. + const container = new TranscriptContainer(); + container.addChild(new StreamingBlock(["earlier turn"], true)); + const assistant = new AssistantMessageComponent(); + assistant.updateContent( + makeAssistantMessage({ content: [{ type: "text", text: "A normal streamed answer with **bold** text." }] }), + ); + container.addChild(assistant); + + for (let frame = 0; frame < 4; frame++) container.render(60); + expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0); + }); it("does not re-render finalized rows already committed to native scrollback", () => { const container = new TranscriptContainer(); const committed = new CountingFinalizedBlock(["committed"]); diff --git a/packages/coding-agent/test/modes/components/welcome.test.ts b/packages/coding-agent/test/modes/components/welcome.test.ts index a910a6f0f..901c184e2 100644 --- a/packages/coding-agent/test/modes/components/welcome.test.ts +++ b/packages/coding-agent/test/modes/components/welcome.test.ts @@ -35,4 +35,31 @@ describe("WelcomeComponent tips", () => { expect(welcomeRegular.tip).not.toBe("Please use nerdfont 😭."); expect(welcomeRegular.tip).toBeDefined(); }); + + it("weights [NEW] tips above ordinary tips in selection", () => { + // Skip the nerdfont gate so the only Math.random() call is the weighted pick. + vi.spyOn(theme, "getSymbolPreset").mockReturnValue("nerd"); + let r = 0; + vi.spyOn(Math, "random").mockImplementation(() => r); + + const counts = new Map(); + const samples = 10_000; + for (let i = 0; i < samples; i++) { + r = (i + 0.5) / samples; // sweep the selection domain uniformly + const tip = new WelcomeComponent("1.0.0", "model", "provider").tip; + if (tip) counts.set(tip, (counts.get(tip) ?? 0) + 1); + } + + let newMax = 0; + let ordinaryMax = 0; + for (const [tip, count] of counts) { + if (/\[NEW\]\s*$/.test(tip)) newMax = Math.max(newMax, count); + else ordinaryMax = Math.max(ordinaryMax, count); + } + + // A "[NEW]" tip carries a >1 weight, so it covers strictly more of the + // uniform selection domain than any single ordinary tip. + expect(newMax).toBeGreaterThan(0); + expect(newMax).toBeGreaterThan(ordinaryMax); + }); }); diff --git a/packages/coding-agent/test/read-summary.test.ts b/packages/coding-agent/test/read-summary.test.ts index c0c53ded5..0539b3fd9 100644 --- a/packages/coding-agent/test/read-summary.test.ts +++ b/packages/coding-agent/test/read-summary.test.ts @@ -72,8 +72,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-ts", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function alpha(value: string): string { .. }"); - expect(text).toContain("export function beta(): number { .. }"); + expect(text).toContain("export function alpha(value: string): string { … }"); + expect(text).toContain("export function beta(): number { … }"); expect(text).not.toContain("const clean = value.trim()"); expect(result.details?.summary?.elidedSpans).toBe(2); }); @@ -124,7 +124,7 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("const clean = 'alpha';"); - expect(text).not.toContain("..."); + expect(text).not.toContain("…"); expect(result.details?.summary).toBeUndefined(); }); @@ -197,10 +197,10 @@ describe("read summary", () => { expect(text).toContain("name: Ada"); }); - it("renders brace-pair elisions as a single numbered line with `..`", async () => { + it("renders brace-pair elisions as a single numbered line with `…`", async () => { // Regression for the read-tool format request: collapse the head / // elided / closing-brace sandwich into one numbered line of the form - // `START-END:head { .. }` instead of three separate lines. + // `START-END:head { … }` instead of three separate lines. const fixture = path.join(tmpDir, "merge.ts"); await fs.writeFile( fixture, @@ -211,9 +211,9 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { .. }"); - // The plain `...` ellipsis line must NOT appear once the merge fires. - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export function stripNewLinePrefixes(lines: string[]): string[] { … }"); + // The plain `…` ellipsis line must NOT appear once the merge fires. + expect(text).not.toContain("\n…\n"); // The merged line must use the numbered range shape. expect(text).toMatch(/\b1-7:export function stripNewLinePrefixes/); expect(result.details?.summary?.elidedSpans).toBe(1); @@ -229,8 +229,8 @@ describe("read summary", () => { const result = await tool.execute("read-summary-merge-trailing", { path: fixture }); const text = textOutput(result); - expect(text).toContain("export const config = { .. };"); - expect(text).not.toContain("\n...\n"); + expect(text).toContain("export const config = { … };"); + expect(text).not.toContain("\n…\n"); }); it("does not merge when the closing line is not a bare brace", async () => { @@ -248,15 +248,15 @@ describe("read summary", () => { const text = textOutput(result); expect(text).toContain("def greet(name: str) -> str:"); - // Python's body elision keeps first/last body lines, so plain `...` + // Python's body elision keeps first/last body lines, so plain `…` // must remain as the elided segment. - expect(text).toContain("\n...\n"); - expect(text).not.toContain(" .. "); + expect(text).toContain("\n…\n"); + expect(text).not.toContain(" … "); }); it("appends an elision footer that names targeted recovery ranges", async () => { // Regression for issue #1046: summarized reads must tell the model how - // to recover the elided body so it does not stall on `...` / `{ .. }` + // to recover the elided body so it does not stall on `…` / `{ … }` // markers and burn a turn guessing the selector. const fixture = path.join(tmpDir, "footer.ts"); await fs.writeFile( @@ -270,7 +270,7 @@ describe("read summary", () => { expect(result.details?.summary?.elidedSpans).toBe(2); expect(result.details?.summary?.elidedLines).toBeGreaterThan(0); - expect(text).toContain("lines elided"); + expect(text).toContain("ln elided"); expect(text).toContain(`${fixture}:1-5,7-11`); expect(text).not.toContain(`${fixture}:raw`); expect(text).not.toContain(`${fixture}:1-9999`); diff --git a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts index d60856fbe..654125252 100644 --- a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts @@ -151,4 +151,62 @@ describe("createAgentSession deferred MCP auto discovery", () => { expect(session.getActiveToolNames()).not.toContain("search_tool_bm25"); expect(session.isMCPDiscoveryEnabled()).toBe(false); }, 40_000); + + it("disconnects the owned MCP manager when a top-level session disposes", async () => { + writeMcpConfig(); + const { session, mcpManager } = await createAgentSession({ + ...baseOptions(), + toolNames: ["read", "edit", "bash"], + }); + expect(mcpManager).toBeDefined(); + if (!mcpManager) throw new Error("expected owning session to create an MCPManager"); + try { + // Let the deferred connect FINISH first, so a later disconnectAll can + // only originate from dispose() itself — not the mid-connect disposal + // path (covered by the test above). Genuine integration wait: discovery + // connects a real subprocess fire-and-forget with no awaitable signal, + // and fake timers cannot drive a child process; poll the live session + // with a generous ceiling, exiting the instant discovery flips on. + const deadline = Date.now() + 30_000; + while (!session.isMCPDiscoveryEnabled() && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(session.isMCPDiscoveryEnabled()).toBe(true); + expect(mcpManager.getConnectedServers()).toContain("many"); + + const disconnectSpy = spyOn(mcpManager, "disconnectAll"); + await session.dispose(); + expect(disconnectSpy).toHaveBeenCalled(); + } finally { + // dispose() already tore it down; this is idempotent belt-and-braces. + await mcpManager.disconnectAll(); + } + }, 40_000); + + it("does not disconnect a reused parent MCP manager when a child session disposes", async () => { + writeMcpConfig(); + const parent = await createAgentSession({ + ...baseOptions(), + toolNames: ["read", "edit", "bash"], + }); + expect(parent.mcpManager).toBeDefined(); + if (!parent.mcpManager) throw new Error("expected parent session to create an MCPManager"); + const parentManager = parent.mcpManager; + try { + // A subagent-style session reuses the parent's manager via + // `mcpManager` and therefore does NOT own it. + const child = await createAgentSession({ + ...baseOptions(), + hasUI: false, + toolNames: ["read"], + mcpManager: parentManager, + }); + const disconnectSpy = spyOn(parentManager, "disconnectAll"); + await child.session.dispose(); + expect(disconnectSpy).not.toHaveBeenCalled(); + } finally { + await parent.session.dispose(); + await parentManager.disconnectAll(); + } + }, 40_000); }); diff --git a/packages/coding-agent/test/sdk-mcp-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-discovery.test.ts index 9e90dbedf..e904768ae 100644 --- a/packages/coding-agent/test/sdk-mcp-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-discovery.test.ts @@ -177,60 +177,6 @@ describe("createAgentSession MCP discovery prompt gating", () => { }); expect(session.getActiveToolNames()).toContain("task"); - const prompt = session.systemPrompt.join("\n"); - expect(prompt).toContain("# Eager Tasks"); - // `preferred` renders the soft delegation nudge, not the hard MUST/ONLY wording. - expect(prompt).toContain("Delegation is preferred here"); - expect(prompt).toContain("batch them into one parallel"); - expect(prompt).not.toContain("you MUST fan the work out"); - await session.dispose(); - }); - - it("uses hard delegation wording in the Eager Tasks section when task.eager is always", async () => { - const { session } = await createAgentSession({ - cwd: tempDir, - agentDir: tempDir, - modelRegistry, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "tools.discoveryMode": "all", "task.eager": "always" }), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - }); - - const prompt = session.systemPrompt.join("\n"); - expect(prompt).toContain("# Eager Tasks"); - expect(prompt).toContain("you MUST fan the work out"); - expect(prompt).toContain("Batch independent slices"); - expect(prompt).not.toContain("Delegation is preferred here"); - await session.dispose(); - }); - - it("omits batch guidance from the Eager Tasks section when task.batch is disabled", async () => { - const { session } = await createAgentSession({ - cwd: tempDir, - agentDir: tempDir, - modelRegistry, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "tools.discoveryMode": "all", "task.eager": "preferred", "task.batch": false }), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - }); - - const prompt = session.systemPrompt.join("\n"); - expect(prompt).toContain("# Eager Tasks"); - expect(prompt).not.toContain("batch them into one parallel"); await session.dispose(); }); @@ -252,7 +198,6 @@ describe("createAgentSession MCP discovery prompt gating", () => { }); expect(session.getActiveToolNames()).not.toContain("task"); - expect(session.systemPrompt.join("\n")).not.toContain("# Eager Tasks"); await session.dispose(); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 6404ea206..af792bacb 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -119,6 +120,19 @@ describe("createAgentSession deferred model pattern resolution", () => { expect(session.thinkingLevel).toBe("off"); }); + test("normalizes max default thinking level from settings", async () => { + const settings = Settings.isolated({ defaultThinkingLevel: "max" }); + + const { session } = await createAgentSession({ + ...(await buildSessionOptions("runtime-provider/runtime-reasoning-model")), + settings, + }); + + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + expect(session.thinkingLevel).toBe(Effort.XHigh); + }); + test("selects the settings default model without synchronously validating auth", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) { @@ -322,7 +336,7 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); - test("restores role model from extension provider after startup resume", async () => { + test("restores role model max selector from extension provider after startup resume", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) { throw new Error("Expected bundled anthropic default model"); @@ -351,7 +365,7 @@ describe("createAgentSession deferred model pattern resolution", () => { id: "smol-model", parentId: "default-model", timestamp, - model: "runtime-provider/runtime-model", + model: "runtime-provider/runtime-reasoning-model:max", role: "smol", }, ] @@ -380,7 +394,8 @@ describe("createAgentSession deferred model pattern resolution", () => { try { expect(session.model?.provider).toBe("runtime-provider"); - expect(session.model?.id).toBe("runtime-model"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + expect(session.thinkingLevel).toBe(Effort.XHigh); } finally { await session.dispose(); authStorage.close(); diff --git a/packages/coding-agent/test/sdk-session-isolation.test.ts b/packages/coding-agent/test/sdk-session-isolation.test.ts index af6255377..df1d3e63e 100644 --- a/packages/coding-agent/test/sdk-session-isolation.test.ts +++ b/packages/coding-agent/test/sdk-session-isolation.test.ts @@ -147,9 +147,8 @@ describe("createAgentSession session storage isolation", () => { await session.dispose(); } }); - it("shows redaction guidance only when secrets are actually loaded", async () => { + it("loads obfuscator only when secrets exist", async () => { await withClearedSecretEnv(async () => { - const redactionGuidance = "redacted as `#XXXX#` tokens"; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-secrets-${Snowflake.next()}-`)); tempDirs.push(tempDir); const cwd = path.join(tempDir, "project"); @@ -172,7 +171,7 @@ describe("createAgentSession session storage isolation", () => { const withoutSecrets = await createAgentSession(commonOptions); try { - expect(withoutSecrets.session.systemPrompt.join("\n")).not.toContain(redactionGuidance); + expect(withoutSecrets.session.obfuscator?.hasSecrets()).toBeFalsy(); } finally { await withoutSecrets.session.dispose(); } @@ -182,7 +181,7 @@ describe("createAgentSession session storage isolation", () => { const withSecrets = await createAgentSession(commonOptions); try { - expect(withSecrets.session.systemPrompt.join("\n")).toContain(redactionGuidance); + expect(withSecrets.session.obfuscator?.hasSecrets()).toBe(true); } finally { await withSecrets.session.dispose(); } diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 94df47c88..89a8d7c19 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -153,3 +153,48 @@ describe("SecretObfuscator regex behavior", () => { expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages); }); }); + +describe("SecretObfuscator cross-turn cache stability", () => { + // The provider prompt cache is content-addressed: convertToLlm / transformProviderContext + // re-run obfuscation over the WHOLE message array every turn, so a non-deterministic + // placeholder for the same secret would rewrite already-sent prefix bytes and bust the + // cache (cacheWrite @ $6.25/M vs cacheRead @ $0.50/M on opus). These tests pin the + // determinism that makes obfuscation cache-safe so a future change cannot silently + // reintroduce per-turn cache invalidation. + it("produces byte-identical output when re-obfuscating the same content across turns", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([ + { type: "plain", content: secret }, + { type: "regex", content: "tok_[a-z0-9]+" }, + ]); + const messages: Message[] = [{ role: "user", content: `use ${secret} and tok_abc123`, timestamp: 1 }]; + + const turn1 = JSON.stringify(obfuscateMessages(obfuscator, messages)); + const turn2 = JSON.stringify(obfuscateMessages(obfuscator, messages)); + + expect(turn1).not.toContain(secret); + expect(turn1).not.toContain("tok_abc123"); + // Identical bytes on the second pass → the cached prefix stays valid. + expect(turn2).toEqual(turn1); + }); + + it("keeps earlier message placeholders stable when a later message reveals a new regex secret", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "tok_[a-z0-9]+" }]); + const early: Message[] = [{ role: "user", content: "first uses tok_aaa", timestamp: 1 }]; + + // Turn N: only the early message exists; tok_aaa mints a fresh placeholder. + const earlyTurnN = JSON.stringify(obfuscateMessages(obfuscator, early)); + expect(earlyTurnN).not.toContain("tok_aaa"); + + // A later turn reveals a brand-new secret. Lazy regex discovery assigns it a fresh + // index — this MUST NOT shift the placeholder already minted for tok_aaa. + const later: Message[] = [{ role: "user", content: "later uses tok_bbb", timestamp: 2 }]; + const laterOut = JSON.stringify(obfuscateMessages(obfuscator, later)); + expect(laterOut).not.toContain("tok_bbb"); + + // Re-obfuscate the early message after the new discovery: identical bytes → the + // already-cached prefix for the early message stays valid. + const earlyTurnNPlus1 = JSON.stringify(obfuscateMessages(obfuscator, early)); + expect(earlyTurnNPlus1).toEqual(earlyTurnN); + }); +}); diff --git a/packages/coding-agent/test/session-manager-cwd-adoption.test.ts b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts index 06f3c3a37..61fc16415 100644 --- a/packages/coding-agent/test/session-manager-cwd-adoption.test.ts +++ b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -86,4 +87,38 @@ describe("SessionManager cwd adoption on resume", () => { expect(manager.getCwd()).toBe(path.resolve(projectA)); expect(manager.getSessionDir()).toBe(path.resolve(sessionsA)); }); + + it("keeps the current cwd when the resumed session's project directory is gone", async () => { + const launch = makeTempDir("@pi-cwd-launch-"); + const store = makeTempDir("@pi-cwd-store-"); + const goneProject = makeTempDir("@pi-cwd-gone-"); + // The session file survives in `store` (like ~/.omp), but its header cwd + // points at a project directory that we then delete. + const file = await writeSession(goneProject, store); + await fs.rm(goneProject, { recursive: true, force: true }); + + const launchSessions = path.join(launch, "sessions"); + const manager = SessionManager.create(launch, launchSessions); + await manager.setSessionFile(file); + + // Adopting the missing cwd would make the follow-up `setProjectDir` chdir + // throw, so resume stays put instead. + expect(manager.getCwd()).toBe(path.resolve(launch)); + expect(manager.getSessionDir()).toBe(path.resolve(launchSessions)); + }); + + it("falls back to the launch cwd when opening a session whose project directory is gone", async () => { + const launch = makeTempDir("@pi-cwd-launch-"); + const store = makeTempDir("@pi-cwd-store-"); + const goneProject = makeTempDir("@pi-cwd-gone-"); + const file = await writeSession(goneProject, store); + await fs.rm(goneProject, { recursive: true, force: true }); + + const manager = await SessionManager.open(file, undefined, undefined, { initialCwd: launch }); + + expect(manager.getCwd()).toBe(path.resolve(launch)); + // /new and /branch anchor to the launch cwd, not the deleted project's store. + expect(manager.getSessionDir()).toBe(SessionManager.getDefaultSessionDir(launch)); + expect(manager.getSessionDir()).not.toBe(path.resolve(store)); + }); }); diff --git a/packages/coding-agent/test/session-messages.test.ts b/packages/coding-agent/test/session-messages.test.ts index 14bfde91f..5d79b6aaf 100644 --- a/packages/coding-agent/test/session-messages.test.ts +++ b/packages/coding-agent/test/session-messages.test.ts @@ -195,7 +195,7 @@ describe("wrapSteeringForModel", () => { expect(wrappedText).not.toContain("&"); }); - it("leaves buried steering messages unchanged", () => { + it("wraps buried steering messages too so wire bytes stay stable across turns", () => { const buried: AgentMessage = { role: "user", content: "old steer", @@ -207,8 +207,16 @@ describe("wrapSteeringForModel", () => { const wrapped = wrapSteeringForModel(messages); - expect(wrapped).toBe(messages); - expect(wrapped[0]).toBe(buried); + // Buried steer is rewritten (enveloped) just like a trailing one, so its wire + // bytes are identical whether it is the tail (turn N) or buried (turn N+1) — + // the cached prefix stays valid instead of busting on the turn after a steer. + expect(wrapped).not.toBe(messages); + expect(wrapped[0]).not.toBe(buried); + expect(getUserText(wrapped[0])).toContain(""); + expect(getUserText(wrapped[0])).toContain("\nold steer\n"); + // Non-steering trailing message is untouched, and the persisted steer is not mutated. + expect(wrapped[1]).toBe(later); + expect(buried.content).toBe("old steer"); }); it("leaves trailing user messages without the steering marker unchanged", () => { diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index e715c104c..eb11c8ea8 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "bun:test"; import type { Model, Usage } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import { type } from "arktype"; const ZERO_USAGE: Usage = { @@ -93,6 +94,39 @@ describe("formatSessionDumpText tool parameters", () => { expect(out).toContain(""); expect(out).toContain(''); }); + + it("omits the Available Tools section if inlineToolDescriptors is true", () => { + const out = formatSessionDumpText({ + messages: [], + inlineToolDescriptors: true, + tools: [ + { + name: "web_search", + description: "Searches the web.", + parameters: { type: "object" }, + }, + ], + }); + + expect(out).not.toContain("## Available Tools"); + }); + + it("does not falsely omit the Available Tools section even if systemPrompt contains tool headings", () => { + const out = formatSessionDumpText({ + messages: [], + systemPrompt: ["# Inventory\nThis is a rule discussing # Tool: web_search.\nNever call it directly."], + inlineToolDescriptors: false, + tools: [ + { + name: "web_search", + description: "Searches the web.", + parameters: { type: "object" }, + }, + ], + }); + + expect(out).toContain("## Available Tools"); + }); }); describe("formatSessionDumpText markdown-headings transcript", () => { @@ -109,7 +143,7 @@ describe("formatSessionDumpText markdown-headings transcript", () => { type: "toolCall", id: "c1", name: "read", - arguments: { _i: "Reading the file", path: "src/foo.ts" }, + arguments: { [INTENT_FIELD]: "Reading the file", path: "src/foo.ts" }, }, ], api: "mock", @@ -138,7 +172,7 @@ describe("formatSessionDumpText markdown-headings transcript", () => { expect(out).toContain("path: src/foo.ts"); // The `_i` intent renders as a `//` comment under the heading, never inside the YAML args. expect(out).toContain("// Reading the file"); - expect(out).not.toContain("_i:"); + expect(out).not.toContain(`${INTENT_FIELD}:`); // Tool calls render as a readable heading + YAML, never the / XML. expect(out).not.toContain(" { type: "toolCall", id: "tc-intent", name: "read", - arguments: { path: "src/config.ts", _i: "reading config file" }, + arguments: { path: "src/config.ts", [INTENT_FIELD]: "reading config file" }, }, { type: "toolCall", @@ -131,7 +132,8 @@ describe("formatSessionHistoryMarkdown", () => { name: "read", arguments: { path: "src/config.ts", - _i: "reading config file with a very very long and descriptive intent that will exceed the maximum length limit of eighty characters", + [INTENT_FIELD]: + "reading config file with a very very long and descriptive intent that will exceed the maximum length limit of eighty characters", }, }, ], diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index b7a1f7829..ebde01131 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -494,5 +494,102 @@ describe("Settings", () => { expect(fs.readFileSync(path.join(agentDir, "last-changelog-version"), "utf8")).toBe("0.41.0"); }); + + it("migrates from settings.json containing comments", async () => { + const jsonPath = path.join(agentDir, "settings.json"); + await fs.promises.writeFile( + jsonPath, + `{ + // This is a comment + "display": { + /* Multiline comment */ + "showTokenUsage": true + } + }`, + ); + + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("display.showTokenUsage")).toBe(true); + expect(fs.existsSync(jsonPath)).toBe(false); + expect(fs.existsSync(`${jsonPath}.bak`)).toBe(true); + }); + it("migrates legacy power booleans with system=true to system level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: true, + declareUserActive: false, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("system"); + }); + + it("migrates legacy power booleans with display=true to display level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: false, + declareUserActive: false, + preventDisplaySleep: true, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("display"); + }); + + it("migrates legacy power booleans with declareUserActive=true to system level", async () => { + await writeSettings({ + power: { + preventIdleSleep: true, + preventSystemSleep: false, + declareUserActive: true, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("system"); + }); + + it("preserves old idle default when only non-idle keys are set", async () => { + // Old default was preventIdleSleep=true; user only set display=false. + // Migration should yield "idle", not "off". + await writeSettings({ + power: { preventDisplaySleep: false }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("idle"); + }); + + it("migrates all-false power booleans to off", async () => { + await writeSettings({ + power: { + preventIdleSleep: false, + preventSystemSleep: false, + declareUserActive: false, + preventDisplaySleep: false, + }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("off"); + }); + + it("migrates flat-key power booleans to the enum", async () => { + await writeSettings({ + "power.preventIdleSleep": true, + "power.preventDisplaySleep": true, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("display"); + }); + + it("does not overwrite an explicit power.sleepPrevention", async () => { + await writeSettings({ + power: { sleepPrevention: "off", preventIdleSleep: true }, + }); + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("power.sleepPrevention")).toBe("off"); + }); }); }); diff --git a/packages/coding-agent/test/shake.test.ts b/packages/coding-agent/test/shake.test.ts index c9fa94b39..3ae2034aa 100644 --- a/packages/coding-agent/test/shake.test.ts +++ b/packages/coding-agent/test/shake.test.ts @@ -192,6 +192,52 @@ describe("AgentSession shake", () => { expect(end[0]).toMatchObject({ type: "auto_compaction_end", action: "shake" }); }); + it("has isCompacting true when the shake auto_compaction_start event fires", async () => { + // Defect 1 parity for the shake strategy: the controller backing isCompacting + // must be installed before auto_compaction_start is emitted, so a message + // typed as the loader appears is queued safely rather than mis-routed. + session.settings.set("compaction.strategy", "shake"); + session.settings.set("compaction.thresholdPercent", 1); + session.settings.set("contextPromotion.enabled", false); + + let capturedIsCompacting: boolean | undefined; + const { promise: shakeStarted, resolve: onShakeStarted } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_start" && event.action === "shake") { + capturedIsCompacting = session.isCompacting; + onShakeStarted(); + } + }); + + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 1, + blocksDropped: 0, + tokensFreed: 10_000, + }); + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "trigger" }], + ...apiInfo, + stopReason: "stop", + usage: { + input: 10_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 11_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); + await shakeStarted; + + expect(capturedIsCompacting).toBe(true); + }); + it("falls back to context-full when shake cannot drop context below the threshold (regression #2119)", async () => { session.settings.set("compaction.strategy", "shake"); session.settings.set("compaction.thresholdPercent", 1); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index e99a6ccf2..5e1bb0f9f 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -12,11 +12,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import type { ObservableSession } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; +import type { TUI } from "@oh-my-pi/pi-tui"; const SESSION_ID = "test-session-1"; @@ -36,7 +37,7 @@ function makeSubagentRegistry(sessions: ObservableSession[]) { } as unknown as import("@oh-my-pi/pi-coding-agent/modes/session-observer-registry").SessionObserverRegistry; } -function makeHub(sessionFile: string, observed: ObservableSession[]): AgentHubOverlayComponent { +function makeViewer(sessionFile: string, observed: ObservableSession[]): AgentTranscriptViewer { const agents = new AgentRegistry(); agents.register({ id: SESSION_ID, @@ -47,15 +48,19 @@ function makeHub(sessionFile: string, observed: ObservableSession[]): AgentHubOv sessionFile, status: "parked", }); - const hub = new AgentHubOverlayComponent({ - observers: makeSubagentRegistry(observed), - hubKeys: ["ctrl+s"], - onDone: () => {}, - requestRender: () => {}, + const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; + return new AgentTranscriptViewer({ + agentId: SESSION_ID, registry: agents, + observers: makeSubagentRegistry(observed), + ui, + cwd: path.dirname(sessionFile), + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, }); - hub.openChat(SESSION_ID); - return hub; } describe("Agent hub silent-abort regression", () => { @@ -112,7 +117,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -123,10 +128,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - // Render with a reasonable width — the hub chat view reads the session - // file and calls #buildTranscriptLines internally. - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // The sentinel MUST NOT appear verbatim in any rendered line @@ -171,7 +174,7 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const hub = makeHub(sessionFile, [ + const viewer = makeViewer(sessionFile, [ { id: SESSION_ID, kind: "subagent", @@ -182,8 +185,8 @@ describe("Agent hub silent-abort regression", () => { }, ]); - const rendered = hub.render(120); - hub.dispose(); + const rendered = viewer.render(120); + viewer.dispose(); const renderedText = rendered.join("\n"); // AssistantMessageComponent renders the error as "Error: " diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index 34f48801a..9e0fb82c2 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { runPrintMode } from "@oh-my-pi/pi-coding-agent/modes/print-mode"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -48,15 +49,19 @@ function createMockSession(messages: AssistantMessage[]): AgentSession { describe("Print-mode silent-abort regression", () => { let exitSpy: ReturnType; let stderrOutput: string[]; + let stdoutOutput: string[]; beforeEach(() => { stderrOutput = []; + stdoutOutput = []; vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { stderrOutput.push(String(chunk)); return true; }); exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.spyOn(process.stdout, "write").mockImplementation((...args: unknown[]) => { + const chunk = args[0]; + if (typeof chunk === "string") stdoutOutput.push(chunk); // Invoke callback if present (runPrintMode flushes stdout before returning) const last = args[args.length - 1]; if (typeof last === "function") last(); @@ -69,8 +74,6 @@ describe("Print-mode silent-abort regression", () => { }); it("does not write silent-abort marker to stderr or exit non-zero", async () => { - const { runPrintMode } = await import("@oh-my-pi/pi-coding-agent/modes/print-mode"); - const silentAbortMsg = makeAssistantMessage({ stopReason: "aborted", errorMessage: SILENT_ABORT_MARKER, @@ -88,8 +91,6 @@ describe("Print-mode silent-abort regression", () => { }); it("writes real error messages to stderr and exits non-zero", async () => { - const { runPrintMode } = await import("@oh-my-pi/pi-coding-agent/modes/print-mode"); - const errorMsg = makeAssistantMessage({ stopReason: "error", errorMessage: "Rate limit exceeded", @@ -105,4 +106,20 @@ describe("Print-mode silent-abort regression", () => { // process.exit(1) SHOULD have been called expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("prints thinking blocks only when printThoughts is enabled", async () => { + const message = makeAssistantMessage({ + content: [ + { type: "thinking", thinking: "inspect hidden branch" }, + { type: "text", text: "final answer" }, + ], + }); + + await runPrintMode(createMockSession([message]), { mode: "text" }); + expect(stdoutOutput.join("")).toBe("final answer\n"); + + stdoutOutput = []; + await runPrintMode(createMockSession([message]), { mode: "text", printThoughts: true }); + expect(stdoutOutput.join("")).toBe("inspect hidden branch\nfinal answer\n"); + }); }); diff --git a/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts new file mode 100644 index 000000000..8a63482d0 --- /dev/null +++ b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts @@ -0,0 +1,154 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { + type BuiltinSlashCommandRuntime, + executeBuiltinSlashCommand, +} from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import { CollabQrCodeComponent } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/collab-qrcode"; +import { Spacer } from "@oh-my-pi/pi-tui"; + +beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + await initTheme(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + resetSettingsForTest(); +}); + +function fakeHost(options?: { + webLink?: string; + webViewLink?: string; +}): NonNullable { + return { + link: "relay.example.com/r/full-control", + viewLink: "relay.example.com/r/read-only", + webLink: options?.webLink ?? "https://my.omp.sh/#full-control", + webViewLink: options?.webViewLink ?? "https://my.omp.sh/#read-only", + participants: [{ name: "host", role: "host" }], + } as unknown as NonNullable; +} + +function createRuntimeHarness(options?: { collabHost?: NonNullable }) { + const setText = vi.fn(); + const showStatus = vi.fn(); + const showError = vi.fn(); + const present = vi.fn(); + const settingsGet = vi.fn((key: string) => { + if (key === "collab.relayUrl") return "wss://relay.example.com"; + if (key === "collab.webUrl") return ""; + return ""; + }); + const ctx = { + editor: { setText }, + showStatus, + showError, + present, + settings: { get: settingsGet }, + collabHost: options?.collabHost, + } as unknown as InteractiveModeContext; + return { + ctx, + setText, + showStatus, + showError, + present, + runtime: { ctx } as BuiltinSlashCommandRuntime, + }; +} + +function mockStartedHostLinks() { + return vi.spyOn(CollabHost.prototype, "start").mockImplementation(function (this: CollabHost): Promise { + Object.defineProperties(this, { + link: { value: "relay.example.com/r/full-control", configurable: true }, + viewLink: { value: "relay.example.com/r/read-only", configurable: true }, + webLink: { value: "https://my.omp.sh/#started-full", configurable: true }, + webViewLink: { value: "https://my.omp.sh/#started-view", configurable: true }, + participants: { value: [{ name: "host", role: "host" as const }], configurable: true }, + }); + return Promise.resolve(); + }); +} + +describe("/collab slash command QR code rendering", () => { + it("starts hosting and prints a one-shot full-control QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab", harness.runtime); + + expect(handled).toBe(true); + expect(harness.setText).toHaveBeenCalledWith(""); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-full"); + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("starts hosting and prints a one-shot read-only QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab view", harness.runtime); + + expect(handled).toBe(true); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-view"); + expect(statusText).not.toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-view"); + }); + + it("prints the active full-control browser QR when hosting", async () => { + const harness = createRuntimeHarness({ collabHost: fakeHost() }); + + const handled = await executeBuiltinSlashCommand("/collab", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#full-control"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("prints a one-shot read-only browser QR when hosting", async () => { + const webLink = "https://my.omp.sh/#full-control"; + const webViewLink = "https://my.omp.sh/#read-only"; + const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) }); + + const handled = await executeBuiltinSlashCommand("/collab view", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain(webViewLink); + expect(statusText).not.toContain(webLink); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe(webViewLink); + expect(component.render(10).join("\n")).toContain("QR code hidden"); + }); +}); diff --git a/packages/coding-agent/test/slash-commands/compact.test.ts b/packages/coding-agent/test/slash-commands/compact.test.ts new file mode 100644 index 000000000..b301be01a --- /dev/null +++ b/packages/coding-agent/test/slash-commands/compact.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "bun:test"; +import type { CompactOptions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { CompactMode } from "@oh-my-pi/pi-coding-agent/session/compact-modes"; +import { + ACP_BUILTIN_SLASH_COMMANDS, + executeAcpBuiltinSlashCommand, +} from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; +import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import type { SlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types"; + +function acpRuntime() { + const compact = vi.fn(async (_instructions?: string, _options?: CompactOptions) => {}); + const getContextUsage = vi.fn(() => undefined); + const output = vi.fn(); + const runtime = { session: { compact, getContextUsage }, output } as unknown as SlashCommandRuntime; + return { compact, output, runtime }; +} + +function tuiRuntime() { + const handleCompactCommand = vi.fn(async () => "ok" as const); + const setText = vi.fn(); + const showWarning = vi.fn(); + const runtime = { + ctx: { + editor: { setText } as unknown as InteractiveModeContext["editor"], + handleCompactCommand, + showWarning, + } as unknown as InteractiveModeContext, + }; + return { handleCompactCommand, setText, showWarning, runtime }; +} + +describe("/compact dispatch (ACP)", () => { + it("compacts with the configured strategy and no mode for a bare invocation", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact", h.runtime); + expect(h.compact).toHaveBeenCalledWith(undefined, undefined); + }); + + it("threads each mode subcommand into compact()", async () => { + for (const mode of ["soft", "remote", "snapcompact"] as const satisfies readonly CompactMode[]) { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand(`/compact ${mode}`, h.runtime); + expect(h.compact).toHaveBeenCalledWith(undefined, { mode }); + } + }); + + it("splits a mode from its focus instructions", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact soft focus on the parser", h.runtime); + expect(h.compact).toHaveBeenCalledWith("focus on the parser", { mode: "soft" }); + }); + + it("treats a non-mode argument as plain focus instructions (backward compatible)", async () => { + const h = acpRuntime(); + await executeAcpBuiltinSlashCommand("/compact summarize the auth flow", h.runtime); + expect(h.compact).toHaveBeenCalledWith("summarize the auth flow", undefined); + }); + + it("rejects focus text on snapcompact without compacting", async () => { + const h = acpRuntime(); + const result = await executeAcpBuiltinSlashCommand("/compact snapcompact keep the diffs", h.runtime); + expect(h.compact).not.toHaveBeenCalled(); + expect(result).toEqual({ consumed: true }); + expect((h.output.mock.calls[0]?.[0] as string) ?? "").toContain("snapcompact"); + }); + + it("advertises the mode subcommands and input hint to ACP clients", () => { + const advertised = ACP_BUILTIN_SLASH_COMMANDS.find(c => c.name === "compact"); + expect(advertised).toBeDefined(); + expect(advertised?.input?.hint).toBe("[soft|remote|snapcompact] [focus]"); + }); +}); + +describe("/compact dispatch (TUI)", () => { + it("routes mode + focus to handleCompactCommand and clears the editor", async () => { + const h = tuiRuntime(); + const handled = await executeBuiltinSlashCommand("/compact soft fix the bug", h.runtime); + expect(handled).toBe(true); + expect(h.setText).toHaveBeenCalledWith(""); + expect(h.handleCompactCommand).toHaveBeenCalledWith("fix the bug", "soft"); + }); + + it("passes no mode for a bare /compact", async () => { + const h = tuiRuntime(); + await executeBuiltinSlashCommand("/compact", h.runtime); + expect(h.handleCompactCommand).toHaveBeenCalledWith(undefined, undefined); + }); + + it("warns on snapcompact + focus text and does not compact", async () => { + const h = tuiRuntime(); + await executeBuiltinSlashCommand("/compact snapcompact keep diffs", h.runtime); + expect(h.handleCompactCommand).not.toHaveBeenCalled(); + expect(h.showWarning).toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 024edca4b..7d5d8b65d 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -310,46 +310,6 @@ describe("SnapcompactInlineTransformer", () => { expect(result.messages[4]).toBe(context.messages[4]); }); - it("stops swapping at OpenRouter's hard 8-image cap; later tools ship verbatim", () => { - const transformer = new SnapcompactInlineTransformer( - withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), - ); - const context: Context = { - messages: [ - userMessage("go"), - toolResult("call_1", LARGE), - toolResult("call_2", LARGE), - toolResult("call_3", LARGE), - toolResult("call_4", LARGE), - toolResult("call_5", LARGE), - toolResult("call_6", LARGE), - ], - }; - // OpenRouter budget 8; each LARGE needs 2 frames: call_1..4 swap (8 - // frames), call_5 no longer fits → verbatim; call_6 is the most - // recent and always stays text. - const result = transformer.transform(context, makeModel({ provider: "openrouter" })); - expect(imageCount(result)).toBe(8); - expect(result.messages[5]).toBe(context.messages[5]); - expect(result.messages[6]).toBe(context.messages[6]); - }); - - it("is a no-op when existing images (e.g. compaction frames) exhaust the OpenRouter cap", () => { - const transformer = new SnapcompactInlineTransformer( - withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), - ); - const png: ImageContent = { type: "image", data: "ZmFrZQ==", mimeType: "image/png" }; - const context: Context = { - messages: [ - { role: "user", content: [{ type: "text", text: "summary" }, ...Array(8).fill(png)], timestamp: 0 }, - toolResult("call_1", LARGE), - toolResult("call_2", LARGE), - ], - }; - const result = transformer.transform(context, makeModel({ provider: "openrouter" })); - expect(result).toBe(context); - }); - it("caches renders across turns: identical input does not re-rasterize", () => { const spy = spyOn(snapcompact, "renderMany"); try { diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 1a682417e..eddda058c 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -560,9 +560,10 @@ describe("truncateMiddle", () => { expect(result.content).not.toContain("elided"); }); - test("formatMiddleElisionMarker pluralises and formats bytes", () => { - expect(formatMiddleElisionMarker(1, 100)).toBe("[… 1 line elided (100B) …]"); - expect(formatMiddleElisionMarker(123, 4096)).toBe("[… 123 lines elided (4.0KB) …]"); + test("formatMiddleElisionMarker uses lines, falling back to bytes for <=1 line", () => { + expect(formatMiddleElisionMarker(0, 512)).toBe("[…512B elided…]"); + expect(formatMiddleElisionMarker(1, 100)).toBe("[…100B elided…]"); + expect(formatMiddleElisionMarker(123, 4096)).toBe("[…123ln elided…]"); }); }); diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index a30867f5b..2c73301dd 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -386,11 +386,11 @@ describe("streaming tool call preview height (bounded across renderers)", () => const width = 80; const { lines } = renderPending("bash", { command: "echo hi" }); const strippedLines = lines.map(line => Bun.stripANSI(line)); - const topBorder = strippedLines.find(line => line.includes(activeTheme.boxSharp.topLeft)); + const topBorder = strippedLines.find(line => line.includes(activeTheme.boxRound.topLeft)); expect(topBorder).toBeDefined(); - expect(topBorder?.[0]).toBe(activeTheme.boxSharp.topLeft); - expect(topBorder?.endsWith(activeTheme.boxSharp.topRight)).toBe(true); + expect(topBorder?.[0]).toBe(activeTheme.boxRound.topLeft); + expect(topBorder?.endsWith(activeTheme.boxRound.topRight)).toBe(true); expect(visibleWidth(topBorder ?? "")).toBe(width); }); diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index f351d6421..24faed559 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -6,6 +6,7 @@ import { buildSystemPrompt, loadProjectContextFiles, loadSystemPromptFiles, + type SystemPromptToolMetadata, } from "@oh-my-pi/pi-coding-agent/system-prompt"; import { cleanupTempHome } from "./helpers/temp-home-cleanup"; @@ -13,6 +14,17 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +const READ_TOOL = new Map([ + [ + "read", + { + label: "Read", + description: "Reads files from disk.", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + ], +]); + describe("SYSTEM.md prompt assembly", () => { let tempDir = ""; let tempHomeDir = ""; @@ -38,9 +50,18 @@ describe("SYSTEM.md prompt assembly", () => { cwd: projectDir, customPrompt: systemPrompt, contextFiles: [], - skills: [], + skills: [ + { + name: "focused-work", + description: "Focused work instructions", + filePath: "skills/focused-work/SKILL.md", + baseDir: "skills/focused-work", + source: "test", + }, + ], rules: [], - toolNames: [], + toolNames: ["read"], + tools: READ_TOOL, workspaceTree: { rootPath: projectDir, rendered: "", @@ -53,6 +74,71 @@ describe("SYSTEM.md prompt assembly", () => { const promptText = renderedPrompt.join("\n\n"); const matches = promptText.match(new RegExp(escapeRegExp(systemPrompt), "g")) ?? []; expect(matches).toHaveLength(1); + expect(promptText).toContain(''); + }); + + it("does not resolve already-loaded prompt text as a path", async () => { + const projectDir = path.join(tempDir, "project"); + const readablePromptText = path.join(projectDir, "README.md"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(readablePromptText, "File content that must not replace the prompt."); + + const { systemPrompt } = await buildSystemPrompt({ + cwd: projectDir, + resolvedCustomPrompt: readablePromptText, + resolvedAppendSystemPrompt: readablePromptText, + contextFiles: [], + skills: [], + rules: [], + toolNames: ["read"], + tools: READ_TOOL, + workspaceTree: { + rootPath: projectDir, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + }); + + const promptText = systemPrompt.join("\n\n"); + expect(promptText).toContain(readablePromptText); + expect(promptText).not.toContain("File content that must not replace the prompt."); + }); + + it("suppresses discovered SYSTEM.md while preserving the project footer", async () => { + const projectDir = path.join(tempDir, "project"); + const appendPrompt = "Extra append instructions"; + fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true }); + fs.writeFileSync(path.join(projectDir, ".omp", "SYSTEM.md"), "Discovered project SYSTEM prompt"); + + const { systemPrompt } = await buildSystemPrompt({ + cwd: projectDir, + resolvedCustomPrompt: "CLI custom prompt", + resolvedAppendSystemPrompt: appendPrompt, + contextFiles: [], + skills: [], + rules: [], + toolNames: ["read"], + tools: READ_TOOL, + workspaceTree: { + rootPath: projectDir, + rendered: ".\n - nested/", + truncated: false, + totalLines: 2, + agentsMdFiles: ["nested/AGENTS.md"], + }, + }); + + const promptText = systemPrompt.join("\n\n"); + const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? []; + expect(systemPrompt).toHaveLength(2); + expect(promptText).toContain("CLI custom prompt"); + expect(promptText).toContain(""); + expect(promptText).toContain(""); + expect(promptText).toContain(`current working directory is '${projectDir}'`); + expect(appendMatches).toHaveLength(1); + expect(promptText).not.toContain("Discovered project SYSTEM prompt"); }); it("prefers project SYSTEM.md over user SYSTEM.md", async () => { diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index bee6e3f61..e10c3cda3 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -46,7 +46,7 @@ describe("system prompt tool inventory", () => { afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); - async function render(opts: { nativeTools: boolean; repeatToolDescriptions: boolean }): Promise { + async function render(opts: { nativeTools: boolean; inlineToolDescriptors: boolean }): Promise { const { systemPrompt } = await buildSystemPrompt({ cwd: tempDir, contextFiles: [], @@ -55,13 +55,14 @@ describe("system prompt tool inventory", () => { toolNames: ["read", "bash"], tools: TOOLS, workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, - ...opts, + nativeTools: opts.nativeTools, + inlineToolDescriptors: opts.inlineToolDescriptors, }); return systemPrompt.join("\n\n"); } - it("renders a compact name list only when native tools are active and descriptions are not repeated", async () => { - const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + it("renders a compact name list only when native tools are active and descriptors stay in schemas", async () => { + const text = await render({ nativeTools: true, inlineToolDescriptors: false }); expect(text).toContain("- Read: `read`"); expect(text).toContain("- Bash: `bash`"); // No full per-tool sections in list mode. @@ -70,7 +71,7 @@ describe("system prompt tool inventory", () => { }); it("renders `# Tool:` sections (not a name list) when tools are not native", async () => { - const text = await render({ nativeTools: false, repeatToolDescriptions: false }); + const text = await render({ nativeTools: false, inlineToolDescriptors: false }); expect(text).toContain("# Tool: read"); expect(text).toContain("# Tool: bash"); expect(text).toContain("Reads files from disk."); @@ -79,8 +80,8 @@ describe("system prompt tool inventory", () => { expect(text).not.toContain(" { - const text = await render({ nativeTools: true, repeatToolDescriptions: true }); + it("renders `# Tool:` sections when descriptors are inlined even with native tools", async () => { + const text = await render({ nativeTools: true, inlineToolDescriptors: true }); expect(text).toContain("# Tool: read"); expect(text).toContain("Executes a shell command."); expect(text).not.toContain("- Read: `read`"); @@ -106,13 +107,12 @@ describe("system prompt tool inventory", () => { }); const text = systemPrompt.join("\n\n"); - expect(text).toContain("Skills are specialized knowledge. Scan descriptions for your task domain."); - expect(text).toContain("If a skill applies, you MUST read `skill://` before proceeding."); + expect(text).toContain(""); expect(text).toContain("- frontend-design: Frontend UI workflow"); }); it("places the inventory at the bottom of the TOOLS section (after I/O and Exploration)", async () => { - const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + const text = await render({ nativeTools: true, inlineToolDescriptors: false }); const inventoryIdx = text.indexOf("# Inventory"); const ioIdx = text.indexOf("# I/O"); const explorationIdx = text.indexOf("# Exploration"); diff --git a/packages/coding-agent/test/system-prompt-math.test.ts b/packages/coding-agent/test/system-prompt-math.test.ts deleted file mode 100644 index 508a0576e..000000000 --- a/packages/coding-agent/test/system-prompt-math.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt"; -import { cleanupTempHome } from "./helpers/temp-home-cleanup"; - -const EMPTY_TREE = { - rootPath: "", - rendered: "", - truncated: false, - totalLines: 0, - agentsMdFiles: [], -}; - -describe("system prompt mathematical formatting", () => { - let tempDir = ""; - let tempHomeDir = ""; - let originalHome: string | undefined; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-math-")); - tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-math-home-")); - originalHome = process.env.HOME; - process.env.HOME = tempHomeDir; - }); - - afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); - - it("renders mathematical formatting constraints into the system prompt", async () => { - const { systemPrompt } = await buildSystemPrompt({ - cwd: tempDir, - contextFiles: [], - skills: [], - rules: [], - toolNames: [], - workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, - }); - - const promptText = systemPrompt.join("\n\n"); - expect(promptText).toContain("In user-visible terminal prose and final chat"); - expect(promptText).toContain("you MAY use LaTeX math delimiters"); - expect(promptText).toContain("LaTeX math commands"); - }); -}); diff --git a/packages/coding-agent/test/task/output-manager.test.ts b/packages/coding-agent/test/task/output-manager.test.ts index b374105fe..de33b13ad 100644 --- a/packages/coding-agent/test/task/output-manager.test.ts +++ b/packages/coding-agent/test/task/output-manager.test.ts @@ -65,4 +65,13 @@ describe("AgentOutputManager", () => { expect(await mgr.allocate("Bob")).toBe("Anna.Bob-2"); expect(await mgr.allocate("Dave")).toBe("Anna.Dave"); }); + + it("reserves the advisor transcript stem so a task can't clobber __advisor.jsonl", async () => { + const mgr = new AgentOutputManager(() => null); + // A subagent allocated `__advisor` would write `__advisor.jsonl`, colliding with + // the advisor transcript in the same artifacts dir; the stem is pre-reserved. + expect(await mgr.allocate("__advisor")).toBe("__advisor-2"); + // Unrelated names sharing the prefix are unaffected. + expect(await mgr.allocate("__advisor-notes")).toBe("__advisor-notes"); + }); }); diff --git a/packages/coding-agent/test/task/render-call.test.ts b/packages/coding-agent/test/task/render-call.test.ts index 4a0c03425..b0d05118c 100644 --- a/packages/coding-agent/test/task/render-call.test.ts +++ b/packages/coding-agent/test/task/render-call.test.ts @@ -124,7 +124,7 @@ describe("task renderer: streaming call preview", () => { expect(out).toContain("Shared brief."); // Interior divider bars start with the tee glyph; only the header (top) // and bottom border may exist. - const tee = theme.boxSharp.teeRight; + const tee = theme.boxRound.teeRight; expect(lines.filter(line => line.trimStart().startsWith(tee))).toHaveLength(0); }); diff --git a/packages/coding-agent/test/task/task-batch.test.ts b/packages/coding-agent/test/task/task-batch.test.ts index 9b3c58996..35982c885 100644 --- a/packages/coding-agent/test/task/task-batch.test.ts +++ b/packages/coding-agent/test/task/task-batch.test.ts @@ -132,23 +132,6 @@ describe("task.batch schema gating", () => { expect(getSchemaProperties(tool).schema).toBeUndefined(); } }); - - it("documents the batch parameters only when enabled", async () => { - mockDiscovery(); - - const off = await TaskTool.create(createSession({ settings: { "task.batch": false } })); - expect(off.description).toContain("Spawns ONE subagent per call to work in the background"); - expect(off.description).not.toContain("`context`: shared background"); - - const offSync = await TaskTool.create( - createSession({ settings: { "async.enabled": false, "task.batch": false } }), - ); - expect(offSync.description).toContain("Runs ONE subagent synchronously per call"); - - const on = await TaskTool.create(createSession({ settings: { "task.batch": true } })); - expect(on.description).toContain("`tasks`: tasks to spawn"); - expect(on.description).toContain("`context`: shared background"); - }); }); describe("task.batch validation", () => { diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 6d829548a..e66c57c04 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -18,8 +18,8 @@ import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import { DEFAULT_FILE_LIMIT, MULTI_FILE_PER_FILE_MATCHES, SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search"; import * as toolTimeouts from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; +import { unzip } from "@oh-my-pi/pi-coding-agent/utils/zip"; import { $which, Snowflake } from "@oh-my-pi/pi-utils"; -import { unzipSync } from "fflate"; // Helper to extract text from content blocks function getTextOutput(result: any): string { @@ -931,7 +931,7 @@ describe("Coding Agent Tools", () => { `Successfully wrote ${content.length} bytes to ${path.basename(archivePath)}:pkg/README.md`, ); - const unzipped = unzipSync(new Uint8Array(fs.readFileSync(archivePath))); + const unzipped = unzip(new Uint8Array(fs.readFileSync(archivePath))); expect(new TextDecoder().decode(unzipped["pkg/README.md"])).toBe(content); expect(new TextDecoder().decode(unzipped["pkg/src/index.ts"])).toBe("export const archiveValue = 1;\n"); }); diff --git a/packages/coding-agent/test/tools/approval.test.ts b/packages/coding-agent/test/tools/approval.test.ts index 11f64bc01..65a6e4d8b 100644 --- a/packages/coding-agent/test/tools/approval.test.ts +++ b/packages/coding-agent/test/tools/approval.test.ts @@ -145,7 +145,7 @@ describe("MCP fallback and prompt formatting", () => { it("truncates prompt details without touching short strings", () => { expect(truncateForPrompt("hello", 10)).toBe("hello"); - expect(truncateForPrompt("abcdefgh", 5)).toBe("abcde… (3 chars truncated)"); + expect(truncateForPrompt("abcdefgh", 5)).toBe("abcde[…3ch elided…]"); }); }); diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 64bf976a6..5bd45a194 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -113,9 +113,8 @@ describe("ast_edit tool schema", () => { expect(previewResult.details).toBeDefined(); expect((previewResult.details as { applied?: boolean }).applied).toBe(false); - expect(queue.inspect().some(l => l.startsWith("pending-action:ast_edit"))).toBe(true); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + expect(queue.hasPendingInvoker).toBe(true); + const invoker = queue.peekPendingInvoker()!; const applyResult = (await invoker({ action: "apply", reason: "apply previewed AST edit", @@ -160,8 +159,7 @@ describe("ast_edit tool schema", () => { const mutatedContent = "otherWrap(x, value)\n"; await Bun.write(filePath, mutatedContent); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; const applyResult = (await invoker({ action: "apply", reason: "apply stale preview" })) as InvokedToolResult; const applyText = applyResult.content.find(content => content.type === "text")?.text ?? ""; @@ -225,8 +223,7 @@ describe("ast_edit tool schema", () => { ]), ); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; await invoker({ action: "apply", reason: "apply previewed AST edit with combined globs" }); expect(await Bun.file(path.join(sourceDir, "root.ts")).text()).toContain("modernWrap(rootValue, rootArg)"); @@ -270,8 +267,7 @@ describe("ast_edit tool schema", () => { expect(details?.totalReplacements).toBe(1); expect(details?.parseErrors).toBeUndefined(); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; await invoker({ action: "apply", reason: "apply tlaplus AST edit" }); expect(await Bun.file(filePath).text()).toContain("Start == x = 0"); } finally { diff --git a/packages/coding-agent/test/tools/eval-agent-progress.test.ts b/packages/coding-agent/test/tools/eval-agent-progress.test.ts index 0b2e4c134..3b95e81f1 100644 --- a/packages/coding-agent/test/tools/eval-agent-progress.test.ts +++ b/packages/coding-agent/test/tools/eval-agent-progress.test.ts @@ -51,7 +51,7 @@ describe("eval renderer: agent() progress below the cell box", () => { /** Index of the box's closing border (bottom-right corner glyph). */ function boxBottomIndex(lines: string[]): number { - return lines.findIndex(line => line.includes(theme.boxSharp.bottomRight)); + return lines.findIndex(line => line.includes(theme.boxRound.bottomRight)); } it("draws a running subagent below the box with its current tool and intent", () => { diff --git a/packages/coding-agent/test/tools/eval-display-text.test.ts b/packages/coding-agent/test/tools/eval-display-text.test.ts index a8e74f22c..238c81087 100644 --- a/packages/coding-agent/test/tools/eval-display-text.test.ts +++ b/packages/coding-agent/test/tools/eval-display-text.test.ts @@ -176,7 +176,7 @@ describe("EvalTool display() text surfacing", () => { }); const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n"); - expect(text).toContain("chars truncated"); + expect(text).toContain("ch elided"); expect(text.length).toBeLessThan(20000); }); }); diff --git a/packages/coding-agent/test/tools/fetch-binary-dispatch.test.ts b/packages/coding-agent/test/tools/fetch-binary-dispatch.test.ts index cab602c17..5d5bdae76 100644 --- a/packages/coding-agent/test/tools/fetch-binary-dispatch.test.ts +++ b/packages/coding-agent/test/tools/fetch-binary-dispatch.test.ts @@ -7,10 +7,10 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; +import { zip } from "@oh-my-pi/pi-coding-agent/utils/zip"; import * as scrapers from "@oh-my-pi/pi-coding-agent/web/scrapers/types"; import * as scraperUtils from "@oh-my-pi/pi-coding-agent/web/scrapers/utils"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { zipSync } from "fflate"; function makeSession(testDir: string): ToolSession { const sessionFile = path.join(testDir, "session.jsonl"); @@ -112,7 +112,7 @@ describe("read URL binary dispatch", () => { }); it("lists a remote zip instead of dumping decoded bytes", async () => { - const zipBytes = zipSync({ + const zipBytes = zip({ "root.txt": Buffer.from("root file\n"), "nested/data.txt": Buffer.from("nested file\n"), }); diff --git a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts index 56c13c2b5..de67c08b7 100644 --- a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts +++ b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts @@ -167,11 +167,6 @@ describe("read tool URL handling", () => { ok: true, buffer: imageBytes, }); - vi.spyOn(scraperUtils, "convertWithMarkit").mockResolvedValue({ - ok: false, - content: "", - error: "markit unavailable", - }); vi.spyOn(imageResize, "resizeImage").mockResolvedValue({ buffer: imageBytes, mimeType: "image/png", @@ -222,11 +217,7 @@ describe("read tool URL handling", () => { ok: true, buffer: new Uint8Array([137, 80, 78, 71]), }); - vi.spyOn(scraperUtils, "convertWithMarkit").mockResolvedValue({ - ok: false, - content: "", - error: "markit unavailable", - }); + const convertSpy = vi.spyOn(scraperUtils, "convertWithMarkit"); const result = await tool.execute("fetch-image-resized", { path: "https://example.com/image.png" }); const imageBlock = result.content.find( @@ -240,52 +231,9 @@ describe("read tool URL handling", () => { expect(imageBlock?.data).toBe("cmVzaXplZA=="); expect(textBlock?.type).toBe("text"); expect(textBlock?.text).toContain("displayed at 1000x500"); + expect(convertSpy).not.toHaveBeenCalled(); }); - it("keeps markit extracted text for image responses", async () => { - const session = createSession(); - const tool = new ReadTool(session); - const extractedText = "Converted image text content that is definitely longer than fifty characters."; - vi.spyOn(imageResize, "resizeImage").mockResolvedValue({ - buffer: new Uint8Array([1, 2, 3]), - mimeType: "image/png", - originalWidth: 100, - originalHeight: 100, - width: 100, - height: 100, - wasResized: false, - get data() { - return "aW1hZ2U="; - }, - }); - vi.spyOn(scrapers, "loadPage").mockResolvedValue({ - ok: true, - status: 200, - contentType: "image/png", - finalUrl: "https://example.com/image.png", - content: "", - }); - vi.spyOn(scraperUtils, "fetchBinary").mockResolvedValue({ - ok: true, - buffer: new Uint8Array([137, 80, 78, 71]), - }); - vi.spyOn(scraperUtils, "convertWithMarkit").mockResolvedValue({ - ok: true, - content: extractedText, - }); - - const result = await tool.execute("fetch-image-with-ocr", { path: "https://example.com/image.png" }); - const textBlock = result.content.find(content => content.type === "text"); - const imageBlock = result.content.find( - (content): content is { type: "image"; data: string; mimeType: string } => content.type === "image", - ); - - expect(result.details?.method).toBe("image"); - expect(textBlock?.type).toBe("text"); - expect(textBlock?.text).toContain(extractedText); - expect(imageBlock?.mimeType).toBe("image/png"); - expect(imageBlock?.data).toBe("aW1hZ2U="); - }); it("falls back to text-only output for unsupported image MIME types", async () => { const session = createSession(); const tool = new ReadTool(session); @@ -309,39 +257,6 @@ describe("read tool URL handling", () => { expect(textBlock?.text).toContain(""); }); - it("uses binary conversion fallback for unsupported image MIME when extension is convertible", async () => { - const session = createSession(); - const tool = new ReadTool(session); - const convertedText = "Converted image text from markit fallback with sufficient length to pass threshold."; - const fetchBinarySpy = vi.spyOn(scraperUtils, "fetchBinary").mockResolvedValue({ - ok: true, - buffer: new Uint8Array([255, 216, 255, 224]), - }); - const convertSpy = vi.spyOn(scraperUtils, "convertWithMarkit").mockResolvedValue({ - ok: true, - content: convertedText, - }); - vi.spyOn(scrapers, "loadPage").mockResolvedValue({ - ok: true, - status: 200, - contentType: "image/jpg", - finalUrl: "https://example.com/image.jpg", - content: "\u0000\u0001garbage", - }); - - const result = await tool.execute("fetch-image-jpg-fallback", { path: "https://example.com/image.jpg" }); - const imageBlock = result.content.find(content => content.type === "image"); - const textBlock = result.content.find(content => content.type === "text"); - - expect(result.details?.method).toBe("markit"); - expect(fetchBinarySpy).toHaveBeenCalledTimes(1); - expect(convertSpy).toHaveBeenCalledTimes(1); - expect(result.details?.notes).toContain("Attempting binary conversion fallback for unsupported image MIME type"); - expect(imageBlock).toBeUndefined(); - expect(textBlock?.type).toBe("text"); - expect(textBlock?.text).toContain(convertedText); - }); - it("does not treat text/html at .png paths as inline images", async () => { const session = createSession(); const tool = new ReadTool(session); @@ -404,11 +319,6 @@ describe("read tool URL handling", () => { ok: true, buffer: new Uint8Array([60, 104, 116, 109, 108]), }); - vi.spyOn(scraperUtils, "convertWithMarkit").mockResolvedValue({ - ok: false, - content: "", - error: "conversion failed", - }); vi.spyOn(imageResize, "resizeImage").mockResolvedValue({ buffer: new Uint8Array([60, 104, 116, 109, 108]), mimeType: "image/png", diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index c01fa906f..f3149ab54 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -2,10 +2,13 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { completeSimple, Model } from "@oh-my-pi/pi-ai"; +import { AuthStorage, type completeSimple, type ImageContent, type Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { InspectImageTool } from "@oh-my-pi/pi-coding-agent/tools/inspect-image"; import { inspectImageToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/inspect-image-renderer"; @@ -39,6 +42,7 @@ interface CreateSessionOptions { availableModels?: Model<"openai-responses">[]; activeModel?: Model<"openai-responses">; configureVisionRole?: boolean; + imageAttachments?: { label: string; uri: string; image: ImageContent }[]; } interface CompleteSimpleStub { @@ -59,7 +63,7 @@ function createSession( settings.setModelRole("vision", `${model.provider}/${model.id}`); } - return { + const session: ToolSession = { cwd, hasUI: false, getSessionFile: () => null, @@ -75,6 +79,10 @@ function createSession( resolver: () => async () => apiKey, } as unknown as NonNullable, }; + if (options.imageAttachments) { + session.getImageAttachments = () => options.imageAttachments ?? []; + } + return session; } function createCompleteSimpleSuccessStub(text: string): CompleteSimpleStub { @@ -148,6 +156,126 @@ describe("InspectImageTool", () => { expect(contentParts[1]).toEqual({ type: "text", text: "Extract visible UI labels." }); }); + it("resolves pasted image labels from current attachments without using cwd", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const stub = createCompleteSimpleSuccessStub("Attached image inspected"); + const missingCwd = path.join(testDir, "missing-cwd"); + const tool = new InspectImageTool( + createSession(missingCwd, visionModel, "test-key", Settings.isolated(), { + imageAttachments: [{ label: "Image #1", uri: "attachment://1", image }], + }), + stub.fn, + ); + + const result = await tool.execute("call-attachment-label", { + path: "Image #1", + question: "Describe the pasted image.", + }); + + expect(result.details?.imagePath).toBe("attachment://1"); + expect(stub.calls).toHaveLength(1); + const request = stub.calls[0]?.[1] as { messages?: Array<{ content?: unknown }> } | undefined; + const attachmentContent = request?.messages?.[0]?.content; + const attachmentParts = (Array.isArray(attachmentContent) ? attachmentContent : []) as Array<{ + type: string; + data?: string; + }>; + expect(attachmentParts[0]).toMatchObject({ type: "image", data: TINY_PNG_BASE64 }); + }); + + it("resolves bracketed labels and attachment URIs deterministically", async () => { + const first: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const second: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const attachments = [ + { label: "Image #1", uri: "attachment://1", image: first }, + { label: "Image #2", uri: "attachment://2", image: second }, + ]; + + const bracketStub = createCompleteSimpleSuccessStub("First"); + const bracketTool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { imageAttachments: attachments }), + bracketStub.fn, + ); + const bracketResult = await bracketTool.execute("call-bracket-label", { + path: "[Image #1, 1568x784]", + question: "Describe the first attachment.", + }); + + const uriStub = createCompleteSimpleSuccessStub("Second"); + const uriTool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { imageAttachments: attachments }), + uriStub.fn, + ); + const uriResult = await uriTool.execute("call-uri-label", { + path: "attachment://2", + question: "Describe the second attachment.", + }); + + expect(bracketResult.details?.imagePath).toBe("attachment://1"); + expect(uriResult.details?.imagePath).toBe("attachment://2"); + }); + + it("reports attachment-aware errors for missing image labels", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const stub = createCompleteSimpleForbiddenStub(); + const tool = new InspectImageTool( + createSession(testDir, visionModel, "test-key", Settings.isolated(), { + imageAttachments: [{ label: "Image #1", uri: "attachment://1", image }], + }), + stub.fn, + ); + + await expect(tool.execute("call-missing-label", { path: "Image #2", question: "Describe it." })).rejects.toThrow( + /Available image attachments: Image #1 -> attachment:\/\/1/, + ); + expect(stub.calls).toHaveLength(0); + }); + + it("wires createAgentSession tool sessions to live image attachments", async () => { + const image: ImageContent = { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }; + const authStorage = await AuthStorage.create(path.join(testDir, "auth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const settings = Settings.isolated({ "compaction.enabled": false, "inspect_image.enabled": true }); + settings.setModelRole("vision", `${visionModel.provider}/${visionModel.id}`); + + try { + const { session } = await createAgentSession({ + cwd: testDir, + agentDir: testDir, + sessionManager: SessionManager.inMemory(testDir), + settings, + model: visionModel, + modelRegistry, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + toolNames: ["inspect_image"], + }); + try { + session.agent.appendMessage({ + role: "user", + content: [{ type: "text", text: "inspect this" }, image], + timestamp: Date.now(), + }); + + const tool = session.getToolByName("inspect_image"); + expect(tool).toBeDefined(); + const wiredToolSession = (tool as unknown as { session?: ToolSession }).session; + expect(wiredToolSession?.getImageAttachments?.()).toEqual([ + { label: "Image #1", uri: "attachment://1", image }, + ]); + } finally { + await session.dispose(); + } + } finally { + authStorage.close(); + } + }); + it("sends question text unchanged", async () => { const imagePath = path.join(testDir, "screen.png"); fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); diff --git a/packages/coding-agent/test/tools/irc.test.ts b/packages/coding-agent/test/tools/irc.test.ts index 37ed6e562..b246f92a0 100644 --- a/packages/coding-agent/test/tools/irc.test.ts +++ b/packages/coding-agent/test/tools/irc.test.ts @@ -434,6 +434,24 @@ describe("IRC", () => { expect(text).toContain("Parked agents are revived automatically"); }); + it("op=list hides advisor-kind refs from the peer roster", async () => { + const sub = makeFakeSession(); + registry.register({ id: "0-Worker", displayName: "task", kind: "sub", session: sub.session }); + registry.register({ + id: "0-Main/advisor", + displayName: "advisor", + kind: "advisor", + session: null, + status: "parked", + }); + + const tool = new IrcTool(makeToolSession(registry, "0-Main")); + const result = await tool.execute("call-1", { op: "list" }); + const peerIds = result.details?.peers?.map(peer => peer.id) ?? []; + expect(peerIds).toContain("0-Worker"); + expect(peerIds).not.toContain("0-Main/advisor"); + }); + it("op=send returns receipts immediately without waiting for a reply", async () => { const sub = makeFakeSession(); registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session }); diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 768fddd49..45039443e 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -308,6 +308,78 @@ describe("lsp regressions", () => { } }); + it("accepts dynamic capability registration before semantic requests", async () => { + const tempDir = TempDir.createSync("@omp-lsp-dynamic-registration-"); + try { + let dynamicRegistrationAccepted = false; + const server = installFakeLsp((message, srv) => { + if (message.method === "initialize") { + srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { hoverProvider: true } } }); + } else if (message.method === "initialized") { + srv.send({ + jsonrpc: "2.0", + id: 9002, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "-42", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/mix.lock" }, { globPattern: "**/*.{ex,exs}" }], + }, + }, + ], + }, + }); + srv.send({ + jsonrpc: "2.0", + id: "expert-unregister-1", + method: "client/unregisterCapability", + params: { unregisterations: [{ id: "-42", method: "workspace/didChangeWatchedFiles" }] }, + }); + } else if (message.id === 9002 && message.method === undefined) { + dynamicRegistrationAccepted = message.error === undefined; + } else if (message.method === "textDocument/hover" && dynamicRegistrationAccepted) { + srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "Atas.version()" } }); + } else if (message.method === "shutdown") { + srv.send({ jsonrpc: "2.0", id: message.id, result: null }); + } else if (message.method === "exit") { + srv.exit(0); + } + }); + + const config: ServerConfig = { + command: "fake-lsp", + fileTypes: ["ex"], + rootMarkers: [], + }; + + const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000); + const registerResponse = await server.waitFor(message => message.id === 9002 && message.method === undefined); + const unregisterResponse = await server.waitFor( + message => message.id === "expert-unregister-1" && message.method === undefined, + ); + expect(registerResponse.error).toBeUndefined(); + expect(unregisterResponse.error).toBeUndefined(); + const result = await lspClient.sendRequest( + client, + "textDocument/hover", + { + textDocument: { uri: fileToUri(path.join(tempDir.path(), "lib", "atas.ex")) }, + position: { line: 0, character: 0 }, + }, + undefined, + 50, + ); + + expect(result).toEqual({ contents: "Atas.version()" }); + } finally { + await lspClient.shutdownAll(); + tempDir.removeSync(); + } + }); + it("opens rust-analyzer Cargo workspace files before polling workspace readiness", async () => { const tempDir = TempDir.createSync("@omp-lsp-rust-workspace-"); try { diff --git a/packages/coding-agent/test/tools/output-caps.test.ts b/packages/coding-agent/test/tools/output-caps.test.ts index 3c7997db6..feec2c115 100644 --- a/packages/coding-agent/test/tools/output-caps.test.ts +++ b/packages/coding-agent/test/tools/output-caps.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "bun:test"; import { DEFAULT_MAX_BYTES, enforceInlineByteCap } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; -const MARKER_PATTERN = /\[… elided \d+ bytes of test output …\]/; +const MARKER_PATTERN = /\[…\d+B elided…\]/; /** Build `count` complete lines of the form `line-00001 `. */ function makeLines(count: number, pad = "x".repeat(40)): string { @@ -22,24 +22,24 @@ function makeLines(count: number, pad = "x".repeat(40)): string { describe("enforceInlineByteCap", () => { it("returns sub-cap text unchanged (identity, not just equality)", async () => { const text = makeLines(10); - const result = await enforceInlineByteCap(text, { maxBytes: 4096, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 4096 }); expect(result).toBe(text); }); it("returns text exactly at the cap unchanged", async () => { const text = "a".repeat(1000); expect(Buffer.byteLength(text, "utf-8")).toBe(1000); - const result = await enforceInlineByteCap(text, { maxBytes: 1000, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes: 1000 }); expect(result).toBe(text); }); it("uses DEFAULT_MAX_BYTES when maxBytes is omitted", async () => { const under = "a".repeat(DEFAULT_MAX_BYTES - 1); - expect(await enforceInlineByteCap(under, { label: "test output" })).toBe(under); + expect(await enforceInlineByteCap(under, {})).toBe(under); const over = makeLines(2000); // ~94KB, well over the 50KB default expect(Buffer.byteLength(over, "utf-8")).toBeGreaterThan(DEFAULT_MAX_BYTES); - const result = await enforceInlineByteCap(over, { label: "test output" }); + const result = await enforceInlineByteCap(over, {}); expect(result).not.toBe(over); expect(result).toMatch(MARKER_PATTERN); expect(Buffer.byteLength(result, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES); @@ -48,7 +48,7 @@ describe("enforceInlineByteCap", () => { it("elides over-cap text to head + tail on line boundaries within the budget", async () => { const maxBytes = 4096; const text = makeLines(500); // ~23KB - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Stays within the cap plus a sliver of slack for the marker line. @@ -80,7 +80,7 @@ describe("enforceInlineByteCap", () => { // Each line is multi-byte heavy: é (2B), € (3B), 😀 (4B). const text = makeLines(800, "é€😀".repeat(12)); const maxBytes = 4096; - const result = await enforceInlineByteCap(text, { maxBytes, label: "test output" }); + const result = await enforceInlineByteCap(text, { maxBytes }); expect(result).toMatch(MARKER_PATTERN); // Valid UTF-8 round-trip: encode/decode is lossless and introduces no @@ -102,7 +102,6 @@ describe("enforceInlineByteCap", () => { let saved: string | undefined; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: full => { saved = full; return Promise.resolve("17"); @@ -118,7 +117,6 @@ describe("enforceInlineByteCap", () => { const text = makeLines(500); const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => undefined, }); expect(result).not.toContain("[raw output: artifact://"); @@ -130,7 +128,6 @@ describe("enforceInlineByteCap", () => { const text = "short output"; const result = await enforceInlineByteCap(text, { maxBytes: 4096, - label: "test output", saveArtifact: () => { called = true; return "99"; diff --git a/packages/coding-agent/test/tools/read-renderer.test.ts b/packages/coding-agent/test/tools/read-renderer.test.ts index 44f7acc15..c87678a17 100644 --- a/packages/coding-agent/test/tools/read-renderer.test.ts +++ b/packages/coding-agent/test/tools/read-renderer.test.ts @@ -122,10 +122,10 @@ describe("read ToolExecutionComponent framing", () => { try { const lines = component.render(80).map(line => Bun.stripANSI(line)); const topBorderIndex = lines.findIndex( - line => line.includes(activeTheme.boxSharp.topLeft) && line.includes("Read"), + line => line.includes(activeTheme.boxRound.topLeft) && line.includes("Read"), ); const bottomBorderIndex = lines.findIndex( - (line, index) => index > topBorderIndex && line.includes(activeTheme.boxSharp.bottomLeft), + (line, index) => index > topBorderIndex && line.includes(activeTheme.boxRound.bottomLeft), ); expect(topBorderIndex).toBeGreaterThanOrEqual(0); diff --git a/packages/coding-agent/test/tools/resolve.test.ts b/packages/coding-agent/test/tools/resolve.test.ts index 5df4bc042..46c1f84ad 100644 --- a/packages/coding-agent/test/tools/resolve.test.ts +++ b/packages/coding-agent/test/tools/resolve.test.ts @@ -147,7 +147,7 @@ it("renders a highlighted apply summary", async () => { expect(rendered).toContain("All replacements are correct"); expect(rendered).not.toContain("Applied 2 replacements in 1 file."); expect(rendered).not.toContain("Decision"); - expect(rendered).not.toContain("┌"); + expect(rendered).not.toContain(uiTheme.boxRound.topLeft); }); it("keeps the inverse block color across the full line (no mid-line fg reset)", async () => { diff --git a/packages/coding-agent/test/tools/root-path-alias.test.ts b/packages/coding-agent/test/tools/root-path-alias.test.ts index 5a8ed4417..daf084bae 100644 --- a/packages/coding-agent/test/tools/root-path-alias.test.ts +++ b/packages/coding-agent/test/tools/root-path-alias.test.ts @@ -148,8 +148,7 @@ describe("tool path root alias", () => { expect(details?.scopePath).toBe("."); expect(details?.totalReplacements).toBe(1); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker()!; + const invoker = queue.peekPendingInvoker()!; expect(invoker).toBeDefined(); await invoker({ action: "apply", reason: "apply root alias rewrite" }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index daaa8854f..f618da3c3 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -7,7 +7,7 @@ import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; import type { ObservableSession, @@ -354,16 +354,20 @@ describe("tool path arrays", () => { status: "parked", }); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: ["ctrl+s"], - onDone: () => {}, - requestRender: () => {}, + const viewer = new AgentTranscriptViewer({ + agentId: "search-overlay-session", registry: agents, + observers, + ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + cwd: tmp, + expandKeys: ["ctrl+o"], + hubKeys: ["ctrl+s"], + requestRender: () => {}, + onClose: () => {}, + onHubClose: () => {}, }); - hub.openChat("search-overlay-session"); - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - hub.dispose(); + const rendered = Bun.stripANSI(viewer.render(120).join("\n")); + viewer.dispose(); // The hub chat now renders through searchToolRenderer.renderCall; the // single-string `paths` arg shows up as the "in " scope meta on the @@ -634,8 +638,7 @@ describe("tool path arrays", () => { expect(details?.totalReplacements).toBe(3); expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); - queue.nextToolChoice(); - const invoker = queue.peekInFlightInvoker(); + const invoker = queue.peekPendingInvoker(); if (!invoker) throw new Error("Expected pending resolve invoker"); await invoker({ action: "apply", reason: "apply multi-path ast edit" }); diff --git a/packages/coding-agent/test/tools/strip-output-notice.test.ts b/packages/coding-agent/test/tools/strip-output-notice.test.ts index d85b4e246..91c0fd9f1 100644 --- a/packages/coding-agent/test/tools/strip-output-notice.test.ts +++ b/packages/coding-agent/test/tools/strip-output-notice.test.ts @@ -45,7 +45,7 @@ const limitsOnly: OutputMeta = { describe("stripOutputNotice", () => { it("removes the exact notice appended by the wrapper for middle elision", () => { - const body = "line1\nline2\n[… 4 lines elided (215B) …]\nline7\nline8"; + const body = "line1\nline2\n[…4ln elided…]\nline7\nline8"; const notice = formatOutputNotice(truncation); const combined = body + notice; diff --git a/packages/coding-agent/test/tools/task-agent-capabilities.test.ts b/packages/coding-agent/test/tools/task-agent-capabilities.test.ts index d1d9f79ac..c187d83b4 100644 --- a/packages/coding-agent/test/tools/task-agent-capabilities.test.ts +++ b/packages/coding-agent/test/tools/task-agent-capabilities.test.ts @@ -72,8 +72,5 @@ describe("task agent capability descriptions", () => { expect(description).toContain("# read_scout — READ-ONLY (no edit/write/exec tools)\nRead-only scout"); expect(description).toContain("# full_agent\nFull agent"); expect(description).not.toContain("# full_agent — READ-ONLY"); - expect(description).toContain( - "NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore`", - ); }); }); diff --git a/packages/coding-agent/test/utils/qrcode.test.ts b/packages/coding-agent/test/utils/qrcode.test.ts new file mode 100644 index 000000000..956f98f6d --- /dev/null +++ b/packages/coding-agent/test/utils/qrcode.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { QrCode, type QrEcLevel, renderQrHalfBlocks } from "@oh-my-pi/pi-coding-agent/utils/qrcode"; + +function matrixFingerprint(qr: QrCode): string { + let bits = ""; + for (let y = 0; y < qr.size; y++) for (let x = 0; x < qr.size; x++) bits += qr.module(x, y) ? "1" : "0"; + return new Bun.CryptoHasher("sha256").update(bits).digest("hex").slice(0, 16); +} + +describe("QR encoder", () => { + // Golden vectors captured from the encoder after byte-for-byte cross-validation + // against the `qrcode` reference library (all versions/EC levels/masks) and a + // real jsQR decode. A changed hash means the symbol bytes drifted. + const vectors: ReadonlyArray<{ + text: string; + ecl: QrEcLevel; + mask: number; + version: number; + size: number; + hash: string; + }> = [ + { text: "HELLO WORLD", ecl: "M", mask: 0, version: 1, size: 21, hash: "a28227450c6dd5ab" }, + { + text: "https://my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU", + ecl: "M", + mask: 4, + version: 4, + size: 33, + hash: "4af2f66e1b06a5b1", + }, + { + text: "https://web.example/collab/#relay.example.com:8443/r/AbCdEfGhIjKlMnOp.0123456789abcdef", + ecl: "Q", + mask: 6, + version: 7, + size: 45, + hash: "2ca4a51e3cba1adf", + }, + { text: "x", ecl: "L", mask: 1, version: 1, size: 21, hash: "d1330f755ac63f88" }, + ]; + + for (const v of vectors) { + it(`produces a stable symbol for ${JSON.stringify(v.text).slice(0, 32)} (${v.ecl})`, () => { + const qr = QrCode.encodeText(v.text, v.ecl, { mask: v.mask }); + expect(qr.version).toBe(v.version); + expect(qr.size).toBe(v.size); + expect(qr.mask).toBe(v.mask); + expect(matrixFingerprint(qr)).toBe(v.hash); + }); + } + + it("auto-selects the smallest version that fits the byte payload", () => { + // Byte-mode, EC level M capacities: v1=14, v2=26, v3=42 data bytes. + expect(QrCode.encodeText("a".repeat(14), "M").version).toBe(1); + expect(QrCode.encodeText("a".repeat(15), "M").version).toBe(2); + expect(QrCode.encodeText("a".repeat(26), "M").version).toBe(2); + expect(QrCode.encodeText("a".repeat(27), "M").version).toBe(3); + }); + + it("sizes by UTF-8 byte length, not string length", () => { + // "日" is 3 UTF-8 bytes; five of them = 15 bytes, just over v1-M's 14. + expect(QrCode.encodeText("日".repeat(5), "M").version).toBe(2); + }); + + it("deterministically selects a penalty-minimizing mask when none is forced", () => { + // Auto mask is the lowest-penalty choice; locking it guards the penalty rules. + const qr = QrCode.encodeText("https://my.omp.sh/#demo", "M"); + expect(qr.mask).toBe(1); + expect(matrixFingerprint(qr)).toBe("ee820c588fe36d99"); + }); + + it("throws when the payload exceeds version 40 at the chosen EC level", () => { + // v40-H holds 1273 data bytes. + expect(() => QrCode.encodeText("a".repeat(1274), "H")).toThrow(/too long/); + }); + + it("places the three finder patterns at the symbol corners", () => { + const qr = QrCode.encodeText("finder", "M"); + // A finder is a 7x7 dark ring: the outer corner is dark and the module one + // step diagonally inward (the separating gap) is light. + const finderCorner = (ox: number, oy: number): boolean => + qr.module(ox, oy) && !qr.module(ox + (ox === 0 ? 1 : -1), oy + (oy === 0 ? 1 : -1)); + expect(finderCorner(0, 0)).toBe(true); + expect(finderCorner(qr.size - 1, 0)).toBe(true); + expect(finderCorner(0, qr.size - 1)).toBe(true); + }); +}); + +describe("renderQrHalfBlocks", () => { + it("frames the symbol in a light quiet zone wide enough for the margin", () => { + const qr = QrCode.encodeText("https://omp.sh/#demo", "M"); + const margin = 3; + const lines = renderQrHalfBlocks(qr, { margin }); + // Visible cell width = symbol + both margins. + const stripped = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + expect(stripped[0]!.length).toBe(qr.size + margin * 2); + // Top quiet rows (margin/2 paired rows) carry no dark half-blocks. + const quietRows = Math.floor(margin / 2); + for (let i = 0; i < quietRows; i++) { + expect(stripped[i]).toBe(" ".repeat(qr.size + margin * 2)); + } + // Body rows use half-block glyphs and a white-background/black-foreground prefix. + const body = lines[quietRows + 2]!; + expect(body).toContain("\x1b[47m"); + expect(body).toMatch(/[▀▄█]/); + }); + + it("emits ceil((size + 2*margin)/2) rows", () => { + const qr = QrCode.encodeText("rows", "M"); + const margin = 4; + const lines = renderQrHalfBlocks(qr, { margin }); + expect(lines.length).toBe(Math.ceil((qr.size + margin * 2) / 2)); + }); +}); diff --git a/packages/coding-agent/test/welcome-tip.test.ts b/packages/coding-agent/test/welcome-tip.test.ts index cabe6d3d8..12429ac2f 100644 --- a/packages/coding-agent/test/welcome-tip.test.ts +++ b/packages/coding-agent/test/welcome-tip.test.ts @@ -17,4 +17,44 @@ describe("renderWelcomeTip", () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it("replaces a trailing [NEW] marker with a rainbow NEW! tag", () => { + const lines = renderWelcomeTip("Try the shiny advisor [NEW]", 60); + const plain = lines.map(line => Bun.stripANSI(line)).join("\n"); + const styled = lines.join("\n"); + + expect(plain).toContain("Try the shiny advisor"); + expect(plain).not.toContain("[NEW]"); // literal marker stripped + expect(plain).toContain("NEW!"); // replaced by the visible tag + expect(styled).toContain("\x1b[1m"); // tag is bold + expect(styled).not.toBe(plain); // tag carries SGR color escapes + }); + + it("keeps the NEW! tag within the box width", () => { + // A width that leaves the wrapped body ending near the right edge forces + // the tag onto its own continuation line rather than overflowing. + for (const width of [24, 40, 60]) { + const lines = renderWelcomeTip("Turn on the advisor to review every turn [NEW]", width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.map(l => Bun.stripANSI(l)).join("\n")).toContain("NEW!"); + } + }); + + it("shimmers the tag across phases without changing visible text", () => { + const tip = "Fresh feature here [NEW]"; + const still = renderWelcomeTip(tip, 60, 0); + const shifted = renderWelcomeTip(tip, 60, 0.5); + + expect(shifted.join("\n")).not.toBe(still.join("\n")); // hues rotate + expect(shifted.map(l => Bun.stripANSI(l))).toEqual(still.map(l => Bun.stripANSI(l))); + }); + + it("leaves tips without the marker untouched", () => { + const lines = renderWelcomeTip("Plain old tip", 60); + const plain = lines.map(line => Bun.stripANSI(line)).join("\n"); + expect(plain).not.toContain("NEW!"); + expect(plain).toContain("Tip: Plain old tip"); + }); }); diff --git a/packages/coding-agent/test/workspace-tree.test.ts b/packages/coding-agent/test/workspace-tree.test.ts index c03423375..72cd0767d 100644 --- a/packages/coding-agent/test/workspace-tree.test.ts +++ b/packages/coding-agent/test/workspace-tree.test.ts @@ -120,7 +120,7 @@ describe("buildWorkspaceTree", () => { expect(tree.truncated).toBe(true); expect(tree.totalLines).toBeLessThanOrEqual(120); expect(renderedLines.length).toBeLessThanOrEqual(120); - expect(tree.rendered).toContain("lines elided beyond depth/cap"); + expect(tree.rendered).toContain("ln elided…]"); }); it("can keep root entries uncapped while truncating child directories", async () => { diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 55fa798e6..22f3eee13 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.0.10] - 2026-06-18 + +### Added + +- Added support for collab browser wrapper links whose web UI host differs from the relay host, so the connect screen joins the relay encoded in the URL fragment. + ## [16.0.5] - 2026-06-17 ### Fixed diff --git a/packages/collab-web/src/lib/link.ts b/packages/collab-web/src/lib/link.ts index 2c3ccd447..f90697689 100644 --- a/packages/collab-web/src/lib/link.ts +++ b/packages/collab-web/src/lib/link.ts @@ -157,15 +157,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1] as string; diff --git a/packages/collab-web/src/tool-render/ToolView.tsx b/packages/collab-web/src/tool-render/ToolView.tsx index dda08ff12..02268058a 100644 --- a/packages/collab-web/src/tool-render/ToolView.tsx +++ b/packages/collab-web/src/tool-render/ToolView.tsx @@ -2,6 +2,7 @@ * Tool card chrome + per-tool dispatch. Works in the collab-web app and inside * the `` web component embedded in HTML session exports. */ +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { ReactNode } from "react"; import { useState } from "react"; import { resolveToolRenderer } from "./registry"; @@ -26,11 +27,11 @@ export interface ToolViewProps { function normalizeArgs(raw: unknown): { args: Record; intent: string | undefined } { if (!isRecord(raw)) return { args: {}, intent: undefined }; - const intent = typeof raw._i === "string" ? raw._i.trim() : undefined; - if (!("_i" in raw)) return { args: raw, intent }; + const intent = typeof raw[INTENT_FIELD] === "string" ? (raw[INTENT_FIELD] as string).trim() : undefined; + if (!(INTENT_FIELD in raw)) return { args: raw, intent }; const args: Record = {}; for (const k in raw) { - if (k !== "_i") args[k] = raw[k]; + if (k !== INTENT_FIELD) args[k] = raw[k]; } return { args, intent }; } diff --git a/packages/collab-web/test/link.test.ts b/packages/collab-web/test/link.test.ts index cfe4c9fa0..ece05c2f9 100644 --- a/packages/collab-web/test/link.test.ts +++ b/packages/collab-web/test/link.test.ts @@ -82,6 +82,31 @@ describe("collab link parsing", () => { expect(local.wsUrl).toBe(`ws://localhost:7466/r/${ROOM}`); }); + it("parses custom web UI wrappers by their relay fragment", () => { + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${ROOM}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", ROOM, KEY); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + it("parses dot-joined web deep links (https:///#.)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}.${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error); @@ -89,6 +114,13 @@ describe("collab link parsing", () => { expect(parsed.key).toEqual(KEY); }); + it("parses legacy https direct relay links with key-only fragments", () => { + const parsed = parseCollabLink(`https://relay.example.com/r/${ROOM}#${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + expect(parsed.key).toEqual(KEY); + }); + it("accepts %23-mangled legacy deep links (macOS Foundation re-encoding)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}%23${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 8f7ff2334..2a3d6363e 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Changed + +- Refined documentation and prompt instructions for clarity and brevity + ## [16.0.2] - 2026-06-16 ### Fixed diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 489c2f4cb..c4fe6f1d3 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "16.0.7", + "version": "16.1.2", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 65c7ae32e..612510edf 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -1,7 +1,7 @@ Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body. -Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist. +Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search`, REQUIRED on every section — no hashless form. Create new files with `write`; hashline only edits existing files. @@ -12,33 +12,30 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro `INS.PRE N:` — insert the body rows immediately before line N. `INS.POST N:` — insert the body rows immediately after line N. `INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`. -`INS.HEAD:` — insert the body rows at the very start of the file. -`INS.TAIL:` — insert the body rows at the very end of the file. +`INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file. Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`). -Body rows appear only under a `:` header. Every body row is: - +TEXT add a new literal line `TEXT`, verbatim (leading whitespace kept). `+` alone adds a blank line. -There is NO other body row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. To insert a literal line starting with `-` or `+`, prefix it: `+-x`, `++x`. +Body rows appear only under a `:` header. Every body row is `+TEXT` — add a literal line `TEXT`, verbatim (leading whitespace kept); `+` alone adds a blank line. No other row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. To insert a literal line starting with `-` or `+`, prefix it: `+-x`, `++x`. -- Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows). -- Numbers refer to the ORIGINAL file; they do not shift as hunks apply. +- Line numbers + `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows). +- Numbers refer to the ORIGINAL file; never shift as hunks apply. - They die with the call: every applied edit mints a fresh `#TAG` and renumbers — anchor the next edit on the edit response or a fresh `read`. -- Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory of it. A hunk anchored on a line you never displayed is REJECTED — re-`read` those exact lines first. (Seeing a line ≠ it holding the code you mean: confirm the numbers map to the construct you intend, especially far from your last-read window.) -- Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M were shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first. +- Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory. A hunk anchored on a line you never displayed is REJECTED — re-`read` first. Seeing a line ≠ it holds the code you mean; confirm numbers map to the construct you intend, especially far from your read window. +- Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first. - Never start or end a range mid-expression or mid-block. - Indent body rows exactly for the depth they should live at. - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits. -- One hunk per range; the body is the final content, never an old/new pair. +- One hunk per range; body = final content, never an old/new pair. - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans. - Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`. - `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N.=M`. -- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`. +- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, last line, or a bare inner statement. Anchoring one statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`), or point N at the real opener. Saw the closer? Use plain `INS.POST M:`. - Non-adjacent changes = separate hunks; untouched lines stay out of every range. -- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair. +- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.) - NEVER format/restyle code with this tool; run the project formatter instead. @@ -90,7 +87,7 @@ SWAP.BLK 1: + print(f"Hello, {name}") ``` -A decorator or doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`: +A decorator/doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would orphan `@cache`: ``` [svc.py#C3D4] SWAP.BLK 1: @@ -117,8 +114,8 @@ SWAP 3.=3: SWAP 3.=3: + return msg -# WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2, -# but you replace 2.=4, retype the keepers in the body, and drop one (here line 4, `greet("world")`). +# WRONG — a pure insertion done as a widened `SWAP`: you want to add one line after 2, +# but you replace 2.=4, retype the keepers, and drop one (here line 4, `greet("world")`). SWAP 2.=4: + msg = "Hello, " + name + extra = compute(name) diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index f37c865bf..80c88b72e 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "16.0.7", + "version": "16.1.2", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 448a14838..9a16cea5b 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [16.0.11] - 2026-06-19 + +### Fixed + +- Fixed native shell execution reporting `pi-natives:command: syntax error at end of input` for a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The output minimizer's segmented-chain runner rebuilds each chain segment from the brush-parser AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator (`Display` drops it) and re-run as broken shell. `simple_segment` now requires every stage to be a `Display`-safe simple command, and — closing the recurring class of brush `Display` round-trip divergences (here-doc close-tag quoting, multi-byte char/byte offsets) at its root — each reconstructed segment is re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole via the unsegmented path instead of corrupting it. + ## [16.0.7] - 2026-06-18 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 3e6b07dc2..33f108f0e 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -155,7 +155,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV16_0_7(): void +export declare function __piNativesV16_1_2(): void /** * Apply conservative pre-execution rewrites to a bash command. diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index fa25149ba..506dd4f11 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -24,7 +24,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV16_0_7 = nativeBindings.__piNativesV16_0_7; +export const __piNativesV16_1_2 = nativeBindings.__piNativesV16_1_2; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; diff --git a/packages/natives/package.json b/packages/natives/package.json index 397eed995..6a2b16e7f 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "16.0.7", + "version": "16.1.2", "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index df560144a..91804552b 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -2,6 +2,55 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data + +### Changed + +- Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source +- Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage +- Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS` +- Updated OpenRouter to use standard 90-image budget +- Updated prompt instructions to clearly distinguish between plain-text and image history regions +- `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default +- Rewrote the resume summary prompt into a structured reading guide (turn headings, grid/two-column layout, ink notes) and render file operations inline as a `FILES` section instead of a spliced `` tag + +### Fixed + +- Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024 +- Improved file list formatting in compaction summaries + +## [16.0.11] - 2026-06-19 + +### Changed + +- Refined elision markers for file operations and truncated text for better display consistency +- Updated summary text for consistent descriptions of archived tool output +- Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?` + +## [16.0.8] - 2026-06-18 + +### Added + +- Added `` block wrapping for tool results to improve document structure +- Rendered thinking process as italicized blocks above assistant text +- Displayed tool call intents as `//` comments in tool call headers +- Changed conversation role markers to standard Markdown headings + +### Changed + +- Merged tool results into their corresponding tool call blocks +- Preserved prose formatting around tool calls to maintain conversation flow +- Hidden `_i` argument from tool call output when an intent is provided +- Optimized assistant turn output to group thinking and text blocks efficiently + +### Fixed + +- Fixed improper splitting of assistant messages around useless tool calls + ## [16.0.1] - 2026-06-15 ### Added diff --git a/packages/snapcompact/README.md b/packages/snapcompact/README.md index d6a4968ec..3901e50e9 100644 --- a/packages/snapcompact/README.md +++ b/packages/snapcompact/README.md @@ -49,18 +49,18 @@ Run a full compaction pass over prepared messages: ```ts import { compact } from "@oh-my-pi/snapcompact"; -const result = await compact(preparation, { model, maxFrames: 8 }); -// result.summary — text summary with operations block -// result.preserveData — frame archive, re-attachable via getPreservedArchive() + images() +const result = await compact(preparation, { model }); +// result.summary — short "resume prior conversation" lead-in, reading guide, and FILES section +// result.preserveData — bounded archive source + rendered image middle ``` ## API surface -- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images` +- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks` - **Rendering**: `render`, `renderMany`, `frames`, `geometry` - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName` - **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap` -- **Budgets**: `providerImageBudget`, `providerFrameBudget`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE` +- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES` - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations` ## References diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index b059f4927..0f2ef26b2 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "16.0.7", + "version": "16.1.2", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -33,7 +33,8 @@ "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", - "@oh-my-pi/pi-utils": "catalog:" + "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/pi-wire": "catalog:" }, "devDependencies": { "@types/bun": "catalog:" diff --git a/packages/snapcompact/src/prompts/snapcompact-summary.md b/packages/snapcompact/src/prompts/snapcompact-summary.md index 182f23d0a..286ee0c94 100644 --- a/packages/snapcompact/src/prompts/snapcompact-summary.md +++ b/packages/snapcompact/src/prompts/snapcompact-summary.md @@ -1,24 +1,24 @@ -Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}. +You are resuming a prior conversation. Its earlier turns were archived to reclaim context and are reproduced under HISTORY below, oldest to newest. Read HISTORY in full, then continue from the live conversation that follows it. -Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each — read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) — treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Think]:, [Tool Call]:, and [Tool Result]:. -{{#if mixedShapes}} +The archived transcript is compact: each turn opens with a heading — `# User ¶`, `# Assistant ¶`, or `# Tool call ¶` — assistant reasoning is wrapped in _italics_, and tool output sits inside `…`. -Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first). -{{/if}} -{{#if includedPreviousSummary}} +Reading HISTORY: +- Plain-text sections are the verbatim transcript — rely on them exactly. +{{#if frameCount}}- Some middle sections are attached as images instead of text. Each image is a page of that same transcript and belongs at its place in the reading order, between marked delimiters. Within an image, a solid black cell marks a newline and runs of spaces collapse to one. +{{#if docColumns}} - A frame holds two side-by-side columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right. +{{else}} - A frame is one grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — there is no word wrap, so a word may break across rows. +{{/if}}{{#if sentenceInk}} - Ink cycles through six colors, one per sentence. +{{/if}}{{#if stopwordDimmed}} - Function words are dim gray; content words keep full ink. +{{/if}}{{#if dimmedToolResults}} - Text inside `` is dim gray — that gray is archived tool output, not conversation. +{{/if}}{{#if lineRepeated}} - Each line is printed twice (white, then a pale-yellow band); the two copies are identical. +{{/if}}{{#if mixedShapes}} - The compressed middle frames use a smaller, denser font than the edge frames; the reading order is unchanged. +{{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns. +{{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget. +{{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing. -The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation. -{{/if}} -{{#if truncatedChars}} +{{#if files}}FILES +=================== +{{files}} -{{truncatedChars}} characters of older history were dropped to respect the frame budget. The first frame (session start) is always kept, so the missing span sits between the first frame and the next. -{{/if}} - -Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing. -{{#if textTail}} - -The frame budget ran out before the newest part of the archive. That remainder continues below as plain text — it is newer than every frame and ends where the live conversation resumes. - -[Archived history, continued as text] -{{textTail}} -{{/if}} +{{/if}}HISTORY +=================== diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 2ea94b123..228ecc3ca 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -31,14 +31,11 @@ * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is * area-proportional, so resolution cannot improve chars/$ — 1568 stays. * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent. - * - **Unknown providers** default to the Anthropic shape. Gateways can - * defeat any shape silently: OpenRouter enforces a per-model image cap - * (measured: 8 images for glm-4.6v — frames past the cap are dropped with - * no error, billed tokens plateau exactly at 8x frame cost). The same - * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so - * `providerImageBudget` caps per-request images per provider (OpenRouter - * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail - * on the summary instead of rendering frames that would be dropped. + * - **Unknown providers** default to the Anthropic shape. `providerImageBudget` + * still caps per-request images per provider so inline imaging cannot flood a + * request with attachments, but the old OpenRouter-specific 8-image cap is + * gone; routers now use the same permissive budget as direct Anthropic/Claude + * lines unless configured otherwise upstream. * * The whole pass is local and deterministic — no LLM call, no API key, no * latency beyond rendering. Rasterization and PNG encoding happen in native @@ -47,9 +44,10 @@ * re-attached to the compaction summary message on every context rebuild. */ -import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai"; +import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import { renderSnapcompactPng } from "@oh-my-pi/pi-natives"; import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils"; +import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" }; import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" }; @@ -300,6 +298,16 @@ const FAMILY_VARIANT: Record = { openai: "8on22-bw", }; +/** Denser companion variant per family for the foveated archive middle: same + * pixels (identical per-frame bill) but a tighter 8px cell, trading some + * legibility for ~40% more chars per frame so the least-important middle of a + * long archive compresses into fewer frames. */ +const FAMILY_VARIANT_LOW: Record = { + anthropic: "8on16-bw", + google: "8on16-bw", + openai: "8on16-bw", +}; + const FAMILY_SHAPE: Record = { anthropic: SHAPES.anthropic, google: SHAPES.google, @@ -376,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | " * shapes carry their own `frameSize`. */ export const FRAME_SIZE = 2576; -/** Maximum frames carried on a compaction entry. Oldest frames are dropped - * first once the budget is exceeded (mirrors how iterative text summaries - * fade the oldest detail). */ -export const MAX_FRAMES = 8; +/** Default upper bound on archive frames carried per compaction. Sized to hold + * ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000 + * billed tokens each → 80 frames) while staying under the ~100-image + * per-request wire cap. Oldest frames are dropped first once the budget is + * exceeded (mirrors how iterative text summaries fade the oldest detail); a + * caller may pass a lower `maxFrames` upper limit, and per-model context + * fitting is handled by the caller's overflow guard. */ +export const MAX_FRAMES_DEFAULT = 80; -/** Conservative per-frame token estimate used for context budgeting - * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */ -export const FRAME_TOKEN_ESTIMATE = 3300; +/** High-quality (legible) frames rendered at each chronological edge of a + * foveated archive — the session head (oldest) and the slice just before the + * text region (newest) — with the denser low-quality tier filling the middle. */ +export const HQ_EDGE_FRAMES = 3; + +/** Conservative per-frame token estimate used for context budgeting — the + * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token + * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from + * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */ +export const FRAME_TOKEN_ESTIMATE = 5024; /** - * Per-request image-count budgets by provider id. Routers and smaller - * providers enforce hard caps and silently DROP images past them (measured: - * OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens - * plateau at 8x frame cost). First-party APIs allow far more; their values - * are conservative policy caps well under the measured hard limits - * (Anthropic 100, OpenAI 500, Gemini ~2500). + * Per-request image-count budgets by provider id. These cap how many images an + * entire request may carry (archive/system-prompt/tool-result imaging combined). + * The values are conservative policy caps under the vendor hard limits + * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe + * floor rather than sending unbounded attachments. */ export const PROVIDER_IMAGE_BUDGETS: Record = { anthropic: 90, @@ -401,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record = { google: 200, "google-vertex": 200, "google-gemini-cli": 200, - openrouter: 8, + openrouter: 90, }; /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */ @@ -412,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number { return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET; } -/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */ -export function providerFrameBudget(provider: string | undefined): number { - return Math.min(MAX_FRAMES, providerImageBudget(provider)); -} - /** Key under `CompactionEntry.preserveData` holding the frame archive. */ export const PRESERVE_KEY = "snapcompact"; @@ -449,16 +462,20 @@ export interface Frame { /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */ export interface Archive { - /** Frames ordered oldest to newest. */ + /** Rendered frames ordered oldest to newest, re-derived from {@link text} + * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged + * middle). May be empty when the whole archive fits in text. */ frames: Frame[]; - /** Characters currently readable across all frames. */ + /** Characters currently readable across all frames plus the text regions. */ totalChars: number; - /** Characters dropped so far to respect the frame budget. */ + /** Characters dropped so far to respect the archive budget. */ truncatedChars: number; - /** Most recent slice of archived history that exceeded the frame budget, - * kept verbatim as normalized text (dim markers and newline glyphs - * included). Shipped as plain text in the compaction summary and folded - * back into frames by the next compaction. */ + /** Full kept archive source (oldest to newest, normalized, bounded to the + * rendered budget) — the single source re-rendered each compaction. */ + text?: string; + /** Oldest text region kept verbatim around the imaged middle. */ + textHead?: string; + /** Newest text region kept verbatim around the imaged middle. */ textTail?: string; } @@ -480,7 +497,7 @@ export interface Options extends SerializeOptions { shape?: Shape; /** Frame edge in pixels. Defaults to the shape's `frameSize`. */ frameSize?: number; - /** Frame budget. Defaults to {@link MAX_FRAMES}. */ + /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */ maxFrames?: number; } @@ -551,10 +568,17 @@ export function createFileOps(): FileOperations { edited: new Set(), }; } +const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i; + +const HEADING_MARKER = " ¶"; + +export function isUrlSchemePath(path: string): boolean { + return URL_SCHEME_RE.test(path); +} export function computeFileLists(fileOps: FileOperations): CompactionDetails { - const modified = new Set([...fileOps.edited, ...fileOps.written]); - const readFiles = [...fileOps.read].filter(file => !modified.has(file)).sort(); + const modified = new Set([...fileOps.edited, ...fileOps.written].filter(file => !isUrlSchemePath(file))); + const readFiles = [...fileOps.read].filter(file => !isUrlSchemePath(file) && !modified.has(file)).sort(); const modifiedFiles = [...modified].sort(); return { readFiles, modifiedFiles }; } @@ -577,7 +601,7 @@ function stripFileOperationTags(summary: string): string { .trimEnd(); } -function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { +function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { if (readFiles.length === 0 && modifiedFiles.length === 0) return ""; const mode = new Map(); for (const file of readFiles) mode.set(file, "Read"); @@ -585,9 +609,14 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { - files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`; + files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } - return prompt.render(fileOperationsTemplate, { files }); + return files; +} + +function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { + const files = formatFileList(readFiles, modifiedFiles, readSet); + return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : ""; } export function upsertFileOperations( @@ -650,15 +679,16 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number): const tailChars = maxChars - headChars; const elided = text.length - maxChars; const tail = tailChars > 0 ? text.slice(-tailChars) : ""; - return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`; + return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`; } const DIM_MARKERS = /[\u000e\u000f]/g; -/** Cap on the unrendered archive text tail, in frame-capacity units: enough - * to keep the newest discarded history readable without re-inflating the - * context a compaction just shrank. */ -const TEXT_TAIL_MAX_PAGES = 2; +/** Plain-text history kept verbatim at each chronological edge, in HQ-frame- + * capacity units per edge. One page at the start and one at the end preserves + * high-fidelity context around the imaged middle while keeping the total text + * budget equal to the prior 2-page tail-only scheme. */ +const TEXT_EDGE_PAGES = 1; /** Normalized archive text → plain text: drop zero-width dim toggles and * print newline glyphs as real newlines. */ @@ -679,15 +709,33 @@ export function serializeConversation(messages: Message[], options?: SerializeOp const dimToolResults = options?.dimToolResults !== false; const parts: string[] = []; - // Tool results flagged contextually useless (and their paired calls) carry - // no information worth archiving — skip the whole pair. + // Tool results flagged contextually useless (and their paired calls) carry no + // information worth archiving — skip the whole pair. Surviving results are + // indexed by tool-call id so each merges into its originating `# Tool call`. const uselessCallIds = new Set(); + const resultTextByCallId = new Map(); for (const msg of messages) { - if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) { + if (msg.role !== "toolResult") continue; + if (msg.useless === true && msg.isError !== true) { uselessCallIds.add(msg.toolCallId); + continue; } + const text = msg.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join(""); + if (text) resultTextByCallId.set(msg.toolCallId, text); } + // Wrap a raw tool-result body in an `` block, dimming only the body so + // the frame coloring keeps structure (headings, calls) loud. + const renderResultBlock = (rawText: string): string => { + const body = truncateForSummary(stripDimMarkers(rawText), toolResultMaxChars, headRatio); + return `\n${dimToolResults ? `${DIM_ON}${body}${DIM_OFF}` : body}\n`; + }; + + const mergedCallIds = new Set(); + for (const msg of messages) { if (msg.role === "user") { const content = @@ -697,22 +745,45 @@ export function serializeConversation(messages: Message[], options?: SerializeOp .filter((content): content is { type: "text"; text: string } => content.type === "text") .map(content => content.text) .join(""); - if (content) parts.push(`[User]: ${stripDimMarkers(content)}`); + if (content) parts.push(`# User${HEADING_MARKER}\n${stripDimMarkers(content)}`); } else if (msg.role === "assistant") { - const textParts: string[] = []; - const thinkingParts: string[] = []; - const toolCalls: string[] = []; + // Stream blocks in content order: buffer thinking/text, then flush a + // `# Assistant` block (thinking as italics above the text) right before + // each tool call, so text or thinking after a call stays after it. + let pendingThinking: string[] = []; + let pendingText: string[] = []; + const flushAssistant = () => { + const sections: string[] = []; + if (pendingThinking.length > 0) sections.push(`_${pendingThinking.join("\n")}_`); + if (pendingText.length > 0) sections.push(pendingText.join("\n")); + if (sections.length > 0) parts.push(`# Assistant${HEADING_MARKER}\n${sections.join("\n\n")}`); + pendingThinking = []; + pendingText = []; + }; for (const block of msg.content) { if (block.type === "text") { - textParts.push(stripDimMarkers(block.text)); + const text = stripDimMarkers(block.text); + if (text.trim()) pendingText.push(text); } else if (block.type === "thinking") { - thinkingParts.push(stripDimMarkers(block.thinking)); + const thinking = stripDimMarkers(block.thinking); + if (thinking.trim()) pendingThinking.push(thinking); } else if (block.type === "toolCall") { if (uselessCallIds.has(block.id)) continue; + flushAssistant(); const args = block.arguments as Record; + // Prefer the harness-derived intent, else the raw `_i` arg; render it as + // a one-line `//comment` and drop `_i` from the args below. + const rawIntent = + typeof block.intent === "string" + ? block.intent + : typeof args[INTENT_FIELD] === "string" + ? (args[INTENT_FIELD] as string) + : ""; + const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim(); const argsStr = truncateForSummary( Object.entries(args) + .filter(([key]) => key !== INTENT_FIELD) .map( ([key, value]) => `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`, @@ -721,30 +792,24 @@ export function serializeConversation(messages: Message[], options?: SerializeOp toolCallMaxChars, headRatio, ); - toolCalls.push(`${block.name}(${argsStr})`); + const lines = [`# Tool call${HEADING_MARKER}`]; + if (intent) lines.push(`//${intent}`); + lines.push(`${block.name}(${argsStr})`); + const resultText = resultTextByCallId.get(block.id); + if (resultText !== undefined) { + mergedCallIds.add(block.id); + lines.push(renderResultBlock(resultText)); + } + parts.push(lines.join("\n")); } } - - if (thinkingParts.length > 0) { - parts.push(`[Think]: ${thinkingParts.join("\n")}`); - } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); - } - if (toolCalls.length > 0) { - parts.push(`[Tool Call]: ${toolCalls.join("; ")}`); - } + flushAssistant(); } else if (msg.role === "toolResult") { - if (uselessCallIds.has(msg.toolCallId)) continue; - const content = msg.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map(block => block.text) - .join(""); - if (content) { - // Args above are JSON-escaped, so only raw result text can carry toggles. - const body = truncateForSummary(stripDimMarkers(content), toolResultMaxChars, headRatio); - parts.push(dimToolResults ? `[Tool Result]: ${DIM_ON}${body}${DIM_OFF}` : `[Tool Result]: ${body}`); - } + // Paired results already merged into their `# Tool call` block above; + // only orphans (call archived outside this window) render standalone. + if (uselessCallIds.has(msg.toolCallId) || mergedCallIds.has(msg.toolCallId)) continue; + const resultText = resultTextByCallId.get(msg.toolCallId); + if (resultText !== undefined) parts.push(`# Tool call${HEADING_MARKER}\n${renderResultBlock(resultText)}`); } } @@ -771,8 +836,11 @@ function stripOpenAiRemoteCompactionPreserveData( // Text normalization // ============================================================================ -/** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */ +/** Punctuation and symbol folds applied before the NFKD fallback in + * {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that + * have no compatibility decomposition (or one that is itself non-ASCII). */ const CHAR_FOLD: Record = { + // Quotation marks and primes. "\u2018": "'", "\u2019": "'", "\u201a": "'", @@ -780,18 +848,44 @@ const CHAR_FOLD: Record = { "\u201c": '"', "\u201d": '"', "\u201e": '"', + "\u2032": "'", + "\u2033": '"', + "\u2035": "'", + "\u2036": '"', + "\u2039": "<", + "\u203a": ">", + // Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions. + "\u2010": "-", + "\u2011": "-", + "\u2012": "-", "\u2013": "-", "\u2014": "-", "\u2015": "-", "\u2212": "-", + "\u2044": "/", + // Dot leaders and ellipses. + "\u2024": ".", + "\u2025": "..", "\u2026": "...", + "\u22ef": "...", + // Bullets. "\u2022": "*", + "\u2023": "*", + "\u2043": "-", + "\u2219": "*", "\u25cf": "*", "\u25a0": "*", "\u25aa": "*", + // Arrows. "\u2190": "<-", + "\u2191": "^", "\u2192": "->", + "\u2193": "v", + "\u2194": "<->", + "\u21d0": "<=", "\u21d2": "=>", + "\u21d4": "<=>", + // Check marks and crosses. "\u2713": "v", "\u2714": "v", "\u2717": "x", @@ -819,15 +913,48 @@ const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g; * combining marks the fonts cannot compose, and lone surrogates. */ const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u; +/** Combining marks NFKD splits off accented letters; dropped so the base + * letter prints without the diacritic the bundled fonts cannot compose. */ +const COMBINING_MARKS = /\p{M}+/gu; + +/** + * Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the + * compatibility form (fullwidth, super/subscripts, ligatures, circled and + * math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the + * combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual + * punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code + * point has no decomposition or still leaves an undrawable glyph, so the + * caller falls back to `?`. + */ +function foldToAscii(ch: string): string | undefined { + const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, ""); + if (decomposed === ch) return undefined; + let out = ""; + for (const part of decomposed) { + const cp = part.codePointAt(0) as number; + if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { + out += part; + continue; + } + const fold = CHAR_FOLD[part]; + if (fold === undefined) return undefined; + out += fold; + } + return out; +} + /** * Prepare text for printing: strip ANSI escape sequences, collapse horizontal * whitespace runs to single spaces and newline-bearing runs to one * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything - * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations. - * Unrenderable control/format/combining characters are dropped without - * occupying a cell; `?` remains the fallback for unsupported graphic - * characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass - * through untouched. + * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first + * through the {@link CHAR_FOLD} punctuation table, then via an NFKD + * decomposition that recovers the ASCII skeleton of compatibility characters + * (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics, + * Roman numerals, vulgar fractions). Unrenderable control/format/combining + * characters are dropped without occupying a cell; `?` remains the fallback + * for unsupported graphic characters. The zero-width ink toggles + * {@link DIM_ON}/{@link DIM_OFF} pass through untouched. */ export function normalize(text: string): string { const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; @@ -853,13 +980,56 @@ export function normalize(text: string): string { } else if (cp >= 0x2500 && cp <= 0x257f) { // Box drawing: keep table skeletons legible. out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+"; - } else if (!UNRENDERABLE.test(ch)) { - out += "?"; + } else { + const folded = foldToAscii(ch); + if (folded !== undefined) out += folded; + else if (!UNRENDERABLE.test(ch)) out += "?"; } } return out; } +/** + * Scan text to determine the proportion of graphic characters that will hit the + * `?` fallback during {@link normalize}. Used as a preflight check to abort + * snapcompact and fall back to the text summarizer when the input is heavily + * non-renderable (e.g., CJK). + */ +export function scanRenderability(text: string): { isSafe: boolean; unrenderableRatio: number } { + const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; + const collapsed = stripped + .replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : "")) + .replace(EDGE_RUNS, ""); + let totalGraphics = 0; + let fallbackCount = 0; + for (const ch of collapsed) { + const cp = ch.codePointAt(0) as number; + if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { + totalGraphics++; + continue; + } + if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { + continue; + } + const fold = CHAR_FOLD[ch]; + if (fold !== undefined) { + totalGraphics++; + } else if (cp >= 0x2500 && cp <= 0x257f) { + totalGraphics++; + } else { + const folded = foldToAscii(ch); + if (folded !== undefined) { + totalGraphics++; + } else if (!UNRENDERABLE.test(ch)) { + totalGraphics++; + fallbackCount++; + } + } + } + const unrenderableRatio = totalGraphics > 0 ? fallbackCount / totalGraphics : 0; + return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio }; +} + // ============================================================================ // Stopword dimming // ============================================================================ @@ -1082,23 +1252,31 @@ export function getPreservedArchive(preserveData: Record | unde const candidate = preserveData?.[PRESERVE_KEY]; if (!candidate || typeof candidate !== "object") return undefined; const archive = candidate as Archive; - if (!Array.isArray(archive.frames)) return undefined; - const frames = archive.frames.filter( - frame => - !!frame && - typeof frame.data === "string" && - frame.data.length > 0 && - typeof frame.mimeType === "string" && - typeof frame.cols === "number" && - typeof frame.rows === "number" && - typeof frame.chars === "number", - ); - if (frames.length === 0) return undefined; + const frames = Array.isArray(archive.frames) + ? archive.frames.filter( + frame => + !!frame && + typeof frame.data === "string" && + frame.data.length > 0 && + typeof frame.mimeType === "string" && + typeof frame.cols === "number" && + typeof frame.rows === "number" && + typeof frame.chars === "number", + ) + : []; + const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined; + const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined; + const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined; + // A text-only archive (everything fit in the plain-text regions) is valid; + // only an archive carrying neither frames nor text is empty. + if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined; return { frames, totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0, truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0, - ...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}), + ...(text !== undefined ? { text } : {}), + ...(textHead !== undefined ? { textHead } : {}), + ...(textTail !== undefined ? { textTail } : {}), }; } @@ -1111,28 +1289,176 @@ export function images(archive: Archive): ImageContent[] { ...(frame.detail ? { detail: frame.detail } : {}), })); } +/** Ordered archive blocks for a compaction summary message, oldest to newest: + * the oldest text region, the imaged middle, then the newest text region. + * Runtime-only; reconstructed from {@link Archive} on each context rebuild + * instead of persisted on the session entry. */ +export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] { + const blocks: (TextContent | ImageContent)[] = []; + const hasImages = archive.frames.length > 0; + if (archive.textHead) { + const suffix = hasImages ? "\n-------------- imaged middle below\n" : ""; + blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix }); + } + blocks.push(...images(archive)); + if (archive.textTail) { + const prefix = hasImages + ? "-------------- imaged middle above\n" + : archive.truncatedChars > 0 + ? "\n-------------- middle history omitted above\n" + : ""; + const tail = prefix + toPlainText(archive.textTail); + if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") { + (blocks[blocks.length - 1] as TextContent).text += tail; + } else { + blocks.push({ type: "text", text: tail }); + } + } + return blocks; +} // ============================================================================ // Compaction entry point // ============================================================================ +/** Denser companion of `high` for the foveated archive middle: same family and + * frame size (identical per-frame bill) but a tighter cell. Returns `high` + * unchanged for doc layouts or when no denser variant exists (foveation off). */ +function denseCompanion(high: Shape, api: Api | undefined): Shape { + if (high.columns === 2) return high; + const family = billingFamily(api); + const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family); + return geometry(low).capacity > geometry(high).capacity ? low : high; +} + +/** One planned frame: the source slice and the shape (quality tier) to render. */ +interface PlanFrame { + text: string; + shape: Shape; +} + +/** A foveated archive layout: frames oldest→newest for the imaged middle, the + * verbatim text kept at both chronological edges, the flat kept source to + * persist, and the chars dropped this round to fit the budget. */ +interface ArchiveLayout { + frames: PlanFrame[]; + textHead: string; + textTail: string; + keptText: string; + truncatedChars: number; +} + +/** Slice `text` into `capacity`-char frames at one shape (tier). */ +function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] { + const out: PlanFrame[] = []; + for (let offset = 0; offset < text.length; offset += capacity) { + out.push({ text: text.slice(offset, offset + capacity), shape }); + } + return out; +} + +/** + * Lay out the accumulated archive `text` (oldest→newest) with text at both + * chronological edges and images in the middle. One HQ-capacity stays verbatim + * at the oldest edge, one at the newest edge, and the middle between them is + * imaged. If the imaged middle itself overflows `maxFrames`, foveate it + * internally (HQ/LQ/HQ) and drop the oldest slice of its dense center. + */ +function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout { + const capHi = geometry(high).capacity; + const edgeCap = TEXT_EDGE_PAGES * capHi; + if (text.length <= 2 * edgeCap) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + if (maxFrames < 1) { + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + return { + frames: [], + textHead, + textTail, + keptText: textHead + textTail, + truncatedChars: text.length - textHead.length - textTail.length, + }; + } + + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + const imageText = text.slice(edgeCap, text.length - edgeCap); + if (imageText.length === 0) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + + // Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the + // newest pages with the session head pinned, drop the oldest middle. + if (high.columns === 2) { + const pages = docPages(imageText, geometry(high)); + let kept = pages; + let truncatedChars = 0; + if (pages.length > maxFrames) { + const dropped = pages.slice(1, pages.length - (maxFrames - 1)); + truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0); + kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))]; + } + const flat = kept.map(page => page.replaceAll("\n", " ")).join(" "); + return { + frames: kept.map(page => ({ text: page, shape: high })), + textHead, + textTail, + keptText: textHead + flat + textTail, + truncatedChars, + }; + } + + // Grid: render all-HQ when the image region fits the budget outright. + if (Math.ceil(imageText.length / capHi) <= maxFrames) { + return { + frames: sliceFrames(imageText, capHi, high), + textHead, + textTail, + keptText: textHead + imageText + textTail, + truncatedChars: 0, + }; + } + + // Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice. + const capLo = geometry(low).capacity; + const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2)); + const imageEdgeCap = imageEdgeFrames * capHi; + const imageHead = imageText.slice(0, imageEdgeCap); + const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : ""; + let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap); + let truncatedChars = 0; + const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo; + if (middleText.length > middleCap) { + truncatedChars = middleText.length - middleCap; + middleText = middleText.slice(truncatedChars); + } + return { + frames: [ + ...sliceFrames(imageHead, capHi, high), + ...sliceFrames(middleText, capLo, low), + ...sliceFrames(imageTail, capHi, high), + ], + textHead, + textTail, + keptText: textHead + imageHead + middleText + imageTail + textTail, + truncatedChars, + }; +} + /** * Run a snapcompact compaction over prepared messages. Fully local: serializes - * the discarded history, prints it onto PNG frames in the provider-optimal - * shape, merges previously archived frames (oldest dropped beyond the - * budget), and produces a deterministic summary explaining how to read the - * frames. Pages past the frame budget are never rendered (providers with - * hard image caps silently drop excess frames on the wire) — the newest - * unrendered slice survives verbatim as a text tail on the summary and is - * folded back into frames by the next compaction. + * the discarded history, appends it to the accumulated archive source text, and + * re-renders that source into an ordered history layout: plain text at the + * oldest edge, imaged middle, then plain text at the newest edge. The imaged + * middle itself foveates (HQ/LQ/HQ) when it grows large. * - * Frames archived under a different shape (provider switches, legacy 5x8 - * sessions) are kept as-is — each frame carries its own geometry, and the - * summary describes the newest shape while noting that older frames may - * differ. + * The full kept source persists on the archive (`text`) so each later compaction + * unfolds and re-renders it coherently alongside the newly archived history. * - * If the previous compaction was text-based, its summary is printed at the - * head of the frame archive as `[Summary of earlier history]` so no continuity is lost. + * If the previous compaction was text-based, its summary is printed at the head + * of the archive as `[Summary of earlier history]` so no continuity is lost. */ export async function compact( preparation: CompactionPreparation, @@ -1142,10 +1468,14 @@ export async function compact( if (!firstKeptEntryId) { throw new Error("First kept entry has no ID - session may need migration"); } - const shape = options?.shape ?? resolveShape(options?.model); - const frameSize = options?.frameSize ?? shape.frameSize; - const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES); - const geo = geometry(shape, frameSize); + const baseShape = options?.shape ?? resolveShape(options?.model); + const frameSize = options?.frameSize ?? baseShape.frameSize; + const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize }; + const low = denseCompanion(high, options?.model?.api); + const geo = geometry(high); + // The engine default caps archive growth; a caller-supplied maxFrames only + // lowers it further (an upper limit), never raising it past the default. + const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT)); const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); @@ -1160,127 +1490,103 @@ export async function compact( let truncatedChars = previousArchive?.truncatedChars ?? 0; - // The previous compaction's unframed text tail is the oldest part of this - // archive slice — prepend it so it ages into frames first. - if (previousArchive?.textTail) { - archiveText = - archiveText.length > 0 - ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}` - : previousArchive.textTail; + // Re-compacting a snapcompacted history unfolds the prior archive's source + // text and treats it as one coherent transcript: the previous kept source + // ages in ahead of the new history, then the whole thing is re-rendered. + const previousText = previousArchive?.text; + if (previousText) { + archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText; } - const pages: string[] = []; - if (shape.columns === 2) { - pages.push(...docPages(archiveText, geo)); - } else { - for (let offset = 0; offset < archiveText.length; offset += geo.capacity) { - pages.push(archiveText.slice(offset, offset + geo.capacity)); - } - } - - // Fit the merged archive into the frame budget BEFORE rendering: pages - // that cannot ship are never rasterized. Old unpinned frames evict first - // (the archive fades oldest-first, as before); new pages that still do - // not fit stay behind as a verbatim text tail instead of being dropped. - const prevFrames = previousArchive?.frames ?? []; - let keptPrev = prevFrames; - if (prevFrames.length + pages.length > maxFrames) { - // Pin the earliest frame: it anchors the session head (the original - // request, or the filmed summary of even older history) the way the - // LLM-summary strategies keep the original goal alive across rounds. - // With a budget of one frame the pin is moot. - const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0; - const evictable = prevFrames.slice(pinCount); - const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount)); - const dropped = evictable.slice(0, evictable.length - surviving); - for (const frame of dropped) truncatedChars += frame.chars; - keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)]; - } - const renderPages = pages.slice(0, maxFrames - keptPrev.length); - const tailPages = pages.slice(renderPages.length); + const layout = planArchive(archiveText, high, low, maxFrames); + truncatedChars += layout.truncatedChars; + // Re-render the planned frames, carrying any open dim span across every + // boundary: textHead → frames → textTail. + let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF); const newFrames: Frame[] = []; - const finish = pageFinisher(shape); - for (const page of renderPages) { - const rendered = render(finish(page), shape, frameSize); + for (const planned of layout.frames) { + let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text; + dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF); + if (planned.shape.stopwordDim) pageText = dimStopwords(pageText); + const rendered = render(pageText, planned.shape); newFrames.push({ data: rendered.data, mimeType: "image/png", cols: rendered.cols, rows: rendered.rows, chars: rendered.chars, - font: shape.font, - variant: shape.variant, - lineRepeat: shape.lineRepeat, - ...(shape.columns === 2 ? { columns: 2 } : {}), - ...(shape.stopwordDim ? { stopwordDim: true } : {}), - ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), + font: planned.shape.font, + variant: planned.shape.variant, + lineRepeat: planned.shape.lineRepeat, + ...(planned.shape.columns === 2 ? { columns: 2 } : {}), + ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}), + ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}), }); // Keep the event loop responsive between native render passes. await Bun.sleep(0); } - // Pages past the budget survive as text, capped at two frames' capacity - // (middle-elided) so an oversized archive cannot blow the context back up. - let textTail = ""; - if (tailPages.length > 0) { - const raw = - shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join(""); - const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity; - if (raw.length > tailCap) truncatedChars += raw.length - tailCap; - // Re-open a dim span the render boundary cut through, so the carried - // tail keeps tool output dim when it lands on frames next compaction. - const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join(""); - const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF); - textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO); - } + const textHead = layout.textHead; + const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : ""; + const textChars = textHead.length + textTail.length; - const frames = [...keptPrev, ...newFrames]; - const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0); + const frames = newFrames; + const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars; const mixedShapes = frames.some( frame => frame.cols !== geo.cols || frame.rows !== geo.rows || - (frame.variant ?? "sent") !== shape.variant || - (frame.lineRepeat ?? 1) !== shape.lineRepeat || - (frame.columns ?? 1) !== (shape.columns ?? 1) || - (frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false), + (frame.variant ?? "sent") !== high.variant || + (frame.lineRepeat ?? 1) !== high.lineRepeat || + (frame.columns ?? 1) !== (high.columns ?? 1) || + (frame.stopwordDim ?? false) !== (high.stopwordDim ?? false), ); + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + const files = formatFileList(readFiles, modifiedFiles, fileOps.read); + let summary: string; - if (frames.length === 0) { + if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) { summary = "No prior history."; } else { summary = prompt.render(snapcompactSummaryPrompt, { frameCount: frames.length, multipleFrames: frames.length > 1, - fontCell: `${shape.cellWidth}x${shape.cellHeight}`, + docColumns: high.columns === 2, cols: geo.cols, rows: geo.rows, - sentenceInk: shape.variant === "sent", - lineRepeated: shape.lineRepeat > 1, - docColumns: shape.columns === 2, - stopwordDimmed: shape.stopwordDim === true, + sentenceInk: high.variant === "sent", + stopwordDimmed: high.stopwordDim === true, dimmedToolResults: options?.dimToolResults !== false, + lineRepeated: high.lineRepeat > 1, mixedShapes, - totalChars, truncatedChars, includedPreviousSummary, - textTail: textTail.length > 0 ? toPlainText(textTail) : undefined, + files: files.length > 0 ? files : undefined, }); } - const { readFiles, modifiedFiles } = computeFileLists(fileOps); - summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read); // A snapcompact pass replaces any provider-side replacement history; strip the // OpenAI remote-compaction payload like the default summarizer path does. const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {}; - const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) }; + const persistedText = + layout.keptText.length > 0 && layout.textTail.length > 0 + ? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}` + : layout.keptText; + const archive: Archive = { + frames, + totalChars, + truncatedChars, + ...(persistedText.length > 0 ? { text: persistedText } : {}), + ...(textHead ? { textHead } : {}), + ...(textTail ? { textTail } : {}), + }; - const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : ""; + const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : ""; return { summary, - shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`, + shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles }, diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index c7fe54ec4..12686aa10 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -67,6 +67,51 @@ function makePreparation( }; } +describe("scanRenderability", () => { + it("considers pure ASCII text safe", () => { + const res = snapcompact.scanRenderability("function hello() { return 'world'; }"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); + + it("considers Latin-1 text safe", () => { + const res = snapcompact.scanRenderability("café résumé naïve"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); + + it("detects high unrenderable rates in CJK text and marks it unsafe", () => { + // Mix of ASCII and CJK: "const a = '你好世界';" + // Total graphics: ~15. Unrenderable: 4. Ratio > 5% (0.05). + const res = snapcompact.scanRenderability("const a = '你好世界';"); + expect(res.isSafe).toBe(false); + expect(res.unrenderableRatio).toBeGreaterThan(0.05); + }); + + it("ignores whitespace, ANSI, and zero-width markers in ratio calculations", () => { + // \u001b[31m is ANSI. + // \u000e \u000f are DIM markers. + const res = snapcompact.scanRenderability("\u001b[31mhello \u000e \u000f \n\t world\u001b[0m"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); +}); + +describe("computeFileLists", () => { + it("drops scheme:// URLs from legacy fileOps before rendering ", () => { + const fileOps = snapcompact.createFileOps(); + fileOps.read.add("src/read-only.ts"); + fileOps.read.add("artifact://7"); + fileOps.edited.add("src/edited.ts"); + fileOps.edited.add("conflict://1"); + fileOps.written.add("local://ctx.md"); + expect(snapcompact.computeFileLists(fileOps)).toEqual({ + readFiles: ["src/read-only.ts"], + modifiedFiles: ["src/edited.ts"], + }); + }); +}); + interface DecodedPng { width: number; height: number; @@ -185,6 +230,19 @@ describe("normalize", () => { `x ${snapcompact.DIM_ON}y${snapcompact.DIM_OFF} z`, ); }); + + it("folds compatibility characters to their ASCII skeleton via NFKD", () => { + expect(snapcompact.normalize("x⁵")).toBe("x5"); // superscript outside Latin-1 + expect(snapcompact.normalize("HELLO")).toBe("HELLO"); // fullwidth forms + expect(snapcompact.normalize("file")).toBe("file"); // fi ligature + expect(snapcompact.normalize("step ① then ②")).toBe("step 1 then 2"); // circled digits + expect(snapcompact.normalize("section Ⅻ")).toBe("section XII"); // roman numeral + expect(snapcompact.normalize("⅓ cup")).toBe("1/3 cup"); // vulgar fraction + expect(snapcompact.normalize("𝐇𝐞𝐥𝐥𝐨")).toBe("Hello"); // math-styled alphanumerics + expect(snapcompact.normalize("™ ‹q› ′ ″ ⇐ ↑")).toBe(`TM ' " <= ^`); + // No decomposition and no glyph still falls back to ? (unchanged contract). + expect(snapcompact.normalize("emoji 🎞")).toBe("emoji ?"); + }); }); describe("shape resolution", () => { @@ -459,10 +517,10 @@ describe("serializeConversation", () => { const text = `HEAD-${"x".repeat(5000)}-TAIL`; const out = snapcompact.serializeConversation([createToolResultMessage(text)]); // Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive. - expect(out).toContain("[Tool Result]: "); + expect(out).toContain(""); expect(out).toContain("HEAD-"); - expect(out).toContain("[... 3010 chars elided ...]"); - expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}`)).toBe(true); + expect(out).toContain("[…3010ch elided…]"); + expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}\n`)).toBe(true); }); it("honors configured budgets; Infinity disables a cap", () => { @@ -471,7 +529,7 @@ describe("serializeConversation", () => { toolResultMaxChars: 10, truncateHeadRatio: 0.5, }); - expect(tight).toContain("[... 90 chars elided ...]"); + expect(tight).toContain("[…90ch elided…]"); const off = snapcompact.serializeConversation([createToolResultMessage(text)], { toolResultMaxChars: Number.POSITIVE_INFINITY, }); @@ -486,7 +544,7 @@ describe("serializeConversation", () => { ]); // JSON-encoded content is 3002 chars; per-value cap 500 elides 2502. expect(out).toContain('write(path="a.ts", content='); - expect(out).toContain("[... 2502 chars elided ...]"); + expect(out).toContain("[…2502ch elided…]"); }); it("caps the whole serialized argument list per call", () => { @@ -496,24 +554,128 @@ describe("serializeConversation", () => { createAssistantMessage([{ type: "toolCall", id: "c1", name: "tool", arguments: args }]), ]); expect(out).toContain("arg0="); - expect(out).toContain("chars elided"); + expect(out).toContain("ch elided"); // 10 values x ~400 chars collapse to the 2000-char call budget plus markers. expect(out.length).toBeLessThan(2200); }); - it("wraps tool results in dim toggles by default and strips stray toggles from content", () => { + it("renders roles as markdown headings", () => { + const out = snapcompact.serializeConversation([ + createUserMessage("do the thing"), + createAssistantMessage([{ type: "text", text: "done" }]), + ]); + expect(out).toBe("# User ¶\ndo the thing\n\n# Assistant ¶\ndone"); + }); + + it("merges a tool call with its paired result into one block, _i as a // comment", () => { + const out = snapcompact.serializeConversation( + [ + createAssistantMessage([ + { type: "toolCall", id: "c1", name: "bash", arguments: { _i: "Running tests", command: "bun test" } }, + ]), + { ...createToolResultMessage("3 pass"), toolCallId: "c1" } as Message, + ], + { dimToolResults: false }, + ); + expect(out).toBe('# Tool call ¶\n//Running tests\nbash(command="bun test")\n\n3 pass\n'); + }); + + it("prefers the harness-derived intent over the raw _i arg and squashes newlines", () => { + const out = snapcompact.serializeConversation([ + createAssistantMessage([ + { + type: "toolCall", + id: "c1", + name: "bash", + arguments: { _i: "raw arg", command: "ls" }, + intent: "Derived\nintent line", + }, + ]), + ]); + expect(out).toContain("//Derived intent line"); + expect(out).not.toContain("raw arg"); + expect(out).not.toContain("_i="); + }); + + it("folds thinking into the assistant block as italics above the text", () => { + const out = snapcompact.serializeConversation([ + createAssistantMessage([ + { type: "thinking", thinking: "weigh options" }, + { type: "text", text: "the answer" }, + ]), + ]); + expect(out).toBe("# Assistant ¶\n_weigh options_\n\nthe answer"); + }); + + it("gives a thinking-only turn its own assistant heading before the tool calls", () => { + const out = snapcompact.serializeConversation([ + createAssistantMessage([ + { type: "thinking", thinking: "plan first" }, + { type: "toolCall", id: "c1", name: "read", arguments: { path: "a.ts" } }, + ]), + ]); + expect(out).toBe('# Assistant ¶\n_plan first_\n\n# Tool call ¶\nread(path="a.ts")'); + }); + + it("renders an orphan tool result (call outside the window) standalone", () => { + const out = snapcompact.serializeConversation([createToolResultMessage("ok")], { dimToolResults: false }); + expect(out).toBe("# Tool call ¶\n\nok\n"); + }); + + it("preserves content order: text before and after a tool call stay split around it", () => { + const out = snapcompact.serializeConversation( + [ + createAssistantMessage([ + { type: "text", text: "before" }, + { type: "toolCall", id: "c1", name: "read", arguments: { path: "a.ts" } }, + { type: "text", text: "after" }, + ]), + { ...createToolResultMessage("file body"), toolCallId: "c1" } as Message, + ], + { dimToolResults: false }, + ); + expect(out).toBe( + '# Assistant ¶\nbefore\n\n# Tool call ¶\nread(path="a.ts")\n\nfile body\n\n\n# Assistant ¶\nafter', + ); + }); + + it("does not split assistant prose around a useless tool call", () => { + const out = snapcompact.serializeConversation([ + createAssistantMessage([ + { type: "text", text: "before" }, + { type: "toolCall", id: "c-drop", name: "search", arguments: { pattern: "zzz" } }, + { type: "text", text: "after" }, + ]), + { ...createToolResultMessage("No matches found"), toolCallId: "c-drop", useless: true } as Message, + ]); + // The useless call vanishes and its surrounding prose stays in one block. + expect(out).toBe("# Assistant ¶\nbefore\nafter"); + }); + + it("drops blank text/thinking blocks instead of emitting an empty assistant heading", () => { + const out = snapcompact.serializeConversation( + [ + createAssistantMessage([ + { type: "thinking", thinking: " " }, + { type: "text", text: "" }, + { type: "toolCall", id: "c1", name: "read", arguments: { path: "a.ts" } }, + ]), + { ...createToolResultMessage("body"), toolCallId: "c1" } as Message, + ], + { dimToolResults: false }, + ); + expect(out).toBe('# Tool call ¶\nread(path="a.ts")\n\nbody\n'); + expect(out).not.toContain("# Assistant ¶"); + }); + + it("wraps tool-result bodies in dim toggles by default and strips stray toggles from content", () => { const out = snapcompact.serializeConversation([ createUserMessage(`hello ${snapcompact.DIM_ON}world`), createToolResultMessage("ok"), ]); - expect(out).toContain(`[Tool Result]: ${snapcompact.DIM_ON}ok${snapcompact.DIM_OFF}`); + expect(out).toContain(`\n${snapcompact.DIM_ON}ok${snapcompact.DIM_OFF}\n`); // A stray toggle in user content cannot forge a dim span. - expect(out).toContain("[User]: hello world"); - }); - - it("omits dim toggles when dimToolResults is false", () => { - const out = snapcompact.serializeConversation([createToolResultMessage("ok")], { dimToolResults: false }); - expect(out).toBe("[Tool Result]: ok"); + expect(out).toContain("# User ¶\nhello world"); }); it("skips tool call/result pairs flagged useless", () => { @@ -533,7 +695,7 @@ describe("serializeConversation", () => { }); describe("compact", () => { - it("archives history onto frames with a self-describing summary", async () => { + it("stores small archives as plain text with no frames", async () => { const fileOps = snapcompact.createFileOps(); fileOps.read.add("src/auth.ts"); fileOps.edited.add("src/login.ts"); @@ -541,164 +703,102 @@ describe("compact", () => { expect(result.firstKeptEntryId).toBe("kept-1"); expect(result.tokensBefore).toBe(99000); - // Reading instructions reflect the default (anthropic 11on16-bw) shape. - expect(result.summary).toContain("29 characters per row"); - expect(result.summary).toContain("dim gray"); - expect(result.summary).toContain("plain black ink"); - expect(result.summary).toContain("snapcompact frame"); - // File operations are upserted like every other compaction summary: - // one grouped tree with per-file access markers. - expect(result.summary).toContain("\n# src/\nauth.ts (Read)\nlogin.ts (Write)\n"); - expect(result.shortSummary).toContain("snapcompact frame"); + expect(result.summary).toContain("You are resuming a prior conversation."); + expect(result.summary).toContain("HISTORY"); + expect(result.summary).toContain("FILES\n===================\n# src/\nauth.ts (Read)\nlogin.ts (Write)"); const archive = snapcompact.getPreservedArchive(result.preserveData); expect(archive).toBeDefined(); - expect(archive?.frames.length).toBe(1); - expect(archive?.frames[0].mimeType).toBe("image/png"); - expect(archive?.frames[0].chars).toBe(archive?.totalChars); - expect(archive?.frames[0].font).toBe("8x13"); - expect(archive?.frames[0].variant).toBe("bw"); - expect(archive?.frames[0].stopwordDim).toBeUndefined(); + expect(archive?.frames).toHaveLength(0); + expect(archive?.textHead).toBeTruthy(); + expect(archive?.textTail).toBeUndefined(); expect(archive?.truncatedChars).toBe(0); - // Frame data round-trips as a decodable PNG. - const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(decoded.width).toBe(TEST_FRAME_SIZE); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe("text"); }); - it("prints tool results in dim gray ink, persisting the span across frame boundaries", async () => { - // Anthropic shape at 320px holds 800 chars/frame; a 1650-char tool - // result spans three frames, so the reopened span must dim in each. + it("carries dim tool-output spans from text into the first image frame", async () => { const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("Run the suite."), createToolResultMessage("FAIL ".repeat(330))], }), - { frameSize: TEST_FRAME_SIZE }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBeGreaterThanOrEqual(2); - for (const frame of archive?.frames ?? []) { - const decoded = decodePng(Buffer.from(frame.data, "base64")); - // Palette index 9 is the dim tool-output ink. - expect(new Set(decoded.pixels).has(9)).toBe(true); - } - // Conversation text outside the span stays in black bw ink (frame 1). - const first = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(new Set(first.pixels).has(7)).toBe(true); - expect(result.summary).toContain("dim gray ink"); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); + const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); + // Palette index 9 is the dim tool-output ink. + expect(new Set(decoded.pixels).has(9)).toBe(true); }); - it("keeps frames free of dim ink when dimToolResults is false", async () => { + it("keeps image frames free of dim ink when dimToolResults is false", async () => { const result = await snapcompact.compact( makePreparation({ - messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good")], + messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good ".repeat(200))], }), - { frameSize: TEST_FRAME_SIZE, dimToolResults: false }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2, dimToolResults: false }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); expect(new Set(decoded.pixels).has(9)).toBe(false); - expect(result.summary).not.toContain("dim gray ink"); }); - it("keeps history past the frame budget as a text tail instead of dropping it", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // Sentences avoid whitespace collapse shrinking the payload below 2.5 frames. - const longText = `${"Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28))}Tail sentinel QQZZ.`; + it("keeps plain text at both edges and images in the middle", async () => { + const longText = `HEAD sentinel AA. ${"Important fact number one. ".repeat(400)}TAIL sentinel QQZZ.`; const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // Nothing is dropped: the unrendered remainder ships as text. - expect(archive?.truncatedChars).toBe(0); - expect(archive?.textTail).toContain("QQZZ"); - expect(result.summary).toContain("[Archived history, continued as text]"); - expect(result.summary).toContain("Tail sentinel QQZZ."); - expect(result.shortSummary).toContain("chars as text"); + expect(archive?.frames).toHaveLength(5); + expect(archive?.textHead).toContain("HEAD sentinel AA"); + expect(archive?.textTail).toContain("TAIL sentinel QQZZ"); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks[0]?.type).toBe("text"); + expect((blocks[0] as { text: string }).text).toContain("imaged middle below"); + expect(blocks.at(-1)?.type).toBe("text"); + expect((blocks.at(-1) as { text: string }).text).toContain("imaged middle above"); + expect(blocks.filter(block => block.type === "image")).toHaveLength(5); }); - it("folds the previous text tail back into frames on the next compaction", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28)); - const first = await snapcompact.compact(makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }); - expect(snapcompact.getPreservedArchive(first.preserveData)?.textTail).toBeTruthy(); + it("uses three HQ image frames on each edge when the budget allows", async () => { + const hugeText = `HEAD sentinel. ${"Important fact number one. ".repeat(1000)}TAIL sentinel.`; + const result = await snapcompact.compact( + makePreparation({ messagesToSummarize: [createUserMessage(hugeText)] }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + ); + const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames).toHaveLength(7); + const hiCols = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE).cols; + const cols = archive?.frames.map(frame => frame.cols) ?? []; + expect(cols.slice(0, 3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols.slice(-3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols[3]).toBeGreaterThan(hiCols); + }); + it("re-renders later compactions from the kept source text", async () => { + const first = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [createUserMessage("A long first turn. ".repeat(500))], + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, + ); const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A short follow-up turn.")], previousSummary: first.summary, previousPreserveData: first.preserveData, }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 8 }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(second.preserveData); - // 2 carried frames + 1 new frame holding (old tail + new turn); no tail left. - expect(archive?.frames.length).toBe(3); - expect(archive?.textTail).toBeUndefined(); - expect(second.summary).not.toContain("[Archived history, continued as text]"); - }); - - it("caps the text tail and counts the elided middle as truncated", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // 6 frames of payload at a 1-frame budget → tail capped at 2 frame capacities. - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 6) / 28)); - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 1 }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(1); - expect(archive?.textTail).toContain("chars elided"); - expect(archive?.textTail?.length).toBeLessThan(capacity * 2.5); - expect(archive?.truncatedChars).toBeGreaterThan(0); - }); - - it("keeps a text tail on two-column doc shapes (wrapped pages rejoined flat)", async () => { - const geo = snapcompact.geometry(snapcompact.SHAPES.google, TEST_FRAME_SIZE); - const longText = `${"Important fact number one. ".repeat(Math.ceil((geo.capacity * 3.5) / 28))}Tail sentinel QQZZ.`; - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 2, shape: snapcompact.SHAPES.google }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // The sentinel ends the archive, so it survives any tail cap. - expect(archive?.textTail).toContain("QQZZ"); - // Wrap-induced line breaks flatten to spaces in the tail. - expect(archive?.textTail).not.toContain("\n"); - }); - - it("evicts the oldest unpinned frames, keeping the session-head frame alive", async () => { - let previous: snapcompact.CompactionResult | undefined; - let headFrameData = ""; - let secondFrameData = ""; - for (let pass = 1; pass <= 4; pass++) { - previous = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage(`Distinct turn number ${pass}.`)], - previousSummary: previous?.summary, - previousPreserveData: previous?.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 3 }, - ); - const archive = snapcompact.getPreservedArchive(previous.preserveData); - if (pass === 1) headFrameData = archive?.frames[0].data ?? ""; - if (pass === 2) secondFrameData = archive?.frames[1].data ?? ""; - } - const final = snapcompact.getPreservedArchive(previous?.preserveData); - expect(final?.frames.length).toBe(3); - // The head frame (original request) is pinned through every eviction; - // the archive fades from the middle out. - expect(final?.frames[0].data).toBe(headFrameData); - expect(final?.frames.some(frame => frame.data === secondFrameData)).toBe(false); - expect(final?.truncatedChars).toBeGreaterThan(0); + expect(archive?.text).toContain("A short follow-up turn."); + expect(archive?.textTail ?? archive?.textHead).toContain("A short follow-up turn."); + expect(archive?.frames.length).toBe(5); }); it("includes the previous text summary when the prior compaction was not snapcompact", async () => { @@ -706,13 +806,11 @@ describe("compact", () => { makePreparation({ previousSummary: "Older context: project scaffolding done." }), { frameSize: TEST_FRAME_SIZE }, ); - expect(result.summary).toContain("[Summary of earlier history]"); + expect(result.summary).toContain("condensed digest of still-older context"); }); - it("carries previous frames forward and strips the OpenAI remote payload", async () => { + it("strips the OpenAI remote payload and preserves unrelated preserveData", async () => { const first = await snapcompact.compact(makePreparation(), { frameSize: TEST_FRAME_SIZE }); - const firstArchive = snapcompact.getPreservedArchive(first.preserveData); - const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A new turn happened after the first compaction.")], @@ -726,33 +824,10 @@ describe("compact", () => { { frameSize: TEST_FRAME_SIZE }, ); - const archive = snapcompact.getPreservedArchive(second.preserveData); - expect(archive?.frames.length).toBe(2); - // Oldest frame rides along unchanged, new frame appended after it. - expect(archive?.frames[0].data).toBe(firstArchive?.frames[0].data ?? ""); - // Previous archive present → previous summary is snapcompact boilerplate, not re-archived. expect(second.summary).not.toContain("[Summary of earlier history]"); expect(second.preserveData?.openaiRemoteCompaction).toBeUndefined(); expect(second.preserveData?.appKey).toBe("kept"); }); - - it("flags mixed shapes when merged frames disagree with the active shape", async () => { - const first = await snapcompact.compact(makePreparation(), { - frameSize: TEST_FRAME_SIZE, - shape: snapcompact.SHAPES.legacy, - }); - const second = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage("Another turn after a provider switch.")], - previousSummary: first.summary, - previousPreserveData: first.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, model: { api: "anthropic-messages" } }, - ); - expect(second.summary).toContain("Older frames may use a different font"); - // Same-shape merges stay silent. - expect(first.summary).not.toContain("Older frames may use a different font"); - }); }); describe("archive helpers", () => { @@ -768,7 +843,16 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: valid })).toEqual(valid); }); - it("getPreservedArchive round-trips a persisted text tail", () => { + it("getPreservedArchive round-trips text-only and text-tail archives", () => { + const textOnly: snapcompact.Archive = { + frames: [], + totalChars: 21, + truncatedChars: 0, + text: "older history newer history", + textHead: "older history newer history", + }; + expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: textOnly })).toEqual(textOnly); + const archive: snapcompact.Archive = { frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 10 }], totalChars: 10, @@ -778,19 +862,32 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: archive })).toEqual(archive); }); - it("provider budgets respect hard image caps and clamp the frame budget", () => { - // OpenRouter silently drops images past 8 — the budget must match. - expect(snapcompact.providerImageBudget("openrouter")).toBe(8); + it("historyBlocks orders text head, imaged middle, then text tail", () => { + const archive: snapcompact.Archive = { + frames: [{ data: btoa("middle"), mimeType: "image/png", cols: 8, rows: 8, chars: 4 }], + totalChars: 40, + truncatedChars: 0, + text: "head text middle tail text", + textHead: "head text", + textTail: "tail text", + }; + const blocks = snapcompact.historyBlocks(archive); + expect(blocks.map(block => block.type)).toEqual(["text", "image", "text"]); + expect((blocks[0] as { text: string }).text).toContain("head text"); + expect((blocks[1] as { data: string }).data).toBe(btoa("middle")); + expect((blocks[2] as { text: string }).text).toContain("tail text"); + }); + + it("provider image budgets stay permissive while unknown providers keep the safe floor", () => { + expect(snapcompact.providerImageBudget("openrouter")).toBe(90); // Unknown providers fall to the safe floor. expect(snapcompact.providerImageBudget(undefined)).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); expect(snapcompact.providerImageBudget("some-new-router")).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); - // Archive frames never exceed MAX_FRAMES even on permissive providers, - // and never exceed the provider's own cap on strict ones. - expect(snapcompact.providerFrameBudget("anthropic")).toBe(snapcompact.MAX_FRAMES); - expect(snapcompact.providerFrameBudget("openrouter")).toBeLessThanOrEqual(8); - expect(snapcompact.providerFrameBudget("some-new-router")).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); expect(snapcompact.providerImageBudget("openai-codex")).toBe(200); - expect(snapcompact.providerFrameBudget("openai-codex")).toBe(snapcompact.MAX_FRAMES); + // The default frame budget must stay under the Anthropic image wire cap: + // compaction no longer clamps the archive per provider, so a default above + // the cap would silently drop frames or error on large-window Claude. + expect(snapcompact.MAX_FRAMES_DEFAULT).toBeLessThanOrEqual(snapcompact.providerImageBudget("anthropic")); }); }); diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 39bdbe834..fb58ff0f9 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [16.0.10] - 2026-06-18 + +### Changed + +- Updated description of moderated content categories to use more inclusive terminology + +### Fixed + +- Wide data tables (Requests, Errors, Overview, Projects) overflowed the page horizontally at narrow-desktop widths (768-1023px): the `.stats-table-desktop-only` wrapper used for mobile-card tables lacked the `overflow-x: auto` containment that `.stats-table-container` already has. They now scroll within their own bounds instead of spilling the page body. + ## [16.0.5] - 2026-06-17 ### Added diff --git a/packages/stats/package.json b/packages/stats/package.json index a8b4d7a98..e502640d3 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "16.0.7", + "version": "16.1.2", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/src/client/styles.css b/packages/stats/src/client/styles.css index 0cfd21d13..72474a539 100644 --- a/packages/stats/src/client/styles.css +++ b/packages/stats/src/client/styles.css @@ -729,6 +729,13 @@ display: none; } +/* Match .stats-table-container so mobile-card tables also contain horizontal + overflow instead of spilling the page body on narrow desktops (768-1023px). */ +.stats-table-desktop-only { + width: 100%; + overflow-x: auto; +} + @media (max-width: 767px) { .stats-table-desktop-only { display: none; diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 2e0b5c0c0..2022f1f9b 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -56,11 +56,11 @@ export async function initDb(): Promise { db = new Database(getStatsDbPath()); // Install the busy handler BEFORE any lock-taking statement. See // https://github.com/can1357/oh-my-pi/issues/2421. - db.exec("PRAGMA busy_timeout = 5000"); - db.exec("PRAGMA journal_mode = WAL"); + db.run("PRAGMA busy_timeout = 5000"); + db.run("PRAGMA journal_mode = WAL"); // Create tables - db.exec(` + db.run(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_file TEXT NOT NULL, @@ -132,9 +132,9 @@ export async function initDb(): Promise { const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[]; if (!messageColumns.some(column => column.name === "premium_requests")) { - db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0"); + db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0"); } - db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL"); + db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL"); // Each behavior-metric bump invalidates previously-ingested rows. We detect // the stale schema by column name and drop the table; `IF NOT EXISTS` above // already produced the new schema, but we want a clean wipe + re-ingest. @@ -159,8 +159,8 @@ export async function initDb(): Promise { const hasV4Columns = userMessageColumns.some(column => column.name === "negation"); const hasOldUserMessages = userMessageColumns.length > 0; if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) { - db.exec("DROP TABLE user_messages"); - db.exec(` + db.run("DROP TABLE user_messages"); + db.run(` CREATE TABLE user_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_file TEXT NOT NULL, @@ -787,8 +787,8 @@ function backfillUserMessages(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM user_messages"); - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM user_messages"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING); @@ -808,7 +808,7 @@ function repairUserMessageLinks(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING); @@ -830,7 +830,7 @@ function backfillPriorityPremiumRequests(database: Database): void { | undefined; if (!shouldResetBackfill(row?.value)) return; - database.exec("DELETE FROM file_offsets"); + database.run("DELETE FROM file_offsets"); database .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING); diff --git a/packages/stats/src/user-metrics.ts b/packages/stats/src/user-metrics.ts index f09bffbe9..438346729 100644 --- a/packages/stats/src/user-metrics.ts +++ b/packages/stats/src/user-metrics.ts @@ -68,7 +68,7 @@ export interface UserMessageMetrics { * Words considered profane/aggressive. Word-boundary, case-insensitive. * * Broad English coverage: f-/s-word families and their censored variants, - * mild swears, intelligence-based insults, body-part epithets, British/ + * mild swears, intelligence-based insults, body-part insults, British/ * Australian/Irish slang, religious exclamations, chat acronyms, and * frustration interjections. Curated to exclude racial, homophobic, and * other identity slurs. diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json index 0912e412a..48d1deb1b 100644 --- a/packages/swarm-extension/package.json +++ b/packages/swarm-extension/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/swarm-extension", - "version": "16.0.7", + "version": "16.1.2", "description": "Swarm orchestration extension for omp", "homepage": "https://omp.sh", "author": "Derek Rynd", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fc8c47214..ad1296c53 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,48 @@ ## [Unreleased] +## [16.1.0] - 2026-06-19 + +### Added + +- `Box` now accepts an optional `border` (box-drawing glyphs + colorizer) and exposes `setBorder()`, drawing a colored outline around its padded/background content. The border is automatically dropped at widths too narrow to frame so a bordered box never overflows its given width. + +## [16.0.11] - 2026-06-19 + +### Breaking Changes + +- Removed `getIndentation` and `getIndentationNoescape` exported utilities +- Tab-related operations no longer respect per-file or globally configured indentation settings + +### Changed + +- Standardized tab expansion to use a fixed display width instead of configurable settings +- Removed support for custom tab width configuration in text rendering and input handling + +### Fixed + +- Corrected logic in string truncation to prevent improper truncation of short strings +- Fixed a one-frame transcript flash during a non-multiplexer resize drag: while the drag borrowed the alternate screen and painted only the viewport, any ordinary (non-forced) render from a still-animating block — a tool spinner tick, a streamed token, a cursor blink — fell through to the deferred geometry-rebuild full paint, which left the alternate screen to repaint the whole transcript on the normal screen for a single frame before the next SIGWINCH re-entered the viewport fast path, so a live tool block flashed in and vanished. Ordinary renders mid-drag now stay on the viewport fast path; only forced renders (tool finalization, reset, image reconciliation) still preempt it. + +## [16.0.10] - 2026-06-18 + +### Fixed + +- Fixed Markdown renderer rendering raw HTML tags (like `
`, `
  • `, `
      `, `
        `, and `

        `) literally in the terminal by parsing and converting them to appropriate terminal formatting, preserving repeated HTML line breaks, nested HTML list indentation, ordered list numbering, paragraph-wrapped list item markers, paragraph separation, and table sizing after HTML line breaks. +- Fixed animated working-message loader frames repainting at 30fps on terminals without synchronized-output support, which could cause visible flicker during normal prompt rendering ([#2771](https://github.com/can1357/oh-my-pi/issues/2771)). + +## [16.0.9] - 2026-06-18 + +### Fixed + +- Fixed bottom-anchored fullscreen overlays keeping their body rows but clipping off footer actions when terminal-height clamping is applied, restoring plan-mode approval options on short or stale-size terminals ([#2957](https://github.com/can1357/oh-my-pi/issues/2957)). + +## [16.0.8] - 2026-06-18 + +### Fixed + +- Fixed bracketed paste under kitty+tmux leaking `[27;5;106~` escape tails throughout the pasted text (newlines became visible garbage instead of line breaks). tmux's default `extended-keys-format=xterm` re-encodes paste control bytes as `modifyOtherKeys` sequences (`ESC[27;5;~`), which the paste sanitizer did not decode — only the sibling `csi-u` form (`ESC[;5u`) was handled. Both forms are now decoded back to their literal control byte (Ctrl+J → "\n") before control-character stripping, and the decoder is shared by the multi-line editor and the single-line modal input. + ## [16.0.5] - 2026-06-17 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index e9a34d90b..62dfaa5d0 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "16.0.7", + "version": "16.1.2", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/tui/src/bracketed-paste.ts b/packages/tui/src/bracketed-paste.ts index bcf0cc34e..351d283f0 100644 --- a/packages/tui/src/bracketed-paste.ts +++ b/packages/tui/src/bracketed-paste.ts @@ -3,6 +3,43 @@ const PASTE_END = "\x1b[201~"; export type PasteResult = { handled: false } | { handled: true; pasteContent?: string; remaining: string }; +// Some terminals re-encode the control bytes inside a bracketed paste as key-event +// escape sequences (observed with tmux extended-keys passthrough under kitty). tmux +// emits one of two formats depending on `extended-keys-format`: +// - csi-u: ESC [ ; 5 u (Ctrl+J → ESC [ 106 ; 5 u) +// - xterm: ESC [ 27 ; 5 ; ~ (Ctrl+J → ESC [ 27 ; 5 ; 106 ~) +// Callers must decode these back to the literal control byte (Ctrl+J → "\n") before +// stripping control chars; otherwise ESC is dropped and the printable tail +// ("[106;5u" / "[27;5;106~") leaks into the editor. +// +// Only Ctrl+ is decoded (codepoint a-z/A-Z → 0x01..0x1A). That is the set tmux +// actually re-encodes from paste content in practice — TAB (Ctrl+I), LF (Ctrl+J), CR +// (Ctrl+M), VT (Ctrl+K), FF (Ctrl+L), … Non-letter Ctrl combos (NUL, ESC, FS-US, DEL) +// never appear as re-encoded paste bytes, so they are left untouched rather than +// synthesized into raw control bytes. Callers still strip leftover control characters +// after decoding (the editor keeps "\n"; the single-line input strips all of them). +const REENCODED_CTRL_CSI_U = /\x1b\[(\d+);5u/g; +const REENCODED_CTRL_XTERM = /\x1b\[27;5;(\d+)~/g; + +function decodeReencodedCtrlByte(match: string, code: string): string { + const cp = Number(code); + if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); // a-z → Ctrl+A..Ctrl+Z + if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); // A-Z → Ctrl+A..Ctrl+Z + return match; +} + +/** + * Decode tmux's re-encoded control bytes (both `extended-keys-format` variants) inside a + * bracketed-paste payload back to their literal byte (e.g. Ctrl+J → "\n"). Leaves the rest of + * the text untouched. Call before any control-character stripping so newlines/tabs survive + * instead of leaking the printable escape tail into the buffer. + */ +export function decodeReencodedPasteControls(text: string): string { + return text + .replace(REENCODED_CTRL_CSI_U, decodeReencodedCtrlByte) + .replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte); +} + /** * Handles bracketed paste mode buffering for terminal input components. * diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 2959147a4..1ba1c7212 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -4,10 +4,24 @@ import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../ut type Cache = { width: number; bgSample: string | undefined; + borderSample: string | undefined; childLines: (readonly string[])[]; result: string[]; }; +/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */ +export interface BoxBorder { + chars: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; + }; + color?: (text: string) => string; +} + /** * Box component - a container that applies padding and background to all children */ @@ -16,6 +30,7 @@ export class Box implements Component { #paddingX: number; #paddingY: number; #bgFn?: (text: string) => string; + #border?: BoxBorder; #ignoreTight = false; @@ -28,10 +43,11 @@ export class Box implements Component { // Cache for rendered output #cached?: Cache; - constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) { this.#paddingX = paddingX; this.#paddingY = paddingY; this.#bgFn = bgFn; + this.#border = border; } addChild(component: Component): void { @@ -72,6 +88,11 @@ export class Box implements Component { // Don't invalidate here - we'll detect bgFn changes by sampling output } + setBorder(border?: BoxBorder): void { + this.#border = border; + this.#invalidateCache(); + } + #invalidateCache(): void { this.#cached = undefined; } @@ -87,10 +108,18 @@ export class Box implements Component { const children = this.children; const count = children.length; const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX); - const contentWidth = Math.max(1, width - paddingX * 2); - // bgFn output can change without the function reference changing (theme - // mutation); sample it so a silent palette swap still misses the cache. + // A border eats one column on each side; skip it unless the interior can still + // hold the horizontal padding plus at least one content column, so a bordered + // Box never overflows the width it was given. + const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined; + const innerWidth = border ? width - 2 : width; + const contentWidth = Math.max(1, innerWidth - paddingX * 2); + // bgFn / border output can change without the function reference changing + // (theme mutation); sample both so a silent palette swap still misses the cache. const bgSample = this.#bgFn ? this.#bgFn("test") : undefined; + const borderSample = border + ? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}` + : undefined; // Render every child every frame (renders may carry side effects); the // memo only skips re-deriving the padded/background rows. Per the @@ -101,6 +130,7 @@ export class Box implements Component { cached !== undefined && cached.width === width && cached.bgSample === bgSample && + cached.borderSample === borderSample && cached.childLines.length === count; const childLines: (readonly string[])[] = new Array(count); let contentRows = 0; @@ -115,23 +145,39 @@ export class Box implements Component { const result: string[] = []; if (contentRows > 0) { const leftPad = padding(paddingX); + const interior: string[] = []; // Top padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); } // Content for (const lines of childLines) { for (const line of lines) { - result.push(this.#applyBg(leftPad + line, width)); + interior.push(this.#applyBg(leftPad + line, innerWidth)); } } // Bottom padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); + } + + if (border) { + const paint = border.color ?? (s => s); + const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth)); + const side = paint(border.chars.vertical); + result.push(paint(border.chars.topLeft + rule + border.chars.topRight)); + for (const row of interior) { + result.push(side + row + side); + } + result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight)); + } else { + for (const row of interior) { + result.push(row); + } } } - this.#cached = { width, bgSample, childLines, result }; + this.#cached = { width, bgSample, borderSample, childLines, result }; return result; } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 5a7cae44e..f3b987122 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,6 +1,6 @@ import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete"; -import { BracketedPasteHandler } from "../bracketed-paste"; +import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste"; import { getKeybindings, type KeybindingsManager } from "../keybindings"; import { extractPrintableText, matchesKey } from "../keys"; import { KillRing } from "../kill-ring"; @@ -1843,20 +1843,14 @@ export class Editor implements Component, Focusable { }); } - /** Normalize raw pasted text: decode tmux CSI-u re-encoded control bytes, normalize CRLF and + /** Normalize raw pasted text: decode tmux re-encoded control bytes (both extended-keys formats), + * normalize CRLF and * NFC (macOS NFD filename drag-drops), expand tabs, and strip control characters except newline. */ #sanitizePastedText(pastedText: string): string { - // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode - // control bytes inside bracketed paste as CSI-u Ctrl+ sequences - // (ESC [ ; 5 u). Decode those back to their literal byte so the - // per-char filter below preserves newlines instead of stripping ESC and - // leaking the printable tail (e.g. "[106;5u") into the editor. - const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { - const cp = Number(code); - if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); - if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); - return match; - }); + // Decode tmux's re-encoded control bytes (both extended-keys formats) back to + // their literal byte so the per-char filter below preserves newlines instead of + // stripping ESC and leaking the printable tail into the editor. See the decoder. + const decodedText = decodeReencodedPasteControls(pastedText); // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`) diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 3929baf37..9bcb6b5b0 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,4 +1,4 @@ -import { BracketedPasteHandler } from "../bracketed-paste"; +import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste"; import { getKeybindings } from "../keybindings"; import { extractPrintableText } from "../keys"; import { KillRing } from "../kill-ring"; @@ -375,8 +375,12 @@ export class Input implements Component, Focusable { this.#lastAction = null; this.#pushUndo(); - // Clean the pasted text — remove newlines and carriage returns, normalize - // tabs, AND normalize Unicode to NFC. + // Clean the pasted text — decode tmux's re-encoded control bytes (both + // extended-keys formats, e.g. Ctrl+J → "\n") back to literal bytes so the escape + // tail does not leak in, remove newlines/carriage returns, expand tabs, NFC-normalize, + // then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z + // (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines + // are already gone and tabs are already spaces by the time the C0/DEL strip runs. // // NFC normalization rationale: macOS Finder drag-drops file paths in NFD // (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each @@ -387,9 +391,11 @@ export class Input implements Component, Focusable { // as cursor drift past the visible filename — N×~1.5 cells for a path // with N Korean syllables. NFC normalization at paste time stores the // value in the same form everything else in the codebase assumes. - const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")).normalize( - "NFC", - ); + const cleanText = replaceTabs( + decodeReencodedPasteControls(pastedText).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""), + ) + .normalize("NFC") + .replace(/[\x00-\x1F\x7F]/g, ""); // Insert at cursor position this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor); diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 65a5c1292..387d5c4c7 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -57,12 +57,15 @@ export class Loader extends Text { this.#intervalId = setInterval(() => { const now = performance.now(); const elapsed = now - this.#lastSpinnerTick; - if (elapsed >= SPINNER_ADVANCE_MS) { + const shouldAdvanceSpinner = elapsed >= SPINNER_ADVANCE_MS; + if (shouldAdvanceSpinner) { const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS); this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length; this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS; } - this.#updateDisplay(); + if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) { + this.#updateDisplay(); + } }, intervalMs); } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 92afbb928..ef5187972 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -31,6 +31,167 @@ function isOsc66Line(line: string): boolean { return line.includes(OSC66_LINE_PREFIX); } +function normalizeHtmlEntitiesForTerminal(raw: string): string { + return raw.replace(/ /gi, " "); +} + +interface HtmlListState { + type: "ol" | "ul"; + next: number; +} + +interface HtmlNormalizationState { + lists: HtmlListState[]; + openItems: boolean[]; + itemHasContent: boolean[]; +} + +function createHtmlNormalizationState(): HtmlNormalizationState { + return { lists: [], openItems: [], itemHasContent: [] }; +} + +const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li)\b(?:\s[^>]*)?\s*\/?>/gi; + +function htmlTagName(tag: string): string { + const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag); + return match ? match[1].toLowerCase() : ""; +} + +function htmlOlStart(tag: string): number { + const match = /\bstart\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i.exec(tag); + if (!match) return 1; + return Number(match[1] ?? match[2] ?? match[3]); +} + +function appendHtmlLineBreak(output: string, force: boolean = false): string { + const trimmed = output.replace(/[ \t]+$/u, ""); + return !force && trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`; +} + +function htmlListIndent(state: HtmlNormalizationState): string { + return " ".repeat(Math.max(0, state.lists.length - 1)); +} + +function appendHtmlListBreak(output: string, state: HtmlNormalizationState): string { + const indent = htmlListIndent(state); + return output.endsWith(`${indent}\n`) ? output : appendHtmlLineBreak(output); +} + +function markCurrentHtmlItemContent(state: HtmlNormalizationState, text: string): void { + if (text.trim() !== "" && state.itemHasContent.length > 0) { + state.itemHasContent[state.itemHasContent.length - 1] = true; + } +} + +function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean { + const itemIndex = state.itemHasContent.length - 1; + return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true; +} + +function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = createHtmlNormalizationState()): string { + let output = ""; + let lastIndex = 0; + + for (const match of raw.matchAll(HTML_TAG_REGEX)) { + const tag = match[0]; + const index = match.index ?? 0; + const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index)); + // HTML formatting whitespace between block/list tags (e.g. the newlines and + // indentation in pretty-printed `

          \n
        • …`) is not rendered content; + // appending it literally would leak source indentation before bullets and + // blank rows between items. Every tag handled here is block-level, so a + // whitespace-only slice is always insignificant formatting and is dropped. + if (textBeforeTag.trim() !== "") { + output += textBeforeTag; + markCurrentHtmlItemContent(state, textBeforeTag); + } + lastIndex = index + tag.length; + + const name = htmlTagName(tag); + const isClosing = /^<\//.test(tag); + const isSelfClosing = /\/\s*>$/.test(tag); + + switch (name) { + case "br": + output = appendHtmlLineBreak(output, true); + break; + case "p": + if (isClosing) { + output = appendHtmlLineBreak(output); + } else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) { + output = appendHtmlLineBreak(output); + } + break; + case "ol": + if (isClosing) { + state.lists.pop(); + state.openItems.pop(); + state.itemHasContent.pop(); + } else if (!isSelfClosing) { + if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) { + output = appendHtmlListBreak(output, state); + } + state.lists.push({ type: "ol", next: htmlOlStart(tag) }); + state.openItems.push(false); + state.itemHasContent.push(false); + } + break; + case "ul": + if (isClosing) { + state.lists.pop(); + state.openItems.pop(); + state.itemHasContent.pop(); + } else if (!isSelfClosing) { + if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) { + output = appendHtmlListBreak(output, state); + } + state.lists.push({ type: "ul", next: 1 }); + state.openItems.push(false); + state.itemHasContent.push(false); + } + break; + case "li": { + if (isClosing) { + output = appendHtmlLineBreak(output); + break; + } + if (state.openItems.length > 0) { + const itemOpenIndex = state.openItems.length - 1; + if (state.openItems[itemOpenIndex]) output = appendHtmlListBreak(output, state); + state.openItems[itemOpenIndex] = true; + state.itemHasContent[itemOpenIndex] = false; + } else if (output.trim() !== "" && !output.endsWith("\n")) { + output = appendHtmlLineBreak(output); + } + const list = state.lists[state.lists.length - 1]; + const indent = htmlListIndent(state); + if (list?.type === "ol") { + output += `${indent}${list.next}. `; + list.next++; + } else { + output += `${indent}• `; + } + break; + } + default: + output += tag; + break; + } + } + + const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex)); + markCurrentHtmlItemContent(state, remainingText); + return output + remainingText; +} + +function splitTerminalLines(text: string): string[] { + const lines = text.split("\n"); + while (lines.length > 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + class StrictStrikethroughTokenizer extends Tokenizer { override del(src: string): Tokens.Del | undefined { const match = STRICT_STRIKETHROUGH_REGEX.exec(src); @@ -61,6 +222,56 @@ markdownParser.setOptions({ // never math. Inline extensions run before marked's escape tokenizer, so // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and // renders as a literal dollar. +const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/; +const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/; + +function getHrChar(char: string, hrChar: string): string { + const isAscii = hrChar === "-"; + switch (char) { + case "=": + return "="; + case "═": + return isAscii ? "=" : "═"; + case "━": + return isAscii ? "-" : "━"; + case "─": + return isAscii ? "-" : "─"; + case "–": + return isAscii ? "-" : "–"; + case "—": + return isAscii ? "-" : "—"; + default: + return hrChar; + } +} + +const customHrExtension: TokenizerAndRendererExtension = { + name: "customHr", + level: "block", + start(src) { + const match = CUSTOM_HR_START_REGEX.exec(src); + if (!match) return undefined; + let idx = match.index; + if (src[idx] === "\n") { + idx += 1; + } + return idx; + }, + tokenizer(src) { + const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src); + if (match) { + return { + type: "hr", + raw: match[0], + }; + } + return undefined; + }, + renderer() { + return ""; + }, +}; + const mathExtension: TokenizerAndRendererExtension = { name: "math", level: "inline", @@ -171,7 +382,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = { return (token as { text?: string }).text ?? ""; }, }; -markdownParser.use({ extensions: [mathBlockExtension, mathEnvBlockExtension, mathExtension] }); +markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension] }); // --------------------------------------------------------------------------- // Module-level LRU render cache @@ -199,16 +410,14 @@ export function clearRenderCache(): void { } // Stable numeric IDs for structural theme/style objects (no ID field on type). -// Symbol-keyed so the id travels with the object and is invisible to consumers. -const kObjectId = Symbol("markdown.objectId"); -type WithObjectId = object & { [kObjectId]?: number }; +// WeakMap-keyed so the ID matches strict object identity and doesn't get copied by spread/cloning. +const themeObjectIds = new WeakMap(); let nextObjectId = 0; function objectId(o: object): number { - const tagged = o as WithObjectId; - let id = tagged[kObjectId]; + let id = themeObjectIds.get(o); if (id === undefined) { id = nextObjectId++; - tagged[kObjectId] = id; + themeObjectIds.set(o, id); } return id; } @@ -989,17 +1198,25 @@ export class Markdown implements Component { break; } - case "hr": - lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80)))); + case "hr": { + const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : ""; + const char = raw[0] || ""; + const fillChar = getHrChar(char, this.#theme.symbols.hrChar); + lines.push(this.#theme.hr(fillChar.repeat(Math.min(width, 80)))); if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } break; + } case "html": - // Render HTML as plain text (escaped for terminal) if ("raw" in token && typeof token.raw === "string") { - lines.push(this.#applyDefaultStyle(token.raw.trim())); + const cleaned = normalizeHtmlForTerminal(token.raw); + const blockLines = splitTerminalLines(cleaned); + for (const line of blockLines) { + const trimmed = line.trimEnd(); + lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed)); + } } break; @@ -1024,31 +1241,45 @@ export class Markdown implements Component { const { applyText, stylePrefix } = resolvedStyleContext; const applyTextWithNewlines = (text: string): string => { const segments: string[] = text.split("\n"); - return segments.map((segment: string) => applyText(segment)).join("\n"); + return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n"); }; const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH; + let trimLeadingWhitespace = false; + const htmlState = createHtmlNormalizationState(); + const markHtmlItemWhenContent = (text: string): void => { + markCurrentHtmlItemContent(htmlState, text); + }; for (const token of tokens) { if (isMathToken(token)) { + markHtmlItemWhenContent(token.text); result += applyTextWithNewlines(renderMathToken(token.text)); continue; } switch (token.type) { - case "text": + case "text": { + const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text; + const text = normalizeHtmlEntitiesForTerminal(rawText); + trimLeadingWhitespace = false; + markHtmlItemWhenContent(text); + if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens)); // Text tokens in list items can have nested tokens for inline formatting if (token.tokens && token.tokens.length > 0) { result += this.#renderInlineTokens(token.tokens, resolvedStyleContext); } else { - result += renderTextWithSwatches(token.text, applyTextWithNewlines, swatchGlyph); + result += renderTextWithSwatches(text, applyTextWithNewlines, swatchGlyph); } break; + } case "paragraph": // Paragraph tokens contain nested inline tokens + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); break; case "strong": { + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); result += this.#theme.bold(boldContent) + stylePrefix; break; @@ -1056,16 +1287,19 @@ export class Markdown implements Component { case "em": { const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#theme.italic(italicContent) + stylePrefix; break; } case "codespan": { + markHtmlItemWhenContent(token.text); result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix; break; } case "link": { + markHtmlItemWhenContent(token.text); const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); const styledLinkText = this.#theme.link(this.#theme.underline(linkText)); const clickableLinkText = formatHyperlink(styledLinkText, token.href); @@ -1085,25 +1319,36 @@ export class Markdown implements Component { case "br": result += "\n"; + trimLeadingWhitespace = true; break; case "del": { const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + markHtmlItemWhenContent(plainInlineTokens(token.tokens || [])); result += this.#theme.strikethrough(delContent) + stylePrefix; break; } case "html": - // Render inline HTML as plain text if ("raw" in token && typeof token.raw === "string") { - result += applyTextWithNewlines(token.raw); + const cleaned = normalizeHtmlForTerminal(token.raw, htmlState); + result += applyTextWithNewlines(cleaned); + if (cleaned.endsWith("\n")) { + trimLeadingWhitespace = true; + } else if (cleaned.length > 0) { + trimLeadingWhitespace = false; + } } break; default: // Handle any other inline token types as plain text if ("text" in token && typeof token.text === "string") { - result += applyTextWithNewlines(token.text); + const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text; + const text = normalizeHtmlEntitiesForTerminal(rawText); + trimLeadingWhitespace = false; + markHtmlItemWhenContent(text); + result += applyTextWithNewlines(text); } } } @@ -1260,6 +1505,10 @@ export class Markdown implements Component { return Math.min(longest, maxWidth); } + #terminalLineWidths(text: string): number[] { + return splitTerminalLines(text).map(line => visibleWidth(line)); + } + /** * Wrap a table cell to fit into a column. * @@ -1267,7 +1516,8 @@ export class Markdown implements Component { * consistently with the rest of the renderer. */ #wrapCellText(text: string, maxWidth: number): string[] { - return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + const cellWidth = Math.max(1, maxWidth); + return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth)); } /** @@ -1307,13 +1557,15 @@ export class Markdown implements Component { const minWordWidths: number[] = []; for (let i = 0; i < numCols; i++) { const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext); - naturalWidths[i] = visibleWidth(headerText); + const headerLineWidths = this.#terminalLineWidths(headerText); + naturalWidths[i] = Math.max(...headerLineWidths, 0); minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth)); } for (const row of token.rows) { for (let i = 0; i < row.length; i++) { const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext); - naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); + const cellLineWidths = this.#terminalLineWidths(cellText); + naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths); minWordWidths[i] = Math.max( minWordWidths[i] || 1, this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth), diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2f175e566..23b22cde7 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1005,11 +1005,6 @@ export class TUI extends Container { // fast path (`#renderResizeViewport`) instead of an authoritative full // paint, and no commit/window/diff state is advanced. #resizeViewportActive = false; - // Set only by the resize callback's cheap-paint request. A concurrent - // caller-forced render (tool finalization, reset, image reconciliation) must - // not be downgraded to the throwaway viewport path just because a resize - // settle window is active. - #resizeViewportPaintPending = false; // Quiet-window timer that ends the drag: its callback clears the flag and // drives the one authoritative full paint. Reset on every resize event so it // only fires once the drag stops. Cancelled on stop(). @@ -1792,7 +1787,6 @@ export class TUI extends Container { // Any non-component-scoped request makes the pending frame a full one. this.#pendingRenderComponentsOnly = false; if (force) { - this.#resizeViewportPaintPending = false; // Forced repaints landing inside the multiplexer resize debounce // (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic // `requestRender(true)`) would paint into a still-reflowing pane @@ -2309,7 +2303,11 @@ export class TUI extends Container { const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight); let overlayLines = component.render(width); if (overlayLines.length > maxHeight) { - overlayLines = overlayLines.slice(0, maxHeight); + const anchor = options?.anchor ?? "center"; + overlayLines = + anchor === "bottom-left" || anchor === "bottom-center" || anchor === "bottom-right" + ? overlayLines.slice(overlayLines.length - maxHeight) + : overlayLines.slice(0, maxHeight); } const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); for (let i = 0; i < overlayLines.length; i++) { @@ -2467,16 +2465,30 @@ export class TUI extends Container { // Strictly state-isolated: it never consumes #resizeEventPending nor // advances any commit/window/diff field, so the authoritative full paint // the settle timer queues reconciles as if these throwaway frames never - // ran. A visible overlay composites over the transcript and needs the - // whole window, so fall through to the normal forced paint when one is up - // (overlay resizes are not on the drag-cost hot path). + // ran. Two render sources reach here mid-drag and BOTH must stay on this + // path: + // - the resize callback's own cheap paint after each SIGWINCH; + // - an ordinary (non-forced) render from a live block that keeps + // animating through the drag — a spinner tick, a streamed token, a + // cursor blink — firing requestRender(false)/requestComponentRender. + // #resizeEventPending is still set (the fast path never consumed it), + // so without this branch the ordinary render falls through to the + // geometry-rebuild full paint below, which LEAVES the borrowed + // alternate screen to repaint the whole transcript on the normal + // screen — then the next SIGWINCH re-enters the alt screen and paints + // only the tail, so the block flashes in for one frame and vanishes. + // A forced render (tool finalization, reset, image reconciliation) must + // still preempt: it set #forceViewportRepaintOnNextRender via + // #prepareForcedRender and owns the next authoritative paint, so it falls + // through. A visible overlay composites over the transcript and needs the + // whole window, so it also falls through (overlay resizes are not on the + // drag-cost hot path). if ( - this.#resizeViewportPaintPending && this.#resizeViewportActive && + !this.#forceViewportRepaintOnNextRender && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined ) { - this.#resizeViewportPaintPending = false; this.#componentRenderTargets.clear(); this.#renderResizeViewport(width, height); return; @@ -3141,7 +3153,6 @@ export class TUI extends Container { #requestResizeViewportPaint(): void { if (this.#stopped) return; - this.#resizeViewportPaintPending = true; this.#renderRequested = false; this.#lastRenderAt = this.#renderScheduler.now(); this.#doRender(); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index d38dd3353..3ff0d8a1f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -7,11 +7,11 @@ import { wrapTextWithAnsi as nativeWrapTextWithAnsi, type SliceResult, } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export { Ellipsis } from "@oh-my-pi/pi-natives"; -export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export type TextSizingScale = 1 | 2 | 3; export type TextSizingVerticalAlign = "top" | "bottom" | "center"; @@ -74,35 +74,32 @@ export function encodeTextSized(text: string, options: TextSizingOptions = {}): } export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult { - return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth()); + return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH); } export function truncateToWidth( text: string, maxWidth: number, - ellipsisKind?: Ellipsis | null, + ellipsisKind?: Ellipsis | null | "", pad?: boolean | null, ): string { - // Guard nullish napi inputs: napi-rs 3 on the Windows prebuilt rejects - // `null` for `Option` (Ellipsis) / `Option` (pad) (issue #848), - // and `maxWidth` is a required `u32` that throws on `null`/`undefined` - // everywhere. Pass concrete defaults that mirror the Rust `unwrap_or`s. - const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0; - let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind; - if (typeof resolvedEllipsis === "string") { - resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode; + maxWidth = Math.max(0, maxWidth | 0); + // Fast path: every UTF-16 unit is at most 3 cells wide, so a string whose + // `length * 3` already fits within `safeWidth` cannot need truncation. + if (!pad && text.length * 3 <= maxWidth) { + return text; } return nativeTruncateToWidth( text, - safeWidth, - resolvedEllipsis ?? Ellipsis.Unicode, + maxWidth, + (ellipsisKind === "" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode, pad ?? false, - getDefaultTabWidth(), + DEFAULT_TAB_WIDTH, ); } export function wrapTextWithAnsi(text: string, width: number): string[] { - return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth()); + return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH); } export function extractSegments( @@ -112,24 +109,17 @@ export function extractSegments( afterLen: number, strictAfter: boolean, ): ExtractSegmentsResult { - return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth()); + return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH); } // Pre-allocated space buffer for padding const SPACE_BUFFER = " ".repeat(512); -/** - * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths. - */ -export function getIndentationNoescape(file?: string): number { - return getIndentation(file, process.cwd()); -} - /* - * Replace tabs with configured spacing for consistent rendering. + * Replace tabs with the fixed display tab width for consistent rendering. */ -export function replaceTabs(text: string, file?: string): string { - return text.replaceAll("\t", " ".repeat(getIndentation(file))); +export function replaceTabs(text: string): string { + return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH)); } /** @@ -186,7 +176,7 @@ export function visibleWidth(str: string): number { for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) { tabCount++; } - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; return width; } @@ -203,7 +193,7 @@ export function visibleWidth(str: string): number { } } if (i === str.length) { - return tabCount === 0 ? str.length : str.length + tabCount * (getDefaultTabWidth() - 1); + return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1); } if (tabCount === 0) { @@ -224,7 +214,7 @@ export function visibleWidth(str: string): number { // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables. let width = Bun.stringWidth(str, STRING_WIDTH_OPTS); - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; // OSC 66: add back each stripped span as `scale * (explicit w ?? payload // width)`. Matched rather than replaced to avoid reallocating the string. diff --git a/packages/tui/test/box-border.test.ts b/packages/tui/test/box-border.test.ts new file mode 100644 index 000000000..68bbe4307 --- /dev/null +++ b/packages/tui/test/box-border.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { Box, type BoxBorder, Text } from "@oh-my-pi/pi-tui"; + +const CHARS: BoxBorder["chars"] = { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", +}; + +function borderedBox(border?: Partial): Box { + // paddingY 0 keeps the row count predictable; ignoreTight pins paddingX to exactly 1. + // Real SGR escapes so Bun.stripANSI removes both bg and border color before width checks. + const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, { + chars: CHARS, + color: t => `\x1b[31m${t}\x1b[39m`, + ...border, + }); + box.setIgnoreTight(true); + box.addChild(new Text("hi", 0, 0)); + return box; +} + +const widths = (rows: readonly string[]): number[] => rows.map(r => Bun.stringWidth(Bun.stripANSI(r))); +const plain = (rows: readonly string[]): string[] => rows.map(r => Bun.stripANSI(r)); + +describe("Box border", () => { + it("frames content without exceeding the given width", () => { + const rows = borderedBox().render(20); + // top rule + single content row + bottom rule + expect(rows.length).toBe(3); + for (const w of widths(rows)) expect(w).toBe(20); + + const flat = plain(rows); + expect(flat[0]).toBe(`+${"-".repeat(18)}+`); + expect(flat[2]).toBe(`+${"-".repeat(18)}+`); + // Interior rows are wrapped by the vertical glyph on both edges. + expect(flat[1]!.startsWith("|")).toBe(true); + expect(flat[1]!.endsWith("|")).toBe(true); + expect(flat[1]).toContain("hi"); + }); + + it("paints border glyphs with the supplied colorizer", () => { + const rows = borderedBox().render(20); + // The top rule is emitted through the color fn (red SGR), the interior is not. + expect(rows[0]).toContain("\x1b[31m"); + }); + + it("recomputes width when the border is toggled off", () => { + const box = borderedBox(); + expect(widths(box.render(20))).toEqual([20, 20, 20]); + + box.setBorder(undefined); + const rows = box.render(20); + // No border rows now; the single content row fills the full width. + expect(rows.length).toBe(1); + expect(Bun.stringWidth(Bun.stripANSI(rows[0]!))).toBe(20); + expect(Bun.stripANSI(rows[0]!)).not.toContain("+"); + }); + + it("frames at the boundary width without overflowing", () => { + // paddingX 1 → the border needs width >= 5 (2 borders + 2 padding + 1 content col). + const rows = borderedBox().render(5); + for (const w of widths(rows)) expect(w).toBe(5); + expect(plain(rows)[0]).toBe(`+${"-".repeat(3)}+`); + }); + + it("drops the border when the interior can't fit padding + content", () => { + for (const width of [3, 4]) { + const rows = borderedBox().render(width); + for (const w of widths(rows)) expect(w).toBeLessThanOrEqual(width); + for (const line of plain(rows)) expect(line).not.toContain("+"); + } + }); +}); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index cf9593519..6ab2b78ca 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -6,7 +6,6 @@ import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { defaultEditorTheme } from "./test-themes"; describe("Editor component", () => { @@ -545,16 +544,10 @@ describe("Editor component", () => { expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß"); }); - it("uses the configured tab width when loading text programmatically", () => { + it("expands tabs to the fixed display width when loading text programmatically", () => { const editor = new Editor(defaultEditorTheme); - - try { - setDefaultTabWidth(5); - editor.setText("foo\tbar"); - expect(editor.getText()).toBe("foo bar"); - } finally { - setDefaultTabWidth(3); - } + editor.setText("foo\tbar"); + expect(editor.getText()).toBe("foo bar"); }); it("strips control characters from programmatically loaded text before render", () => { @@ -1829,6 +1822,22 @@ describe("Editor component", () => { expect(editor.getText()).toBe("abc\ndef"); }); + it("decodes tmux xterm-format re-encoded control bytes in bracketed paste (kitty+tmux)", () => { + const editor = new Editor(defaultEditorTheme); + // tmux extended-keys-format=xterm (the default under kitty) re-encodes the + // newline (Ctrl+J) inside the paste as ESC[27;5;106~. It must land as a real + // newline, not leak the literal escape tail "[27;5;106~" into the buffer. + editor.handleInput("\x1b[200~line1\x1b[27;5;106~line2\x1b[201~"); + expect(editor.getText()).toBe("line1\nline2"); + }); + + it("decodes tmux csi-u-format re-encoded control bytes in bracketed paste", () => { + const editor = new Editor(defaultEditorTheme); + // tmux extended-keys-format=csi-u re-encodes the newline (Ctrl+J) as ESC[106;5u. + editor.handleInput("\x1b[200~line1\x1b[106;5uline2\x1b[201~"); + expect(editor.getText()).toBe("line1\nline2"); + }); + it("undoes the last paste when a transient #undo trigger is executed", () => { const editor = new Editor(defaultEditorTheme); diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 8f79afa8d..9311cab60 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -3,7 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); @@ -155,7 +155,7 @@ describe("Input component", () => { setKittyProtocolActive(false); }); - it("normalizes tabs in buffered bracketed paste using configured indentation", () => { + it("normalizes tabs in buffered bracketed paste using the fixed display width", () => { const input = setupAtEnd(""); input.handleInput("\x1b[200~a\t"); @@ -165,7 +165,22 @@ describe("Input component", () => { expect(input.getValue()).toBe(""); input.handleInput("c\x1b[201~"); - expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`); + expect(input.getValue()).toBe(`a${" ".repeat(DEFAULT_TAB_WIDTH)}bc`); + }); + + it("decodes tmux re-encoded control bytes in bracketed paste without leaking tails or storing raw C0", () => { + // Regression: kitty+tmux (extended-keys-format=xterm) re-encodes the newline + // (Ctrl+J) inside a paste as ESC[27;5;106~. For a single-line input the newline + // is stripped, but the escape tail "[27;5;106~" must never leak in as text. + const input = setupAtEnd(""); + input.handleInput("\x1b[200~ab\x1b[27;5;106~cd\x1b[201~"); + expect(input.getValue()).toBe("abcd"); + + // A non-newline re-encoded control (Ctrl+A → 0x01) must be stripped, not stored + // as a raw control byte in the single-line value. + const input2 = setupAtEnd(""); + input2.handleInput("\x1b[200~x\x1b[27;5;97~y\x1b[201~"); + expect(input2.getValue()).toBe("xy"); }); it("never renders a line wider than the terminal width (wide chars)", () => { diff --git a/packages/tui/test/loader.test.ts b/packages/tui/test/loader.test.ts index 77c341a1f..e200e1b24 100644 --- a/packages/tui/test/loader.test.ts +++ b/packages/tui/test/loader.test.ts @@ -92,7 +92,7 @@ describe("Loader component", () => { it("requests render when animated message bytes change between spinner frames", () => { vi.useFakeTimers(); setSystemTime(new Date(1_000)); - const ui = { requestComponentRender: vi.fn() } as unknown as TUI; + const ui = { synchronizedOutput: true, requestComponentRender: vi.fn() } as unknown as TUI; const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true }; colorMessage.animated = true; const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0"]); @@ -106,6 +106,26 @@ describe("Loader component", () => { loader.stop(); }); + it("holds animated message-only frames when synchronized output is unavailable", () => { + vi.useFakeTimers(); + setSystemTime(new Date(1_000)); + const ui = { synchronizedOutput: false, requestComponentRender: vi.fn() } as unknown as TUI; + const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true }; + colorMessage.animated = true; + const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0", "1"]); + + expect(ui.requestComponentRender).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(34); + expect(ui.requestComponentRender).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(67); + expect(ui.requestComponentRender).toHaveBeenCalledTimes(2); + expect(loader.render(40).join("\n")).toContain("1 Checking-"); + + loader.stop(); + }); + it("dispose() stops the animation so no further renders are scheduled", async () => { const term = new VirtualTerminal(20, 4); const tui = new TUI(term); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index ea7c3baff..ecb13a8c2 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -752,6 +752,73 @@ again, hello world`, }); }); + describe("Custom section dividers", () => { + it("should render Unicode light line ───────────── as a horizontal rule with ─", () => { + const unicodeTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "─", + }, + }; + const markdown = new Markdown("─────────────", 0, 0, unicodeTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("─".repeat(80)); + }); + + it("should render double line ============ as a horizontal rule with =", () => { + const markdown = new Markdown("============", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("=".repeat(80)); + }); + + it("should render em dash line ——— as a horizontal rule with —", () => { + const unicodeTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "─", + }, + }; + const markdown = new Markdown("———", 0, 0, unicodeTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("—".repeat(80)); + }); + + it("should render em dash line ——— as a horizontal rule with - in ASCII theme", () => { + const asciiTheme = { + ...defaultMarkdownTheme, + symbols: { + ...defaultMarkdownTheme.symbols, + hrChar: "-", + }, + }; + const markdown = new Markdown("———", 0, 0, asciiTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines[0]).toBe("-".repeat(80)); + }); + + it("should preserve Setext H1 headings for Title\\n===", () => { + const markdown = new Markdown("Title\n===", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines.some(line => line.includes("Title"))).toBeTruthy(); + expect(plainLines.includes("=".repeat(80))).toBeFalsy(); + }); + + it("should preserve Setext H2 headings for Title\\n---", () => { + const markdown = new Markdown("Title\n---", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map(line => stripVTControlCharacters(line).trimEnd()); + expect(plainLines.some(line => line.includes("Title"))).toBeTruthy(); + expect(plainLines.includes("─".repeat(80))).toBeFalsy(); + }); + }); + describe("Spacing after headings", () => { it("should have only one blank line between heading and following paragraph", () => { const markdown = new Markdown( @@ -1435,6 +1502,177 @@ describe("Markdown.render reference stability", () => { expect(narrow.every(line => visibleWidth(line) <= 30)).toBe(true); expect(wide.every(line => visibleWidth(line) <= 60)).toBe(true); }); + + it("formats common HTML tags inside table cells", () => { + const md = new Markdown( + "| Gemini result |\n| --- |\n|
          • None. Static checks
            are green.
          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const bulletLineIndex = lines.findIndex(line => line.includes("• None. Static checks")); + const continuationLineIndex = lines.findIndex(line => line.includes("are green.")); + + expect(lines.some(line => /<\/?(?:br|ul|li)\b/i.test(line))).toBe(false); + expect(bulletLineIndex).toBeGreaterThan(-1); + expect(continuationLineIndex).toBeGreaterThan(bulletLineIndex); + }); + + it("preserves separators between adjacent HTML tags inside table cells", () => { + const md = new Markdown( + "| Result |\n| --- |\n|
          • First

          Second result.

          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.findIndex(line => line.includes("• First")); + const secondLineIndex = lines.findIndex(line => line.includes("Second result.")); + + expect(lines.some(line => /<\/?(?:p|ul|li)\b| /i.test(line))).toBe(false); + expect(firstLineIndex).toBeGreaterThan(-1); + expect(secondLineIndex).toBeGreaterThan(firstLineIndex); + }); + + it("preserves ordered HTML list numbering", () => { + const md = new Markdown("
          1. First
          2. Second
          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("1. First"); + expect(lines).toContain("2. Second"); + expect(lines.some(line => line.includes("• First") || line.includes("• Second"))).toBe(false); + }); + + it("keeps HTML list markers with paragraph-wrapped list text", () => { + const unordered = new Markdown("
          • First

          ", 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + const ordered = new Markdown('
          1. Third

          ', 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + const table = new Markdown("| Result |\n| --- |\n|
          • First

          |", 0, 0, defaultMarkdownTheme) + .render(80) + .map(line => stripVTControlCharacters(line).trimEnd()); + + expect(unordered).toContain("• First"); + expect(unordered).not.toContain("•"); + expect(unordered).not.toContain("First"); + expect(ordered).toContain("3. Third"); + expect(ordered).not.toContain("3."); + expect(ordered).not.toContain("Third"); + expect(table).toContain("| • First |"); + expect(table).not.toContain("| • |"); + }); + + it("fits table columns to split HTML lines", () => { + const md = new Markdown("| Result |\n| --- |\n|
          • Pass
            OK
          |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const topBorder = lines.find(line => line.startsWith("+")); + + expect(topBorder).toBe("+--------+"); + expect(lines).toContain("| • Pass |"); + expect(lines).toContain("| OK |"); + }); + + it("preserves repeated HTML line breaks as intentional blank spacing", () => { + const cases = ["First

          Second", "First

          Second"]; + + for (const input of cases) { + const md = new Markdown(input, 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.indexOf("First"); + + expect(firstLineIndex).toBeGreaterThan(-1); + expect(lines[firstLineIndex + 1]).toBe(""); + expect(lines[firstLineIndex + 2]).toBe("Second"); + } + }); + + it("preserves repeated HTML line breaks inside table cells", () => { + const md = new Markdown("| Result |\n| --- |\n| First

          Second |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + const firstLineIndex = lines.findIndex(line => line.includes("| First")); + + expect(firstLineIndex).toBeGreaterThan(-1); + expect(lines[firstLineIndex + 1]).toContain("| |"); + expect(lines[firstLineIndex + 2]).toContain("| Second |"); + }); + + it("indents nested HTML list items by list stack depth", () => { + const md = new Markdown( + '
          • Parent
            • Child
            • Second child
            1. Ordered child
          • Sibling
          ', + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("• Parent"); + expect(lines).toContain(" • Child"); + expect(lines).toContain(" • Second child"); + expect(lines).toContain(" 3. Ordered child"); + expect(lines).toContain("• Sibling"); + expect(lines).not.toContain("• Child"); + expect(lines).not.toContain("• Second child"); + expect(lines).not.toContain("3. Ordered child"); + }); + + it("indents nested HTML list items inside table cells", () => { + const md = new Markdown( + "| Result |\n| --- |\n|
          • Parent
            • Child
          |", + 0, + 0, + defaultMarkdownTheme, + ); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines.some(line => line.includes("| • Parent"))).toBe(true); + expect(lines.some(line => line.includes("| • Child"))).toBe(true); + expect(lines.some(line => line.includes("| • Child"))).toBe(false); + }); + + it("does not emit ANSI-only lines for empty styled HTML replacements", () => { + const md = new Markdown("

          Visible", 0, 0, defaultMarkdownTheme, { + color: text => chalk.gray(text), + italic: true, + }); + const lines = md.render(80); + + const blankLines = lines.filter(line => stripVTControlCharacters(line).trimEnd() === ""); + expect(blankLines.length).toBeGreaterThan(0); + expect(blankLines.every(line => !line.includes("\x1b["))).toBe(true); + expect(lines.some(line => stripVTControlCharacters(line).trimEnd() === "Visible")).toBe(true); + }); + + it("decodes non-breaking spaces in table text", () => { + const md = new Markdown("| Result |\n| --- |\n| A B |", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines.some(line => line.includes("A B"))).toBe(true); + expect(lines.some(line => line.includes(" "))).toBe(false); + }); + + it("separates paragraph HTML tags instead of concatenating text", () => { + const md = new Markdown("

          First result.

          Second result.

          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + expect(lines).toContain("First result."); + expect(lines).toContain("Second result."); + expect(lines).not.toContain("First result.Second result."); + }); + + it("drops HTML formatting whitespace between pretty-printed list tags", () => { + const md = new Markdown("
            \n
          • First
          • \n
          • Second
          • \n
          ", 0, 0, defaultMarkdownTheme); + const lines = md.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + // Source indentation must not leak in front of the bullets, and the + // formatting newlines between items must not become blank rows. + expect(lines).toContain("• First"); + expect(lines).toContain("• Second"); + expect(lines.some(line => line.startsWith(" ") && line.includes("•"))).toBe(false); + expect(lines.filter(line => line === "").length).toBe(0); + }); }); describe("Math rendering", () => { diff --git a/packages/tui/test/overlay-scroll.test.ts b/packages/tui/test/overlay-scroll.test.ts index 915d5092c..2b760443c 100644 --- a/packages/tui/test/overlay-scroll.test.ts +++ b/packages/tui/test/overlay-scroll.test.ts @@ -202,6 +202,28 @@ describe("TUI overlays", () => { tui.stop(); }); + it("preserves bottom-anchored overlay actions when clamped", async () => { + const term = new VirtualTerminal(80, 5); + const tui = new TUI(term); + + tui.addChild(new LineComponent("base-", 1)); + + try { + tui.start(); + await flushRender(term); + + tui.showOverlay(new LineComponent("ov-", 10), { anchor: "bottom-center", width: "100%", maxHeight: "100%" }); + await flushRender(term); + + const viewport = term.getViewport().join("\n"); + expect(viewport).toContain("ov-5"); + expect(viewport).toContain("ov-9"); + expect(viewport).not.toContain("ov-0"); + } finally { + tui.stop(); + } + }); + it("clears stale viewport content on launch", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index c3ef58b83..a0ceac04d 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -57,7 +57,7 @@ async function withEnvPatch(patch: Record, run: ( class DeferScheduler implements RenderScheduler { #time = 0; #immediates: (() => void)[] = []; - #renders = new Map void>(); + #renders = new Map void; delay: number }>(); #nextId = 0; now(): number { @@ -69,9 +69,9 @@ class DeferScheduler implements RenderScheduler { this.#immediates.push(callback); } - scheduleRender(callback: () => void, _delayMs: number): RenderTimer { + scheduleRender(callback: () => void, delayMs: number): RenderTimer { const id = this.#nextId++; - this.#renders.set(id, callback); + this.#renders.set(id, { run: callback, delay: delayMs }); return { cancel: () => { this.#renders.delete(id); @@ -94,6 +94,28 @@ class DeferScheduler implements RenderScheduler { await term.flush(); } + // Fire immediates plus any throttled render whose delay is below `maxDelayMs`, + // leaving longer timers (the 120ms resize settle) pending. Lets a test drive + // an interleaved ordinary render — a spinner tick / streamed token, scheduled + // at the ~33ms render throttle — mid-drag without ending the drag. + async flushOrdinaryRenders(term: VirtualTerminal, maxDelayMs = 100): Promise { + let rounds = 0; + for (;;) { + if (++rounds > 100) throw new Error("ordinary renders did not settle"); + const immediates = this.#immediates; + this.#immediates = []; + for (const callback of immediates) callback(); + if (this.#immediates.length > 0) continue; + const due = [...this.#renders.entries()].filter(([, entry]) => entry.delay < maxDelayMs); + if (due.length === 0) break; + for (const [id, entry] of due) { + this.#renders.delete(id); + entry.run(); + } + } + await term.flush(); + } + async flushAll(term: VirtualTerminal): Promise { let rounds = 0; while (this.#immediates.length > 0 || this.#renders.size > 0) { @@ -104,7 +126,7 @@ class DeferScheduler implements RenderScheduler { if (this.#immediates.length > 0) continue; const renders = [...this.#renders.values()]; this.#renders.clear(); - for (const callback of renders) callback(); + for (const entry of renders) entry.run(); } await term.flush(); } @@ -269,6 +291,53 @@ describe("non-multiplexer resize viewport fast path", () => { }); }); + it("keeps an interleaved live-block render on the viewport fast path instead of flashing a normal-screen full paint", async () => { + await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const { tui, scheduler } = makeTui(term); + try { + tui.start(); + await scheduler.flushImmediates(term); + + // One drag SIGWINCH enters the fast path and borrows the alt screen. + term.resize(60, 10); + await scheduler.flushImmediates(term); + expect(tui.resizeViewportActive).toBe(true); + + const baselineFull = tui.fullRedraws; + const baselinePaints = tui.resizeViewportPaints; + const writes = captureWrites(term); + + // A live block keeps animating mid-drag: a spinner tick / streamed + // token fires an ordinary (non-forced) render before the 120ms settle + // elapses. It must stay on the viewport fast path. Without the guard it + // falls through to the geometry-rebuild full paint, which leaves the + // borrowed alternate screen (ALT_SCREEN_EXIT) and erases native + // scrollback (ED3) to repaint the whole transcript on the normal screen + // for one frame — the flash — before the next SIGWINCH hides it again. + tui.requestRender(); + await scheduler.flushOrdinaryRenders(term); + + // Still mid-drag, still on the alternate screen: a viewport-only paint, + // no authoritative full redraw, no scrollback erase, no alt-screen exit. + expect(tui.resizeViewportActive).toBe(true); + expect(tui.resizeViewportPaints).toBeGreaterThan(baselinePaints); + expect(tui.fullRedraws).toBe(baselineFull); + expect(writes.join("")).not.toContain(ALT_SCREEN_EXIT); + expect(eraseScrollbackCount(writes)).toBe(0); + + // The settle still fires exactly one authoritative full paint once the + // drag goes quiet — the interleaved render did not consume or corrupt + // the deferred geometry rebuild. + await scheduler.flushAll(term); + expect(tui.resizeViewportActive).toBe(false); + expect(tui.fullRedraws).toBe(baselineFull + 1); + } finally { + tui.stop(); + } + }); + }); + it("does not leave a pending settle paint after stop()", async () => { await withEnvPatch(NO_MULTIPLEXER_ENV, async () => { const term = new VirtualTerminal(40, 10, 1000); diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts index d1bfd8faa..98afd6a67 100644 --- a/packages/tui/test/visible-width.test.ts +++ b/packages/tui/test/visible-width.test.ts @@ -12,12 +12,12 @@ */ import { describe, expect, it } from "bun:test"; import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { DEFAULT_TAB_WIDTH, visibleWidth } from "@oh-my-pi/pi-tui/utils"; const ESC = "\x1b"; const ST = "\x1b\\"; const BEL = "\x07"; -const TAB = getDefaultTabWidth(); +const TAB = DEFAULT_TAB_WIDTH; describe("visibleWidth — parity with the native width engine", () => { const corpus: [string, string][] = [ diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c17e2869a..61409767f 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,32 @@ ## [Unreleased] +## [16.1.2] - 2026-06-19 + +### Added + +- Added `directoryExists(dir)` to `dirs`: resolves whether a path is an existing directory, returning `false` on any stat failure (ENOENT, permission, non-directory). Lets callers check a directory is safe to `chdir` into before `setProjectDir` throws. + +### Removed + +- Removed the public `createAbortableStream` API from `@oh-my-pi/pi-utils`. Consumers should use the lighter, direct-reader `abortableSource` async generator inside `@oh-my-pi/pi-utils/stream` to avoid the extra ReadableStream wrapper layer and per-chunk enqueue overhead. + +## [16.0.11] - 2026-06-19 + +### Removed + +- Removed `getIndentation`, `setDefaultTabWidth`, and `getDefaultTabWidth` helpers + +## [16.0.8] - 2026-06-18 + +### Changed + +- Mermaid diagrams are now rendered to ASCII by a first-party vendored renderer (`src/vendor/mermaid-ascii`, derived from the MIT-licensed `beautiful-mermaid`, ASCII pipeline only) with terminal display width measured via `Bun.stringWidth` (grapheme-aware, correct for wide/East-Asian glyphs and emoji). Inline label formatting (HTML formatting tags and markdown emphasis) is now reduced to plain text instead of printed raw. + +### Removed + +- Removed the external `beautiful-mermaid` dependency (and its transitive `elkjs`, ~3.13MB) in favor of the vendored ASCII renderer. + ## [16.0.3] - 2026-06-16 ### Added diff --git a/packages/utils/package.json b/packages/utils/package.json index 0f2310c5a..19c68e009 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "16.0.7", + "version": "16.1.2", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -32,7 +32,6 @@ }, "dependencies": { "@oh-my-pi/pi-natives": "catalog:", - "beautiful-mermaid": "catalog:", "handlebars": "catalog:", "winston": "catalog:", "winston-daily-rotate-file": "catalog:" diff --git a/packages/utils/src/abortable.ts b/packages/utils/src/abortable.ts index bf4b4db2c..fdf9736cf 100644 --- a/packages/utils/src/abortable.ts +++ b/packages/utils/src/abortable.ts @@ -10,101 +10,52 @@ export class AbortError extends Error { } } -type AbortableStreamReadResult = { done: true; value?: T } | { done: false; value: T }; - -interface AbortableStreamReader { - read(): Promise>; - cancel(reason?: unknown): Promise; - releaseLock(): void; -} - /** - * Creates an abortable stream from a given stream and signal. + * Abortable async iteration over a {@link ReadableStream}. Reads the source + * reader directly and yields each chunk, so the consumer's `for await` drives a + * single read loop with no intermediate stream or per-chunk enqueue. * * Unlike `stream.pipeThrough(..., { signal })`, this explicitly cancels the - * source reader when the signal aborts. That propagates HTTP-client disconnects - * and stream watchdog timeouts all the way to the backend request instead of - * only stopping the local consumer. - * - * @param stream - The stream to make abortable - * @param signal - The signal to abort the stream - * @returns The abortable stream + * source reader on abort or early `break`, propagating HTTP-client disconnects + * and watchdog timeouts to the backend request instead of only stopping the + * local consumer. On abort it throws {@link AbortError}; the lock is released + * on completion, abort, throw, or early exit. The source is cancelled only on + * abort or early exit — never on natural EOF. */ -export function createAbortableStream(stream: ReadableStream, signal?: AbortSignal): ReadableStream { - if (!signal) return stream; - let reader: AbortableStreamReader | undefined; - let closed = false; +export async function* abortableSource(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { + if (signal?.aborted) throw new AbortError(signal); + const reader = stream.getReader(); let onAbort: (() => void) | undefined; - - const cleanup = () => { - if (onAbort) signal.removeEventListener("abort", onAbort); - onAbort = undefined; - const currentReader = reader; - reader = undefined; - try { - currentReader?.releaseLock(); - } catch {} - }; - - const cancelReader = (reason: unknown): Promise => { - if (closed) return Promise.resolve(); - closed = true; - const currentReader = reader; - reader = undefined; - if (onAbort) signal.removeEventListener("abort", onAbort); - onAbort = undefined; - if (!currentReader) return Promise.resolve(); - return currentReader - .cancel(reason) - .catch(() => {}) - .finally(() => { - try { - currentReader.releaseLock(); - } catch {} - }); - }; - - return new ReadableStream({ - start(controller) { - reader = stream.getReader(); - onAbort = () => { - void cancelReader(signal.reason); - try { - controller.error(new AbortError(signal)); - } catch {} - }; - if (signal.aborted) { - onAbort(); + if (signal) { + onAbort = () => { + void reader.cancel(signal.reason).catch(() => {}); + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + let completed = false; + try { + for (;;) { + const result = await reader.read(); + if (signal?.aborted) throw new AbortError(signal); + if (result.done) { + completed = true; return; } - signal.addEventListener("abort", onAbort, { once: true }); - void (async () => { - try { - for (;;) { - const currentReader = reader; - if (!currentReader) return; - const { value, done } = await currentReader.read(); - if (closed) return; - if (done) { - closed = true; - cleanup(); - controller.close(); - return; - } - controller.enqueue(value); - } - } catch (error) { - if (closed) return; - closed = true; - cleanup(); - controller.error(signal.aborted ? new AbortError(signal) : error); - } - })(); - }, - cancel(reason) { - return cancelReader(reason); - }, - }); + yield result.value; + } + } finally { + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + // Propagate early-exit (`break`/`return`) and abort to the backend; skip + // on natural EOF where the stream already closed itself. + if (!completed) { + try { + await reader.cancel(); + } catch {} + } + try { + reader.releaseLock(); + } catch {} + } } /** diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 711a36f46..4771f0ec8 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -185,6 +185,20 @@ export function setProjectDir(dir: string): void { process.chdir(projectDir); } +/** + * Whether `dir` resolves to an existing directory. Any stat failure — a deleted + * path (ENOENT), permission error, or a non-directory — returns `false`, so + * callers can decide whether a directory is safe to `chdir` into or adopt as a + * working directory before {@link setProjectDir} throws on it. + */ +export async function directoryExists(dir: string): Promise { + try { + return (await fs.promises.stat(dir)).isDirectory(); + } catch { + return false; + } +} + /** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */ export function getConfigDirName(): string { return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8f554f3d0..a71a12c04 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,4 @@ -export { createAbortableStream, once, untilAborted } from "./abortable"; +export { once, untilAborted } from "./abortable"; export * from "./async"; export * from "./color"; export * from "./dirs"; diff --git a/packages/utils/src/mermaid-ascii.ts b/packages/utils/src/mermaid-ascii.ts index 9192d0516..69d07f9b6 100644 --- a/packages/utils/src/mermaid-ascii.ts +++ b/packages/utils/src/mermaid-ascii.ts @@ -1,4 +1,4 @@ -import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid"; +import { type AsciiRenderOptions, renderMermaidASCII } from "./vendor/mermaid-ascii"; export type { AsciiRenderOptions as MermaidAsciiRenderOptions }; diff --git a/packages/utils/src/stream.ts b/packages/utils/src/stream.ts index df6d14e55..22037127d 100644 --- a/packages/utils/src/stream.ts +++ b/packages/utils/src/stream.ts @@ -1,4 +1,4 @@ -import { createAbortableStream } from "./abortable"; +import { abortableSource } from "./abortable"; const LF = 0x0a; type JsonlChunkResult = { @@ -23,7 +23,7 @@ function parseJsonlChunkCompat(input: Uint8Array | string, beg?: number, end?: n export async function* readLines(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { const buffer = new ConcatSink(); - const source = createAbortableStream(stream, signal); + const source = abortableSource(stream, signal); try { for await (const chunk of source) { for (const line of buffer.appendAndFlushLines(chunk)) { @@ -46,7 +46,7 @@ export async function* readLines(stream: ReadableStream, signal?: Ab export async function* readJsonl(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { const buffer = new ConcatSink(); - const source = createAbortableStream(stream, signal); + const source = abortableSource(stream, signal); try { for await (const chunk of source) { yield* buffer.pullJSONL(chunk, 0, chunk.length); @@ -339,7 +339,7 @@ export async function* readSseEvents( ): AsyncGenerator { const lineBuffer = new ConcatSink(); const state: SseEventState = { event: null, data: null, raw: [] }; - const source = createAbortableStream(stream, signal); + const source = abortableSource(stream, signal); try { for await (const chunk of source) { for (const line of lineBuffer.appendAndFlushLines(chunk)) { diff --git a/packages/utils/src/tab-spacing.ts b/packages/utils/src/tab-spacing.ts index ac1522123..31f680144 100644 --- a/packages/utils/src/tab-spacing.ts +++ b/packages/utils/src/tab-spacing.ts @@ -20,11 +20,8 @@ const NAME_MAX_BYTES = 255; const EDITORCONFIG_NAME = ".editorconfig"; -let defaultTabWidth = DEFAULT_TAB_WIDTH; - const editorConfigCache = new Map(); const editorConfigChainCache = new Map(); -const indentationCache = new Map(); interface EditorConfigSection { pattern: string; @@ -266,31 +263,6 @@ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | und return match; } -function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined { - if (match === undefined) return undefined; - - if (match.indentSize?.kind === "spaces") { - return match.indentSize.n; - } - - if (match.indentSize?.kind === "tab") { - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - return fallback; - } - - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - - if (match.indentStyle === IndentStyle.Tab) { - return fallback; - } - - return undefined; -} - function hasOverlongPathComponent(filePath: string): boolean { for (const part of filePath.split(/[\\/]/)) { if (part.length > 0 && Buffer.byteLength(part) > NAME_MAX_BYTES) { @@ -300,53 +272,12 @@ function hasOverlongPathComponent(filePath: string): boolean { return false; } -export function getDefaultTabWidth(): number { - return defaultTabWidth; -} - -export function setDefaultTabWidth(width: number): void { - defaultTabWidth = clampTabWidth(width); -} - -/** - * Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted. - */ -export function getIndentation(file?: string | null, projectDir?: string | null): number { - const fallback = defaultTabWidth; - if (file === undefined || file === null || file === "") { - return fallback; - } - - const cwd = projectDir ?? process.cwd(); - const absoluteFile = resolveFilePath(cwd, file); - - // Renderers can hand us arbitrary strings (e.g. a malformed edit tool - // call whose `file_path` is gibberish). Reject paths whose normalized - // absolute form still has any component longer than `NAME_MAX_BYTES` — - // the editorconfig chain would only trip `ENAMETOOLONG` from - // `readFileSync` and escape. - if (hasOverlongPathComponent(absoluteFile)) { - return fallback; - } - const absKey = absoluteFile; - const cached = indentationCache.get(absKey); - if (cached !== undefined) { - return cached; - } - - const editorMatch = resolveEditorConfigMatch(absoluteFile); - const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback; - const clamped = clampTabWidth(resolved); - indentationCache.set(absKey, clamped); - return clamped; -} - /** * `.editorconfig`-derived formatting options for an LSP `textDocument/formatting` request. * * Both fields are absent when the resolved `.editorconfig` chain does not pin them, so callers * can layer their own fallbacks (content sniffing, project defaults) underneath. Returned values - * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH} for parity with {@link getIndentation}. + * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH}. */ export interface EditorConfigFormatting { /** Effective indent width in columns, from `indent_size` or `tab_width`. */ @@ -359,8 +290,8 @@ export interface EditorConfigFormatting { * Resolve `.editorconfig` formatting hints for `file` without falling back to any default. * * Used by the LSP format-on-write path so a missing `.editorconfig` declaration falls through - * to caller-provided defaults instead of clobbering the file with the renderer's display - * `defaultTabWidth` (issue #2329). + * to caller-provided defaults instead of clobbering the file with the renderer's + * display tab width (issue #2329). */ export function getEditorConfigFormatting(file?: string | null, projectDir?: string | null): EditorConfigFormatting { if (file === undefined || file === null || file === "") { @@ -370,7 +301,7 @@ export function getEditorConfigFormatting(file?: string | null, projectDir?: str const cwd = projectDir ?? process.cwd(); const absoluteFile = resolveFilePath(cwd, file); - // Same NAME_MAX guard as `getIndentation`: editorconfig discovery is + // NAME_MAX guard: editorconfig discovery is // best-effort and must never escape as `ENAMETOOLONG` from a renderer's // stray gibberish path. if (hasOverlongPathComponent(absoluteFile)) { diff --git a/packages/utils/src/vendor/mermaid-ascii/NOTICE b/packages/utils/src/vendor/mermaid-ascii/NOTICE new file mode 100644 index 000000000..396f08347 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/NOTICE @@ -0,0 +1,33 @@ +This directory contains an in-house Mermaid-diagram-to-ASCII renderer adapted +from beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid), used +under the MIT License. + + Copyright (c) 2026 Craft Docs + +Only the ASCII rendering pipeline is ported (flowchart/state, sequence, class, +ER, and xychart diagrams); the SVG renderer and its `elkjs` graph-layout +dependency, the browser entry point, and the SVG theme/style modules were +dropped. Terminal display width is reimplemented on `Bun.stringWidth`, and +inline label formatting (HTML tags, markdown emphasis) is reduced to plain text +for ASCII output. Layout and edge-routing logic is preserved faithfully so +ASCII output matches the upstream package. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts new file mode 100644 index 000000000..3ae0b8a5e --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/ansi.ts @@ -0,0 +1,409 @@ +// ============================================================================ +// ASCII renderer — color utilities +// +// Provides color output for themed ASCII diagrams. +// Supports ANSI terminal modes (16/256/truecolor) and HTML tags +// for browser rendering. +// ============================================================================ + +import type { CharRole, AsciiTheme, ColorMode } from './types' + +declare const document: unknown + +// ============================================================================ +// Default theme — matches SVG theme colors for consistency +// ============================================================================ + +/** + * Default ASCII theme derived from the SVG renderer's color palette. + * Uses the same mixing ratios to maintain visual consistency. + */ +export const DEFAULT_ASCII_THEME: AsciiTheme = { + fg: '#27272a', // zinc-800 — primary text + border: '#a1a1aa', // zinc-400 — node borders (12% mix) + line: '#71717a', // zinc-500 — edge lines (35% mix) + arrow: '#52525b', // zinc-600 — arrowheads (60% mix) + corner: '#71717a', // same as line + junction: '#a1a1aa', // same as border +} + +// ============================================================================ +// Color mode detection +// ============================================================================ + +/** + * Detect the best color mode for the current environment. + * + * Terminal detection order: + * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor + * 2. TERM contains "256color" → ansi256 + * 3. TERM is set and not "dumb" → ansi16 + * + * Browser: returns 'html' (uses tags with inline styles). + * Unknown/piped: returns 'none'. + */ +export function detectColorMode(): ColorMode { + // Check if we're in a Node.js-like environment with process object + // Use globalThis to safely check for process without TypeScript errors + const proc = (globalThis as { process?: { stdout?: { isTTY?: boolean }, env?: Record } }).process + + if (proc) { + // Check if stdout is a TTY (not piped/redirected) + if (!proc.stdout?.isTTY) { + return 'none' + } + + const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? '' + const term = proc.env?.TERM?.toLowerCase() ?? '' + + // True color support + if (colorTerm === 'truecolor' || colorTerm === '24bit') { + return 'truecolor' + } + + // 256 color support + if (term.includes('256color') || term.includes('256')) { + return 'ansi256' + } + + // Basic color support + if (term && term !== 'dumb') { + return 'ansi16' + } + + return 'none' + } + + // No process object → browser environment → use HTML color output + if (typeof document !== 'undefined') { + return 'html' + } + + return 'none' +} + +// ============================================================================ +// Hex color parsing +// ============================================================================ + +/** + * Parse a hex color string to RGB values. + * Supports both 3-char (#RGB) and 6-char (#RRGGBB) formats. + */ +function parseHex(hex: string): { r: number; g: number; b: number } { + const h = hex.replace('#', '') + if (h.length === 3) { + return { + r: parseInt(h[0]! + h[0]!, 16), + g: parseInt(h[1]! + h[1]!, 16), + b: parseInt(h[2]! + h[2]!, 16), + } + } + return { + r: parseInt(h.substring(0, 2), 16), + g: parseInt(h.substring(2, 4), 16), + b: parseInt(h.substring(4, 6), 16), + } +} + +// ============================================================================ +// ANSI escape code generation +// ============================================================================ + +/** ANSI escape sequence prefix */ +const ESC = '\x1b[' +/** Reset all attributes */ +const RESET = `${ESC}0m` + +/** + * Generate ANSI foreground color escape sequence for 24-bit true color. + * Format: ESC[38;2;R;G;Bm + */ +function truecolorFg(hex: string): string { + const { r, g, b } = parseHex(hex) + return `${ESC}38;2;${r};${g};${b}m` +} + +/** + * Find the closest 256-color palette index for an RGB color. + * The 256-color palette has: + * - 0-15: Standard colors (duplicates of 16-color) + * - 16-231: 6x6x6 color cube (216 colors) + * - 232-255: Grayscale ramp (24 shades) + */ +function rgbTo256(r: number, g: number, b: number): number { + // Check if it's close to grayscale + const avg = (r + g + b) / 3 + const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg)) + + if (maxDiff < 10) { + // Use grayscale ramp (232-255) + // Each step is ~10.625 (256/24) + const gray = Math.round((avg / 255) * 23) + return 232 + Math.min(23, Math.max(0, gray)) + } + + // Use 6x6x6 color cube (16-231) + // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255 + const toIndex = (v: number): number => { + if (v < 48) return 0 + if (v < 115) return 1 + return Math.min(5, Math.floor((v - 35) / 40)) + } + + const ri = toIndex(r) + const gi = toIndex(g) + const bi = toIndex(b) + + return 16 + (36 * ri) + (6 * gi) + bi +} + +/** + * Generate ANSI foreground color escape sequence for 256-color mode. + * Format: ESC[38;5;Nm + */ +function ansi256Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const index = rgbTo256(r, g, b) + return `${ESC}38;5;${index}m` +} + +/** + * Map an RGB color to the closest 16-color ANSI code. + * Returns the foreground color escape sequence. + * + * Standard 16 colors: + * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white + * 8-15 = bright versions + */ +function ansi16Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const luma = 0.299 * r + 0.587 * g + 0.114 * b + + // Determine brightness (use bright colors for better visibility) + const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset + + // Determine base color based on dominant channel + let code: number + if (r > 180 && g < 100 && b < 100) code = 31 // red + else if (g > 180 && r < 100 && b < 100) code = 32 // green + else if (r > 150 && g > 150 && b < 100) code = 33 // yellow + else if (b > 180 && r < 100 && g < 100) code = 34 // blue + else if (r > 150 && b > 150 && g < 100) code = 35 // magenta + else if (g > 150 && b > 150 && r < 100) code = 36 // cyan + else if (luma > 200) code = 37 // white + else if (luma < 50) code = 30 // black + else code = 37 // default to white for grays + + return `${ESC}${code + bright}m` +} + +// ============================================================================ +// HTML color output (for browser rendering) +// ============================================================================ + +/** Escape characters that would break HTML output. */ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +/** Wrap text in a with an inline color style. */ +function htmlSpan(hex: string, text: string): string { + return `${escapeHtml(text)}` +} + +// ============================================================================ +// Role → color mapping +// ============================================================================ + +/** + * Get the color for a character role from the theme. + */ +function getRoleColor(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +/** + * Generate the ANSI escape sequence for a role color. + */ +export function getAnsiColor(role: CharRole, theme: AsciiTheme, mode: ColorMode): string { + if (mode === 'none') return '' + + const hex = getRoleColor(role, theme) + + switch (mode) { + case 'truecolor': return truecolorFg(hex) + case 'ansi256': return ansi256Fg(hex) + case 'ansi16': return ansi16Fg(hex) + default: return '' + } +} + +/** + * Get the ANSI reset sequence. + */ +export function getAnsiReset(mode: ColorMode): string { + return mode === 'none' ? '' : RESET +} + +/** + * Wrap a character with ANSI color codes based on its role. + */ +export function colorizeChar( + char: string, + role: CharRole | null, + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none' || role === null || char === ' ') { + return char + } + + const colorCode = getAnsiColor(role, theme, mode) + return `${colorCode}${char}${RESET}` +} + +/** + * Colorize an entire line efficiently by grouping consecutive same-role characters. + * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output. + */ +export function colorizeLine( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') { + return chars.join('') + } + + if (mode === 'html') { + return colorizeLineHtml(chars, roles, theme) + } + + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + // Whitespace doesn't need coloring + if (char === ' ') { + // Flush any buffered characters (with or without color) + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + buffer = '' + currentRole = null + } + result += char + continue + } + + // Same role as previous — accumulate + if (role === currentRole) { + buffer += char + continue + } + + // Role changed — flush buffer (with or without color) and start new + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + } + buffer = char + currentRole = role + } + + // Flush remaining buffer + if (buffer.length > 0 && currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else if (buffer.length > 0) { + result += buffer + } + + return result +} + +/** + * HTML-specific line colorization. + * Groups consecutive same-role characters into tags with inline color styles. + * Whitespace is emitted bare (no wrapping) to keep output compact. + */ +function colorizeLineHtml( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, +): string { + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + const flush = () => { + if (buffer.length === 0) return + if (currentRole !== null) { + result += htmlSpan(getRoleColor(currentRole, theme), buffer) + } else { + result += escapeHtml(buffer) + } + buffer = '' + currentRole = null + } + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + if (char === ' ') { + flush() + result += ' ' + continue + } + + if (role === currentRole) { + buffer += char + continue + } + + flush() + buffer = char + currentRole = role + } + + flush() + return result +} + +/** + * Colorize a text string with a direct hex color. + * Used by renderers that need per-cell color control (e.g. multi-series xychart). + * Handles all output modes: ANSI (16/256/truecolor) and HTML. + */ +export function colorizeText(text: string, hex: string, mode: ColorMode): string { + if (mode === 'none' || text.length === 0) return text + if (mode === 'html') return htmlSpan(hex, text) + let code: string + switch (mode) { + case 'truecolor': code = truecolorFg(hex); break + case 'ansi256': code = ansi256Fg(hex); break + case 'ansi16': code = ansi16Fg(hex); break + default: return text + } + return `${code}${text}${RESET}` +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts new file mode 100644 index 000000000..2db2d19c6 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts @@ -0,0 +1,476 @@ +// ============================================================================ +// ASCII renderer — 2D text canvas +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go. +// The canvas is a column-major 2D array of single-character strings. +// canvas[x][y] gives the character at column x, row y. +// ============================================================================ + +import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +/** + * Create a blank canvas filled with spaces. + * Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2). + */ +export function mkCanvas(x: number, y: number): Canvas { + const canvas: Canvas = [] + for (let i = 0; i <= x; i++) { + const col: string[] = [] + for (let j = 0; j <= y; j++) { + col.push(' ') + } + canvas.push(col) + } + return canvas +} + +/** Create a blank canvas with the same dimensions as the given canvas. */ +export function copyCanvas(source: Canvas): Canvas { + const [maxX, maxY] = getCanvasSize(source) + return mkCanvas(maxX, maxY) +} + +// ============================================================================ +// Role canvas creation and management +// ============================================================================ + +/** + * Create a blank role canvas filled with nulls. + * Same dimensions as mkCanvas — column-major, roleCanvas[x][y]. + */ +export function mkRoleCanvas(x: number, y: number): RoleCanvas { + const roleCanvas: RoleCanvas = [] + for (let i = 0; i <= x; i++) { + const col: (CharRole | null)[] = [] + for (let j = 0; j <= y; j++) { + col.push(null) + } + roleCanvas.push(col) + } + return roleCanvas +} + +/** Create a blank role canvas with the same dimensions as the given role canvas. */ +export function copyRoleCanvas(source: RoleCanvas): RoleCanvas { + const maxX = source.length - 1 + const maxY = (source[0]?.length ?? 1) - 1 + return mkRoleCanvas(maxX, maxY) +} + +/** + * Grow the role canvas to fit at least (newX, newY), preserving existing roles. + * Mutates the role canvas in place and returns it. + */ +export function increaseRoleCanvasSize(roleCanvas: RoleCanvas, newX: number, newY: number): RoleCanvas { + const currX = roleCanvas.length - 1 + const currY = (roleCanvas[0]?.length ?? 1) - 1 + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkRoleCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < roleCanvas.length && y < roleCanvas[0]!.length) { + grown[x]![y] = roleCanvas[x]![y]! + } + } + } + roleCanvas.length = 0 + roleCanvas.push(...grown) + return roleCanvas +} + +/** + * Set a role at a specific coordinate. + * Expands the role canvas if necessary. + */ +export function setRole(roleCanvas: RoleCanvas, x: number, y: number, role: CharRole): void { + if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) { + increaseRoleCanvasSize(roleCanvas, x, y) + } + roleCanvas[x]![y] = role +} + +/** + * Merge role canvases — same logic as mergeCanvases but for roles. + * Non-null roles in overlays overwrite null roles in base. + */ +export function mergeRoleCanvases( + base: RoleCanvas, + offset: DrawingCoord, + ...overlays: RoleCanvas[] +): RoleCanvas { + let maxX = base.length - 1 + let maxY = (base[0]?.length ?? 1) - 1 + + for (const overlay of overlays) { + const oX = overlay.length - 1 + const oY = (overlay[0]?.length ?? 1) - 1 + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkRoleCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const role = overlay[x]?.[y] + if (role !== null && role !== undefined) { + const mx = x + offset.x + const my = y + offset.y + merged[mx]![my] = role + } + } + } + } + + return merged +} + +/** Returns [maxX, maxY] — the highest valid indices in each dimension. */ +export function getCanvasSize(canvas: Canvas): [number, number] { + return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1] +} + +/** + * Grow the canvas to fit at least (newX, newY), preserving existing content. + * Mutates the canvas in place and returns it. + */ +export function increaseSize(canvas: Canvas, newX: number, newY: number): Canvas { + const [currX, currY] = getCanvasSize(canvas) + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < canvas.length && y < canvas[0]!.length) { + grown[x]![y] = canvas[x]![y]! + } + } + } + // Mutate in place: splice old contents and replace with grown + canvas.length = 0 + canvas.push(...grown) + return canvas +} + +// ============================================================================ +// Junction merging — Unicode box-drawing character compositing +// ============================================================================ + +/** All Unicode box-drawing characters that participate in junction merging. */ +const JUNCTION_CHARS = new Set([ + '─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╴', '╵', '╶', '╷', +]) + +export function isJunctionChar(c: string): boolean { + return JUNCTION_CHARS.has(c) +} + +/** + * Check if a cell holds label content for first-label-wins collision + * handling during merges: letters/digits in any script, the continuation + * cell of a wide glyph, or any 2-column glyph. Wide glyphs are only ever + * produced by labels (CJK ideographs, Hangul, emoji) — the renderer's own + * structural glyphs (borders, the narrow arrowheads ◀▶) are 1 column — so + * width 2 is a sufficient signal for emoji labels (🚀, 🇨🇳, 👍🏽) that the + * letter/digit test misses. + */ +function isLabelChar(c: string): boolean { + return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) +} + +/** + * Write one cell, dissolving any wide-glyph pair the write would split: + * overwriting a WIDE_PAD orphans its lead, and overwriting a lead orphans + * its pad — the orphaned half becomes a space so serialized rows keep + * exactly one column per cell. + */ +function writeCell(canvas: Canvas, x: number, y: number, c: string): void { + const current = canvas[x]![y]! + if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) { + canvas[x - 1]![y] = ' ' + } else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) { + canvas[x + 1]![y] = ' ' + } + canvas[x]![y] = c +} + +/** + * When two junction characters overlap during canvas merging, + * resolve them to the correct combined junction. + * E.g., '─' overlapping '│' becomes '┼'. + */ +const JUNCTION_MAP: Record> = { + '─': { '│': '┼', '┌': '┬', '┐': '┬', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┬', '┴': '┴' }, + '│': { '─': '┼', '┌': '├', '┐': '┤', '└': '├', '┘': '┤', '├': '├', '┤': '┤', '┬': '┼', '┴': '┼' }, + '┌': { '─': '┬', '│': '├', '┐': '┬', '└': '├', '┘': '┼', '├': '├', '┤': '┼', '┬': '┬', '┴': '┼' }, + '┐': { '─': '┬', '│': '┤', '┌': '┬', '└': '┼', '┘': '┤', '├': '┼', '┤': '┤', '┬': '┬', '┴': '┼' }, + '└': { '─': '┴', '│': '├', '┌': '├', '┐': '┼', '┘': '┴', '├': '├', '┤': '┼', '┬': '┼', '┴': '┴' }, + '┘': { '─': '┴', '│': '┤', '┌': '┼', '┐': '┤', '└': '┴', '├': '┼', '┤': '┤', '┬': '┼', '┴': '┴' }, + '├': { '─': '┼', '│': '├', '┌': '├', '┐': '┼', '└': '├', '┘': '┼', '┤': '┼', '┬': '┼', '┴': '┼' }, + '┤': { '─': '┼', '│': '┤', '┌': '┼', '┐': '┤', '└': '┼', '┘': '┤', '├': '┼', '┬': '┼', '┴': '┼' }, + '┬': { '─': '┬', '│': '┼', '┌': '┬', '┐': '┬', '└': '┼', '┘': '┼', '├': '┼', '┤': '┼', '┴': '┼' }, + '┴': { '─': '┴', '│': '┼', '┌': '┼', '┐': '┼', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┼' }, +} + +export function mergeJunctions(c1: string, c2: string): string { + return JUNCTION_MAP[c1]?.[c2] ?? c1 +} + +// ============================================================================ +// Canvas merging — composite multiple canvases with offset +// ============================================================================ + +/** + * Merge overlay canvases onto a base canvas at the given offset. + * Non-space characters in overlays overwrite the base. + * When both characters are Unicode junction chars, they're merged intelligently. + */ +export function mergeCanvases( + base: Canvas, + offset: DrawingCoord, + useAscii: boolean, + ...overlays: Canvas[] +): Canvas { + let [maxX, maxY] = getCanvasSize(base) + for (const overlay of overlays) { + const [oX, oY] = getCanvasSize(overlay) + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const c = overlay[x]![y]! + // WIDE_PAD cells are written atomically with their lead below + if (c === ' ' || c === WIDE_PAD) continue + const mx = x + offset.x + const my = y + offset.y + const current = merged[mx]![my]! + const isWide = overlay[x + 1]?.[y] === WIDE_PAD + if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { + merged[mx]![my] = mergeJunctions(current, c) + } else if (isWide) { + // Wide glyphs land or yield as a whole pair (first label wins) + if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? ' ')) { + writeCell(merged, mx, my, c) + writeCell(merged, mx + 1, my, WIDE_PAD) + } + } else if (isLabelChar(current) && isLabelChar(c)) { + // Don't overwrite existing label text with new label text + // This prevents label collisions (first label wins) + } else { + writeCell(merged, mx, my, c) + } + } + } + } + + return merged +} + +// ============================================================================ +// Canvas → string conversion +// ============================================================================ + +/** Options for converting canvas to string with optional coloring. */ +export interface CanvasToStringOptions { + /** Role canvas for applying colors. If not provided, output is plain text. */ + roleCanvas?: RoleCanvas + /** Color mode for terminal output. Default: 'none' */ + colorMode?: ColorMode + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: AsciiTheme +} + +/** + * Convert the canvas to a multi-line string (row by row, left to right). + * Optionally applies ANSI color codes based on character roles. + */ +export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): string { + const [maxX, maxY] = getCanvasSize(canvas) + const lines: string[] = [] + + const roleCanvas = options?.roleCanvas + const colorMode = options?.colorMode ?? 'none' + const theme = options?.theme ?? DEFAULT_ASCII_THEME + + for (let y = 0; y <= maxY; y++) { + if (colorMode === 'none' || !roleCanvas) { + // Plain text output — no colors + let line = '' + for (let x = 0; x <= maxX; x++) { + const c = canvas[x]![y]! + // Skip wide-glyph continuation cells: the glyph itself spans 2 columns + if (c !== WIDE_PAD) line += c + } + lines.push(line) + } else { + // Colored output — collect chars and roles for this row + const chars: string[] = [] + const roles: (CharRole | null)[] = [] + for (let x = 0; x <= maxX; x++) { + const c = canvas[x]![y]! + if (c === WIDE_PAD) continue + chars.push(c) + roles.push(roleCanvas[x]?.[y] ?? null) + } + lines.push(colorizeLine(chars, roles, theme, colorMode)) + } + } + + return lines.join('\n') +} + +// ============================================================================ +// Canvas vertical flip — used for BT (bottom-to-top) direction support. +// +// The ASCII renderer lays out graphs top-down (TD). For BT direction, we +// flip the finished canvas vertically and remap directional characters so +// arrows point upward and corners are mirrored correctly. +// ============================================================================ + +/** + * Characters that change meaning when the Y-axis is flipped. + * Symmetric characters (─, │, ├, ┤, ┼) are unchanged. + */ +const VERTICAL_FLIP_MAP: Record = { + // Unicode arrows + '▲': '▼', '▼': '▲', + '◤': '◣', '◣': '◤', + '◥': '◢', '◢': '◥', + // ASCII arrows + '^': 'v', 'v': '^', + // Unicode corners + '┌': '└', '└': '┌', + '┐': '┘', '┘': '┐', + // Unicode junctions (T-pieces flip vertically) + '┬': '┴', '┴': '┬', + // Box-start junctions (exit points from node boxes) + '╵': '╷', '╷': '╵', +} + +/** + * Flip the canvas vertically (mirror across the horizontal center). + * Reverses row order within each column and remaps directional characters + * (arrows, corners, junctions) so they point the correct way after flip. + * + * Used to transform a TD-rendered canvas into BT output. + * Mutates the canvas in place and returns it. + */ +export function flipCanvasVertically(canvas: Canvas): Canvas { + // Reverse each column array (Y-axis flip in column-major layout) + for (const col of canvas) { + col.reverse() + } + + // Remap directional characters that change meaning after vertical flip + for (const col of canvas) { + for (let y = 0; y < col.length; y++) { + const flipped = VERTICAL_FLIP_MAP[col[y]!] + if (flipped) col[y] = flipped + } + } + + return canvas +} + +/** + * Flip the role canvas vertically to match flipCanvasVertically. + * Mutates the role canvas in place and returns it. + */ +export function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas { + for (const col of roleCanvas) { + col.reverse() + } + return roleCanvas +} + +/** + * Draw text string onto the canvas starting at the given coordinate. + * By default, preserves existing non-space characters (labels don't overwrite each other). + * Set forceOverwrite=true to always overwrite (for box content). + */ +export function drawText( + canvas: Canvas, + start: DrawingCoord, + text: string, + forceOverwrite = false +): void { + const cells = toCells(text) + increaseSize(canvas, start.x + cells.length, start.y) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]! + // WIDE_PAD cells are written atomically with their lead below + if (cell === WIDE_PAD) continue + const x = start.x + i + if (cells[i + 1] === WIDE_PAD) { + // Wide glyph: needs both its cells free (or forced) to land + const pairFree = canvas[x]![start.y] === ' ' && canvas[x + 1]![start.y] === ' ' + if (forceOverwrite || pairFree) { + writeCell(canvas, x, start.y, cell) + writeCell(canvas, x + 1, start.y, WIDE_PAD) + } + } else if (forceOverwrite || canvas[x]![start.y] === ' ') { + writeCell(canvas, x, start.y, cell) + } + } +} + +/** + * Set the canvas size to fit all grid columns and rows. + * Called after layout to ensure the canvas covers the full drawing area. + */ +export function setCanvasSizeToGrid( + canvas: Canvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseSize(canvas, maxX - 1, maxY - 1) +} + +/** + * Set the role canvas size to match the grid dimensions. + * Should be called alongside setCanvasSizeToGrid. + */ +export function setRoleCanvasSizeToGrid( + roleCanvas: RoleCanvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts new file mode 100644 index 000000000..fa34eec1c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/class-diagram.ts @@ -0,0 +1,699 @@ +// ============================================================================ +// ASCII renderer — class diagrams +// +// Renders classDiagram text to ASCII/Unicode art. +// Each class is a multi-compartment box (header | attributes | methods). +// Relationships are drawn as lines between classes with UML markers. +// +// Layout: level-based top-down. "From" classes are placed above "to" classes +// for all relationship types, matching ELK/mermaid.com behavior. +// Relationship lines use simple Manhattan routing (vertical + horizontal). +// ============================================================================ + +import { parseClassDiagram } from '../class/parser' +import type { ClassDiagram, ClassNode, ClassMember, ClassRelationship, RelationshipType } from '../class/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { drawMultiBox } from './draw' +import { splitLines } from './multiline-utils' +import { displayWidth, toCells } from '../text-metrics' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Class member formatting +// ============================================================================ + +/** Format a class member as a display string: visibility + name + optional type */ +function formatMember(m: ClassMember): string { + const vis = m.visibility || '' + const type = m.type ? `: ${m.type}` : '' + return `${vis}${m.name}${type}` +} + +/** Build the text sections for a class box: [header], [attributes], [methods] */ +function buildClassSections(cls: ClassNode): string[][] { + // Header section: optional annotation + class name (may be multi-line) + const header: string[] = [] + if (cls.annotation) header.push(`<<${cls.annotation}>>`) + // Support multi-line class names + const nameLines = splitLines(cls.label) + header.push(...nameLines) + + // Attributes section + const attrs = cls.attributes.map(formatMember) + + // Methods section + const methods = cls.methods.map(formatMember) + + // If no attrs and no methods, just return header (1-section box) + if (attrs.length === 0 && methods.length === 0) return [header] + // If no methods, return header + attrs (2-section box) + if (methods.length === 0) return [header, attrs] + // Full 3-section box + return [header, attrs, methods] +} + +// ============================================================================ +// Relationship marker characters +// ============================================================================ + +interface RelMarker { + /** Relationship type (determines marker shape) */ + type: RelationshipType + /** Which end the marker is placed at */ + markerAt: 'from' | 'to' + /** Whether the line is dashed */ + dashed: boolean +} + +/** + * Build the marker metadata for a relationship. + * The actual marker character will be determined at placement time based on line direction. + */ +function getRelMarker(type: RelationshipType, markerAt: 'from' | 'to'): RelMarker { + const dashed = type === 'dependency' || type === 'realization' + return { type, markerAt, dashed } +} + +/** + * Get the UML marker shape character for a relationship type. + * For directional arrows (association/dependency), the direction parameter + * specifies which way the arrow should point. + */ +function getMarkerShape( + type: RelationshipType, + useAscii: boolean, + direction?: 'up' | 'down' | 'left' | 'right' +): string { + switch (type) { + case 'inheritance': + case 'realization': + // Hollow triangle - rotate based on line direction + // Triangle points TOWARD the parent class + if (direction === 'down') { + // Line goes down (parent above, child below) - triangle points UP + return useAscii ? '^' : '△' + } else if (direction === 'up') { + // Line goes up (parent below, child above) - triangle points DOWN + return useAscii ? 'v' : '▽' + } else if (direction === 'left') { + // Line goes left - triangle points LEFT + return useAscii ? '>' : '◁' + } else { + // Default: line goes right - triangle points RIGHT + return useAscii ? '<' : '▷' + } + case 'composition': + // Filled diamond - omnidirectional shape + return useAscii ? '*' : '◆' + case 'aggregation': + // Hollow diamond - omnidirectional shape + return useAscii ? 'o' : '◇' + case 'association': + case 'dependency': + // Directional arrow - rotate based on line direction + if (direction === 'down') { + return useAscii ? 'v' : '▼' + } else if (direction === 'up') { + return useAscii ? '^' : '▲' + } else if (direction === 'left') { + return useAscii ? '<' : '◀' + } else { + // Default to right (or when direction not specified) + return useAscii ? '>' : '▶' + } + } +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** Positioned class node on the canvas */ +interface PlacedClass { + cls: ClassNode + sections: string[][] + x: number + y: number + width: number + height: number +} + +/** + * Render a Mermaid class diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string. + */ +export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseClassDiagram(lines) + + if (diagram.classes.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 4 // horizontal gap between class boxes + const vGap = 3 // vertical gap between levels (enough for relationship lines) + + // --- Build box dimensions for each class --- + const classSections = new Map() + const classBoxW = new Map() + const classBoxH = new Map() + + for (const cls of diagram.classes) { + const sections = buildClassSections(cls) + classSections.set(cls.id, sections) + + // Compute box dimensions from drawMultiBox logic + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 // section lines + dividers + top/bottom border + + classBoxW.set(cls.id, boxW) + classBoxH.set(cls.id, boxH) + } + + // --- Assign levels: topological sort based on directed relationships --- + // All relationship types place "from" above "to" in the layout, matching + // ELK's layered algorithm and the official mermaid.com renderer behavior. + // For "Animal <|-- Dog": from="Animal", to="Dog" → Animal above Dog. + // + // Every relationship type (including association and dependency) forces nodes + // to different levels. Same-row routing for mixed diagrams causes collisions: + // detour lines overlap with cross-level routing, and labels overwrite box borders. + + const classById = new Map() + for (const cls of diagram.classes) classById.set(cls.id, cls) + + const parents = new Map>() // child → set of parent IDs + const children = new Map>() // parent → set of child IDs + + for (const rel of diagram.relationships) { + // For inheritance/realization, the marker (hollow triangle) points to the parent. + // - `Animal <|-- Dog` (markerAt='from'): Animal is parent, Dog is child + // - `Bird ..|> Flyable` (markerAt='to'): Flyable is parent, Bird is child + // For other relationships, use the default from→to direction. + const isHierarchical = rel.type === 'inheritance' || rel.type === 'realization' + const parentId = isHierarchical && rel.markerAt === 'to' ? rel.to : rel.from + const childId = isHierarchical && rel.markerAt === 'to' ? rel.from : rel.to + + if (!parents.has(childId)) parents.set(childId, new Set()) + parents.get(childId)!.add(parentId) + if (!children.has(parentId)) children.set(parentId, new Set()) + children.get(parentId)!.add(childId) + } + + // BFS from roots (classes that have no parents) to assign levels. + // Cap at classes.length - 1 to prevent infinite loops on cyclic graphs + // (e.g. View --> Model and Model ..> View would otherwise push levels + // upward forever). In a DAG the longest path has at most N-1 edges. + const level = new Map() + const roots = diagram.classes.filter(c => !parents.has(c.id) || parents.get(c.id)!.size === 0) + const queue: string[] = roots.map(c => c.id) + for (const id of queue) level.set(id, 0) + + const levelCap = diagram.classes.length - 1 + let qi = 0 + while (qi < queue.length) { + const id = queue[qi++]! + const childSet = children.get(id) + if (!childSet) continue + for (const childId of childSet) { + const newLevel = (level.get(id) ?? 0) + 1 + if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop + if (!level.has(childId) || level.get(childId)! < newLevel) { + level.set(childId, newLevel) + queue.push(childId) + } + } + } + + // Assign remaining (unconnected) classes to level 0 + for (const cls of diagram.classes) { + if (!level.has(cls.id)) level.set(cls.id, 0) + } + + // --- Position classes by level --- + // Group classes by level + const maxLevel = Math.max(...[...level.values()], 0) + const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => []) + for (const cls of diagram.classes) { + levelGroups[level.get(cls.id)!]!.push(cls.id) + } + + // Compute positions: each level is a row, classes in a row are spaced horizontally + const placed = new Map() + let currentY = 0 + + for (let lv = 0; lv <= maxLevel; lv++) { + const group = levelGroups[lv]! + if (group.length === 0) continue + + let currentX = 0 + let maxH = 0 + + for (const id of group) { + const cls = classById.get(id)! + const w = classBoxW.get(id)! + const h = classBoxH.get(id)! + placed.set(id, { + cls, + sections: classSections.get(id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + currentX += w + hGap + maxH = Math.max(maxH, h) + } + + currentY += maxH + vGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + + // Extra space for relationship lines that may go below/beside + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw class boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + // Copy box onto main canvas at (p.x, p.y) with role tracking + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Build occupancy map for collision avoidance --- + // Track which x positions are occupied at each y level (to avoid routing through boxes) + const boxOccupancy: { x1: number; x2: number; y1: number; y2: number }[] = [] + for (const p of placed.values()) { + boxOccupancy.push({ + x1: p.x, + x2: p.x + p.width - 1, + y1: p.y, + y2: p.y + p.height - 1, + }) + } + + /** Check if a point (x, y) is inside any class box */ + function isInsideBox(x: number, y: number, excludeIds?: Set): boolean { + for (const [id, p] of placed.entries()) { + if (excludeIds?.has(id)) continue + if (x >= p.x && x <= p.x + p.width - 1 && y >= p.y && y <= p.y + p.height - 1) { + return true + } + } + return false + } + + /** Find a clear vertical column for routing that doesn't pass through any boxes */ + function findClearColumn(startX: number, y1: number, y2: number, excludeIds: Set): number { + // Try the original column first + let clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(startX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return startX + + // Try columns to the left and right, alternating + for (let offset = 1; offset < totalW + 10; offset++) { + // Try right + const rightX = startX + offset + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(rightX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return rightX + + // Try left + const leftX = startX - offset + if (leftX >= 0) { + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(leftX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return leftX + } + } + + // Fallback to right edge of canvas + some extra space + return totalW + 2 + } + + // --- Draw relationship lines --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const fromP = placed.get(rel.from) + const toP = placed.get(rel.to) + if (!fromP || !toP) continue + + const marker = getRelMarker(rel.type, rel.markerAt) + const lineH = marker.dashed ? dashH : H + const lineV = marker.dashed ? dashV : V + + // Exclude source and target boxes from collision detection + const excludeIds = new Set([rel.from, rel.to]) + + // Connection points: center-bottom of source → center-top of target + const fromCX = fromP.x + Math.floor(fromP.width / 2) + const fromBY = fromP.y + fromP.height - 1 + const toCX = toP.x + Math.floor(toP.width / 2) + const toTY = toP.y + + // Route: Manhattan routing with collision avoidance + // If target is below source: vertical down from source, horizontal if needed, vertical down to target + // If same row: horizontal line with a small vertical detour above or below + if (fromBY < toTY) { + // Target is below source — routing with collision avoidance + // Find a clear vertical column for the ENTIRE path from source to target + const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds) + const needsDetour = routeX !== fromCX + + // Expand canvas if needed to accommodate routing column + if (routeX >= totalW) { + increaseSize(canvas, routeX + 2, totalH) + } + + if (needsDetour) { + // COLLISION CASE: Route around intermediate boxes + // Path: source center → horizontal to routeX → vertical to entry → horizontal to target center + + const exitY = fromBY + 1 + const entryY = toTY - 1 + + // 1. Horizontal from source center to route column + const lx1 = Math.min(fromCX, routeX) + const rx1 = Math.max(fromCX, routeX) + for (let x = lx1; x <= rx1; x++) { + setC(x, exitY, lineH, 'line') + } + if (!useAscii && exitY < (canvas[0]?.length ?? 0)) { + if (fromCX < routeX) { + setC(fromCX, exitY, '└', 'corner') + setC(routeX, exitY, '┐', 'corner') + } else { + setC(fromCX, exitY, '┘', 'corner') + setC(routeX, exitY, '┌', 'corner') + } + } + + // 2. Vertical at routeX from exit to entry + for (let y = exitY + 1; y <= entryY; y++) { + setC(routeX, y, lineV, 'line') + } + + // 3. Horizontal from routeX to target center at entry + if (routeX !== toCX) { + const lx2 = Math.min(routeX, toCX) + const rx2 = Math.max(routeX, toCX) + for (let x = lx2; x <= rx2; x++) { + setC(x, entryY, lineH, 'line') + } + if (!useAscii && entryY < (canvas[0]?.length ?? 0)) { + if (routeX < toCX) { + setC(routeX, entryY, '└', 'corner') + setC(toCX, entryY, '┐', 'corner') + } else { + setC(routeX, entryY, '┘', 'corner') + setC(toCX, entryY, '┌', 'corner') + } + } + } + + // Markers for detour case + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(toCX, entryY, markerChar, 'arrow') + } + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(fromCX, fromBY + 1, markerChar, 'arrow') + } + } else { + // NO COLLISION CASE: Use original midpoint-based routing + // Path: source center → vertical to midY → horizontal at midY → vertical to target + + const midY = fromBY + Math.floor((toTY - fromBY) / 2) + + // 1. Vertical from source bottom to midY + for (let y = fromBY + 1; y <= midY; y++) { + setC(fromCX, y, lineV, 'line') + } + + // 2. Horizontal from fromCX to toCX at midY (if needed) + if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii) { + setC(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner') + setC(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner') + } + } + + // 3. Vertical from midY to target top + for (let y = midY + 1; y < toTY; y++) { + setC(toCX, y, lineV, 'line') + } + + // Markers for no-collision case + if (marker.markerAt === 'to') { + setC(toCX, toTY - 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + if (marker.markerAt === 'from') { + setC(fromCX, fromBY + 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + } + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target is ABOVE source — draw upward from source top to target bottom + const fromTY = fromP.y + const toBY = toP.y + toP.height - 1 + const midY = toBY + Math.floor((fromTY - toBY) / 2) + + for (let y = fromTY - 1; y >= midY; y--) { + setC(fromCX, y, lineV, 'line') + } + + if (fromCX !== toCX) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii && midY >= 0 && midY < totalH) { + setC(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner') + setC(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner') + } + } + + for (let y = midY - 1; y > toBY; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - arrows point in the direction of the vertical segment (upward) + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = fromTY - 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const isHierarchical = marker.type === 'inheritance' || marker.type === 'realization' + const markerDir = isHierarchical ? 'down' : 'up' + const markerChar = getMarkerShape(marker.type, useAscii, markerDir) + const my = toBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } else { + // Same level — draw horizontal line with a detour below both boxes + const detourY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + increaseSize(canvas, totalW, detourY + 1) + increaseRoleCanvasSize(rc, totalW, detourY + 1) + + // Vertical down from source + for (let y = fromBY + 1; y <= detourY; y++) { + setC(fromCX, y, lineV, 'line') + } + // Horizontal + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, detourY, lineH, 'line') + } + // Vertical up to target + for (let y = detourY - 1; y >= toP.y + toP.height; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - same-level routing uses vertical segments at both ends + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + const my = fromBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = toP.y + toP.height + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } + + // Draw relationship label at midpoint if present (supports multi-line) + // Add padding around the label for readability + if (rel.label) { + const lines = splitLines(rel.label) + const maxLabelWidth = Math.max(...lines.map(l => displayWidth(l))) + 2 // +2 for padding + + // Calculate ideal label position based on routing direction + let baseMidY: number + let idealMidX: number + + if (fromBY < toTY) { + // Target below source: place in gap between source bottom and target top + baseMidY = Math.floor((fromBY + 1 + toTY - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target above source: place in gap between target bottom and source top + const toBY = toP.y + toP.height - 1 + baseMidY = Math.floor((toBY + 1 + fromP.y - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else { + // Same level: place label at midpoint of the detour line + baseMidY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + idealMidX = Math.floor((fromCX + toCX) / 2) + } + + // Find a clear vertical position for the label (not inside any box) + let labelY = baseMidY + const halfHeight = Math.floor(lines.length / 2) + + // Check if any label line would be inside a box + let labelInBox = false + for (let i = 0; i < lines.length; i++) { + const y = labelY - halfHeight + i + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + // Check if this line overlaps any box + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + labelInBox = true + break + } + } + if (labelInBox) break + } + + // If label is inside a box, find the gap between boxes + if (labelInBox) { + // Find the gap between source and target boxes + const gapTop = fromBY + 1 + const gapBottom = toTY - 1 + + // Place label in the middle of the gap, outside any intermediate box + for (let y = gapTop; y <= gapBottom; y++) { + let clearRow = true + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + clearRow = false + break + } + } + if (clearRow) { + labelY = y + break + } + } + } + + // Center lines vertically around labelY + const startY = labelY - halfHeight + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const paddedLine = ` ${lines[lineIdx]!} ` // Add space padding on both sides + const cells = toCells(paddedLine) + // Calculate label start, but ensure it doesn't go negative + const idealLabelStart = idealMidX - Math.floor(cells.length / 2) + const labelStart = Math.max(0, idealLabelStart) + const y = startY + lineIdx + // Ensure canvas is wide enough for the label + const labelEnd = labelStart + cells.length + if (labelEnd > 0 && y >= 0) { + increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + } + // Clear the area first (overwrite line characters) then draw the padded label + for (let i = 0; i < cells.length; i++) { + const lx = labelStart + i + if (lx >= 0 && y >= 0) { + setC(lx, y, cells[i]!, 'text') + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts new file mode 100644 index 000000000..166b9a186 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/converter.ts @@ -0,0 +1,271 @@ +// ============================================================================ +// ASCII renderer — MermaidGraph → AsciiGraph converter +// +// Bridges the existing TypeScript parser output to the ASCII renderer's +// internal graph structure. This avoids maintaining a separate parser +// for ASCII rendering — we reuse parseMermaid() and convert its output. +// ============================================================================ + +import type { MermaidGraph, MermaidSubgraph } from '../types' +import type { + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiConfig, +} from './types' +import { EMPTY_STYLE } from './types' +import { mkCanvas, mkRoleCanvas } from './canvas' + +/** + * Convert a parsed MermaidGraph into an AsciiGraph ready for grid layout. + * + * Key mappings: + * - MermaidGraph.nodes (Map) → ordered AsciiNode[] preserving insertion order + * - MermaidGraph.edges → AsciiEdge[] with resolved node references + * - MermaidGraph.subgraphs → AsciiSubgraph[] with parent/child tree + * - Node labels are used as display names (not raw IDs) + */ +export function convertToAsciiGraph(parsed: MermaidGraph, config: AsciiConfig): AsciiGraph { + // Build node list preserving Map insertion order + const nodeMap = new Map() + let index = 0 + + for (const [id, mNode] of parsed.nodes) { + const asciiNode: AsciiNode = { + // Use the parser ID as the unique identity key to avoid collisions + // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]). + name: id, + // The label is used for rendering inside the box. + displayLabel: mNode.label, + // Preserve shape from parser for shape-aware rendering + shape: mNode.shape, + index, + gridCoord: null, + drawingCoord: null, + drawing: null, + drawn: false, + styleClassName: '', + styleClass: EMPTY_STYLE, + } + nodeMap.set(id, asciiNode) + index++ + } + + const nodes = [...nodeMap.values()] + + // Build edges with resolved node references + const edges: AsciiEdge[] = [] + for (const mEdge of parsed.edges) { + const from = nodeMap.get(mEdge.source) + const to = nodeMap.get(mEdge.target) + if (!from || !to) continue + + edges.push({ + from, + to, + text: mEdge.label ?? '', + path: [], + labelLine: [], + startDir: { x: 0, y: 0 }, + endDir: { x: 0, y: 0 }, + style: mEdge.style, + hasArrowStart: mEdge.hasArrowStart, + hasArrowEnd: mEdge.hasArrowEnd, + }) + } + + // Convert subgraphs recursively + const subgraphs: AsciiSubgraph[] = [] + for (const mSg of parsed.subgraphs) { + convertSubgraph(mSg, null, nodeMap, subgraphs) + } + + // Deduplicate subgraph node membership to match Go parser behavior. + // In Go, a node belongs only to the subgraph where it was FIRST DEFINED. + // The TS parser adds referenced nodes to all subgraphs they appear in, + // which causes incorrect bounding boxes when nodes span subgraph boundaries. + deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed) + + // Apply class definitions + for (const [nodeId, className] of parsed.classAssignments) { + const node = nodeMap.get(nodeId) + const classDef = parsed.classDefs.get(className) + if (node && classDef) { + node.styleClassName = className + node.styleClass = { name: className, styles: classDef } + } + } + + return { + nodes, + edges, + canvas: mkCanvas(0, 0), + roleCanvas: mkRoleCanvas(0, 0), + grid: new Map(), + columnWidth: new Map(), + rowHeight: new Map(), + subgraphs, + config, + offsetX: 0, + offsetY: 0, + bundles: [], // Populated by analyzeEdgeBundles() during layout + } +} + +/** + * Recursively convert a MermaidSubgraph to AsciiSubgraph. + * Flattens the tree into the subgraphs array while maintaining parent/child references. + * This matches the Go implementation where all subgraphs are in a flat list + * but linked via parent/children pointers. + */ +function convertSubgraph( + mSg: MermaidSubgraph, + parent: AsciiSubgraph | null, + nodeMap: Map, + allSubgraphs: AsciiSubgraph[], +): AsciiSubgraph { + // Normalize subgraph direction: BT→TD, RL→LR (same as root graph normalization) + let normalizedDirection: 'LR' | 'TD' | undefined + if (mSg.direction) { + normalizedDirection = (mSg.direction === 'LR' || mSg.direction === 'RL') ? 'LR' : 'TD' + } + + const sg: AsciiSubgraph = { + name: mSg.label, + nodes: [], + parent, + children: [], + minX: 0, minY: 0, maxX: 0, maxY: 0, + direction: normalizedDirection, + } + + // Resolve node references + for (const nodeId of mSg.nodeIds) { + const node = nodeMap.get(nodeId) + if (node) sg.nodes.push(node) + } + + allSubgraphs.push(sg) + + // Recurse into children + for (const childMSg of mSg.children) { + const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs) + sg.children.push(child) + + // Child nodes are also part of parent subgraphs (Go behavior). + // The Go parser adds nodes to ALL subgraphs in the stack, so a nested + // node belongs to both the inner and outer subgraph. + for (const childNode of child.nodes) { + if (!sg.nodes.includes(childNode)) { + sg.nodes.push(childNode) + } + } + } + + return sg +} + +/** + * Deduplicate subgraph node membership to match Go parser behavior. + * + * The Go parser only adds a node to the subgraph that was active when the node + * was FIRST CREATED. If a node is later referenced inside a different subgraph, + * it is NOT added to that subgraph. The TS parser is more permissive — it adds + * referenced nodes to whichever subgraph they appear in. + * + * This function fixes the discrepancy by: + * 1. Walking the edges to determine which nodes were first created inside each subgraph + * 2. Removing nodes from subgraphs where they weren't first created + */ +function deduplicateSubgraphNodes( + mermaidSubgraphs: MermaidSubgraph[], + asciiSubgraphs: AsciiSubgraph[], + nodeMap: Map, + parsed: MermaidGraph, +): void { + // Build a map from MermaidSubgraph to its corresponding AsciiSubgraph. + // The ordering matches since we convert them in the same order. + const sgMap = new Map() + buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap) + + // Determine which subgraph each node was "first defined" in. + // A node is first defined in the subgraph where it first appears as a NEW node + // in the ordered edge/node list. We approximate this by checking the global + // node insertion order against subgraph membership. + const nodeOwner = new Map() // nodeId → owning subgraph + + // Walk all mermaid subgraphs in document order. For each subgraph, + // claim nodes that haven't been claimed yet by any previous subgraph. + function claimNodes(mSg: MermaidSubgraph): void { + const asciiSg = sgMap.get(mSg) + if (!asciiSg) return + + // Recurse into children first (they appear before parent in the Go parser stack, + // but nodes defined in children are added to parent too — this is handled by + // the convertSubgraph function which propagates child nodes to parents). + // For dedup, we process children first so their claims propagate up correctly. + for (const child of mSg.children) { + claimNodes(child) + } + + // Claim unclaimed nodes in this subgraph + for (const nodeId of mSg.nodeIds) { + if (!nodeOwner.has(nodeId)) { + nodeOwner.set(nodeId, asciiSg) + } + } + } + + for (const mSg of mermaidSubgraphs) { + claimNodes(mSg) + } + + // Now remove nodes from subgraphs that don't own them. + // A node should remain in: its owner subgraph + all ancestors of the owner. + for (const asciiSg of asciiSubgraphs) { + asciiSg.nodes = asciiSg.nodes.filter(node => { + // Find this node's ID in the nodeMap + let nodeId: string | undefined + for (const [id, n] of nodeMap) { + if (n === node) { nodeId = id; break } + } + if (!nodeId) return false + + const owner = nodeOwner.get(nodeId) + if (!owner) return true // not in any subgraph claim — keep as-is + + // Keep the node if this subgraph is the owner or an ancestor of the owner + return isAncestorOrSelf(asciiSg, owner) + }) + } +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** Build a mapping from MermaidSubgraph → AsciiSubgraph (matching by position). */ +function buildSgMap( + mSgs: MermaidSubgraph[], + aSgs: AsciiSubgraph[], + result: Map, +): void { + // The asciiSubgraphs array is flat (all subgraphs including nested ones), + // while mermaidSubgraphs is hierarchical. We need to flatten the mermaid tree + // in the same order the converter processes them (pre-order DFS). + const flatMermaid: MermaidSubgraph[] = [] + function flatten(sgs: MermaidSubgraph[]): void { + for (const sg of sgs) { + flatMermaid.push(sg) + flatten(sg.children) + } + } + flatten(mSgs) + + for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) { + result.set(flatMermaid[i]!, aSgs[i]!) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts new file mode 100644 index 000000000..7d7fdc821 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -0,0 +1,1382 @@ +// ============================================================================ +// ASCII renderer — drawing operations +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go + cmd/arrow.go. +// Contains all visual rendering: boxes, lines, arrows, corners, +// subgraphs, labels, and the top-level draw orchestrator. +// ============================================================================ + +import type { + Canvas, DrawingCoord, GridCoord, Direction, + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiEdgeStyle, EdgeBundle, +} from './types' +import { + Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, + drawingCoordEquals, +} from './types' +import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas' +import type { RoleCanvas, CharRole } from './types' +import { determineDirection, dirEquals } from './edge-routing' +import { gridToDrawingCoord, lineToDrawing } from './grid' +import { splitLines } from './multiline-utils' +import { getCorners } from './shapes/corners' +import { getShapeAttachmentPoint } from './shapes/index' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +// ============================================================================ +// Node drawing — renders a node using shape-aware rendering +// ============================================================================ + +/** + * Draw a node using its shape type. + * Returns a standalone canvas containing the rendered shape. + * + * For basic shapes (rectangle, rounded), uses grid-determined dimensions + * to ensure consistent sizing across nodes in the same column. + * For special shapes (diamond, circle, state pseudo-states, etc.), + * uses shape-specific dimension calculation but centers the content + * within the grid cell dimensions to ensure proper vertical alignment. + */ +export function drawNode(node: AsciiNode, graph: AsciiGraph): Canvas { + // All shapes use grid-determined dimensions to fill their allocated space. + // This ensures consistent sizing across nodes and eliminates gaps between + // nodes and subgraph borders. All shapes are rectangles with distinctive + // corner characters (defined in corners.ts) to indicate shape type. + return drawBoxWithGridDimensions(node, graph) +} + +/** + * Draw a box shape using grid-determined dimensions. + * This ensures consistent sizing when multiple nodes share a column, + * and eliminates gaps between nodes and subgraph borders by filling + * the entire allocated grid space. + * + * All shapes are rendered as rectangles with distinctive corner characters + * (defined in corners.ts) to indicate shape type. + */ +function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { + const gc = node.gridCoord! + const useAscii = graph.config.useAscii + + // Width spans 2 columns (border + content) - matching original behavior + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + // Height spans 2 rows (border + content) + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: w, y: h } + const box = mkCanvas(Math.max(from.x, to.x), Math.max(from.y, to.y)) + + // Get corner characters for this shape type + const corners = getCorners(node.shape, useAscii) + + // State-end uses double border to differentiate from state-start + const isDoubleBox = node.shape === 'state-end' + const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─') + const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│') + + // Double-box corners (for state-end) + const doubleCorners = useAscii + ? { tl: '#', tr: '#', bl: '#', br: '#' } + : { tl: '╔', tr: '╗', bl: '╚', br: '╝' } + const effectiveCorners = isDoubleBox ? doubleCorners : corners + + // Draw box border with shape-specific corners + for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar + for (let x = from.x + 1; x < to.x; x++) box[x]![to.y] = hChar + for (let y = from.y + 1; y < to.y; y++) box[from.x]![y] = vChar + for (let y = from.y + 1; y < to.y; y++) box[to.x]![y] = vChar + box[from.x]![from.y] = effectiveCorners.tl + box[to.x]![from.y] = effectiveCorners.tr + box[from.x]![to.y] = effectiveCorners.bl + box[to.x]![to.y] = effectiveCorners.br + + // Center the multi-line display label inside the box + const label = node.displayLabel + const lines = splitLines(label) + const textCenterY = from.y + Math.floor(h / 2) + const startY = textCenterY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const cells = toCells(line) + const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 + for (let j = 0; j < cells.length; j++) { + if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0]!.length) { + box[textX + j]![startY + i] = cells[j]! + } + } + } + + return box +} + +/** + * Draw a node box with centered label text. + * Returns a standalone canvas containing just the box. + * Box size is determined by the grid column/row sizes for the node's position. + */ +export function drawBox(node: AsciiNode, graph: AsciiGraph): Canvas { + return drawNode(node, graph) +} + +// ============================================================================ +// Multi-section box drawing — for class and ER diagram nodes +// ============================================================================ + +/** + * Draw a multi-section box with horizontal dividers between sections. + * Used by class diagrams (header | attributes | methods) and ER diagrams (header | attributes). + * Each section is an array of text lines to render left-aligned with padding. + * + * @param sections - Array of sections, each section is an array of text lines + * @param useAscii - true for ASCII chars, false for Unicode box-drawing + * @param padding - horizontal padding inside the box (default 1) + * @returns A standalone Canvas containing the multi-section box + */ +export function drawMultiBox( + sections: string[][], + useAscii: boolean, + padding: number = 1, +): Canvas { + // Compute width: widest line across all sections + 2*padding + 2 border chars + let maxTextWidth = 0 + for (const section of sections) { + for (const line of section) { + maxTextWidth = Math.max(maxTextWidth, displayWidth(line)) + } + } + const innerWidth = maxTextWidth + 2 * padding + const boxWidth = innerWidth + 2 // +2 for left/right border + + // Compute height: sum of all section line counts + dividers + 2 border rows + let totalLines = 0 + for (const section of sections) { + totalLines += Math.max(section.length, 1) // at least 1 row per section + } + const numDividers = sections.length - 1 + const boxHeight = totalLines + numDividers + 2 // +2 for top/bottom border + + // Box-drawing characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + const tl = useAscii ? '+' : '┌' + const tr = useAscii ? '+' : '┐' + const bl = useAscii ? '+' : '└' + const br = useAscii ? '+' : '┘' + const divL = useAscii ? '+' : '├' + const divR = useAscii ? '+' : '┤' + + const canvas = mkCanvas(boxWidth - 1, boxHeight - 1) + + // Top border + canvas[0]![0] = tl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![0] = hLine + canvas[boxWidth - 1]![0] = tr + + // Bottom border + canvas[0]![boxHeight - 1] = bl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![boxHeight - 1] = hLine + canvas[boxWidth - 1]![boxHeight - 1] = br + + // Left and right borders (full height) + for (let y = 1; y < boxHeight - 1; y++) { + canvas[0]![y] = vLine + canvas[boxWidth - 1]![y] = vLine + } + + // Render sections with dividers + let row = 1 // current y position (starts after top border) + for (let s = 0; s < sections.length; s++) { + const section = sections[s]! + const lines = section.length > 0 ? section : [''] + + // Draw section text lines + for (const line of lines) { + const startX = 1 + padding + const cells = toCells(line) + for (let i = 0; i < cells.length; i++) { + canvas[startX + i]![row] = cells[i]! + } + row++ + } + + // Draw divider after each section except the last + if (s < sections.length - 1) { + canvas[0]![row] = divL + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![row] = hLine + canvas[boxWidth - 1]![row] = divR + row++ + } + } + + return canvas +} + +// ============================================================================ +// Line drawing — 8-directional lines on the canvas +// ============================================================================ + +/** + * Line character sets for different edge styles. + * Each style has horizontal, vertical, and diagonal characters for both + * Unicode (box-drawing) and ASCII (basic punctuation) modes. + * + * Unicode dotted: ┄ (horizontal), ┆ (vertical) — U+2504, U+2506 + * Unicode thick: ━ (horizontal), ┃ (vertical) — U+2501, U+2503 + */ +/** + * Line character sets for different edge styles. + * Only horizontal and vertical characters - no diagonals. + * All edges use orthogonal Manhattan routing (90° bends only). + */ +const LINE_CHARS = { + solid: { + h: { unicode: '─', ascii: '-' }, + v: { unicode: '│', ascii: '|' }, + }, + dotted: { + h: { unicode: '┄', ascii: '.' }, + v: { unicode: '┆', ascii: ':' }, + }, + thick: { + h: { unicode: '━', ascii: '=' }, + v: { unicode: '┃', ascii: '‖' }, + }, +} as const + +/** + * Draw a line between two drawing coordinates using orthogonal Manhattan routing. + * Returns the list of coordinates that were drawn on. + * offsetFrom/offsetTo control how many cells to skip at the start/end. + * + * All lines use 90° bends only - no diagonal lines are produced. + * For diagonal directions, uses horizontal-first routing (draws horizontal + * segment, then vertical segment). + */ +export function drawLine( + canvas: Canvas, + from: DrawingCoord, + to: DrawingCoord, + offsetFrom: number, + offsetTo: number, + useAscii: boolean, + style: AsciiEdgeStyle = 'solid', +): DrawingCoord[] { + const dir = determineDirection(from, to) + const drawnCoords: DrawingCoord[] = [] + + // Select character set based on style (horizontal and vertical only) + const chars = LINE_CHARS[style] + const hChar = useAscii ? chars.h.ascii : chars.h.unicode + const vChar = useAscii ? chars.v.ascii : chars.v.unicode + + // Pure vertical directions + if (dirEquals(dir, Up)) { + for (let y = from.y - offsetFrom; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else if (dirEquals(dir, Down)) { + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } + // Pure horizontal directions + else if (dirEquals(dir, Left)) { + for (let x = from.x - offsetFrom; x >= to.x - offsetTo; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } else if (dirEquals(dir, Right)) { + for (let x = from.x + offsetFrom; x <= to.x + offsetTo; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } + // Diagonal directions: use Manhattan routing (horizontal-first, then vertical) + // UpperLeft: go left first, then up + else if (dirEquals(dir, UpperLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // UpperRight: go right first, then up + else if (dirEquals(dir, UpperRight)) { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerLeft: go left first, then down + else if (dirEquals(dir, LowerLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerRight: go right first, then down + // Special case: if x difference is small (1), draw straight vertical at from.x + // This keeps edges visually aligned with the source node + else if (dirEquals(dir, LowerRight)) { + const dx = to.x - from.x + if (dx <= 1) { + // Draw vertical line at from.x (source's x-coordinate) + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + } + + return drawnCoords +} + +// ============================================================================ +// Arrow drawing — path, corners, arrowheads, box-start junctions, labels +// ============================================================================ + +/** + * Draw a complete arrow (edge) between two nodes. + * Returns 6 separate canvases for layered compositing: + * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label] + * + * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd. + */ +export function drawArrow( + graph: AsciiGraph, + edge: AsciiEdge, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + if (edge.path.length === 0) { + const empty = copyCanvas(graph.canvas) + return [empty, empty, empty, empty, empty, empty] + } + + const labelCanvas = drawArrowLabel(graph, edge) + const [pathCanvas, linesDrawn, lineDirs] = drawPath(graph, edge.path, edge.style) + const boxStartCanvas = drawBoxStart(graph, edge.path, linesDrawn[0]!, edge.from.shape) + + // Draw end arrowhead only if hasArrowEnd is true (default behavior) + let arrowHeadEndCanvas: Canvas + if (edge.hasArrowEnd) { + arrowHeadEndCanvas = drawArrowHead( + graph, + linesDrawn[linesDrawn.length - 1]!, + lineDirs[lineDirs.length - 1]!, + ) + } else { + arrowHeadEndCanvas = copyCanvas(graph.canvas) + } + + // Draw start arrowhead for bidirectional edges + // The start arrowhead needs to be at the box connector position (one step back + // from the first line point), pointing into the source node. + let arrowHeadStartCanvas: Canvas + if (edge.hasArrowStart && linesDrawn.length > 0) { + const firstLine = linesDrawn[0]! + const firstPoint = firstLine[0]! + const startDir = reverseDirection(lineDirs[0]!) + + // Calculate the box connector position (one step back from first point) + const arrowPos: DrawingCoord = { x: firstPoint.x, y: firstPoint.y } + if (dirEquals(lineDirs[0]!, Right)) arrowPos.x = firstPoint.x - 1 + else if (dirEquals(lineDirs[0]!, Left)) arrowPos.x = firstPoint.x + 1 + else if (dirEquals(lineDirs[0]!, Down)) arrowPos.y = firstPoint.y - 1 + else if (dirEquals(lineDirs[0]!, Up)) arrowPos.y = firstPoint.y + 1 + + // Create a synthetic line ending at the arrow position for drawArrowHead + const syntheticLine: DrawingCoord[] = [firstPoint, arrowPos] + arrowHeadStartCanvas = drawArrowHead(graph, syntheticLine, startDir) + } else { + arrowHeadStartCanvas = copyCanvas(graph.canvas) + } + + const cornersCanvas = drawCorners(graph, edge.path) + + return [pathCanvas, boxStartCanvas, arrowHeadEndCanvas, arrowHeadStartCanvas, cornersCanvas, labelCanvas] +} + +/** + * Reverse a direction (for bidirectional arrow start heads). + */ +function reverseDirection(dir: Direction): Direction { + if (dirEquals(dir, Up)) return Down + if (dirEquals(dir, Down)) return Up + if (dirEquals(dir, Left)) return Right + if (dirEquals(dir, Right)) return Left + if (dirEquals(dir, UpperLeft)) return LowerRight + if (dirEquals(dir, UpperRight)) return LowerLeft + if (dirEquals(dir, LowerLeft)) return UpperRight + if (dirEquals(dir, LowerRight)) return UpperLeft + return Middle +} + +/** + * Draw the path lines for an edge. + * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment. + */ +function drawPath( + graph: AsciiGraph, + path: GridCoord[], + style: AsciiEdgeStyle = 'solid', +): [Canvas, DrawingCoord[][], Direction[]] { + const canvas = copyCanvas(graph.canvas) + let previousCoord = path[0]! + const linesDrawn: DrawingCoord[][] = [] + const lineDirs: Direction[] = [] + + for (let i = 1; i < path.length; i++) { + const nextCoord = path[i]! + const prevDC = gridToDrawingCoord(graph, previousCoord) + const nextDC = gridToDrawingCoord(graph, nextCoord) + + if (drawingCoordEquals(prevDC, nextDC)) { + previousCoord = nextCoord + continue + } + + const dir = determineDirection(previousCoord, nextCoord) + const segment = drawLine(canvas, prevDC, nextDC, 1, -1, graph.config.useAscii, style) + if (segment.length === 0) segment.push(prevDC) + linesDrawn.push(segment) + lineDirs.push(dir) + previousCoord = nextCoord + } + + return [canvas, linesDrawn, lineDirs] +} + +/** + * Draw the junction character where an edge exits the source node's box. + * Only applies to Unicode mode (ASCII mode just uses the line characters). + * Skips drawing for state pseudo-states which have their own visual borders. + */ +function drawBoxStart( + graph: AsciiGraph, + path: GridCoord[], + firstLine: DrawingCoord[], + sourceShape: string, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (graph.config.useAscii) return canvas + + // Skip box start connectors for state pseudo-states (they have their own bordered design) + if (sourceShape === 'state-start' || sourceShape === 'state-end') { + return canvas + } + + const from = firstLine[0]! + const dir = determineDirection(path[0]!, path[1]!) + + if (dirEquals(dir, Up)) canvas[from.x]![from.y + 1] = '┴' + else if (dirEquals(dir, Down)) canvas[from.x]![from.y - 1] = '┬' + else if (dirEquals(dir, Left)) canvas[from.x + 1]![from.y] = '┤' + else if (dirEquals(dir, Right)) canvas[from.x - 1]![from.y] = '├' + + return canvas +} + +/** + * Draw the arrowhead at the end of an edge path. + * Uses triangular Unicode symbols (▲▼◄►) or ASCII symbols (^v<>). + */ +function drawArrowHead( + graph: AsciiGraph, + lastLine: DrawingCoord[], + fallbackDir: Direction, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (lastLine.length === 0) return canvas + + const from = lastLine[0]! + const lastPos = lastLine[lastLine.length - 1]! + let dir = determineDirection(from, lastPos) + if (lastLine.length === 1 || dirEquals(dir, Middle)) dir = fallbackDir + + let char: string + + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else if (dirEquals(dir, UpperRight)) char = '◥' + else if (dirEquals(dir, UpperLeft)) char = '◤' + else if (dirEquals(dir, LowerRight)) char = '◢' + else if (dirEquals(dir, LowerLeft)) char = '◣' + else { + // Fallback + if (dirEquals(fallbackDir, Up)) char = '▲' + else if (dirEquals(fallbackDir, Down)) char = '▼' + else if (dirEquals(fallbackDir, Left)) char = '◄' + else if (dirEquals(fallbackDir, Right)) char = '►' + else if (dirEquals(fallbackDir, UpperRight)) char = '◥' + else if (dirEquals(fallbackDir, UpperLeft)) char = '◤' + else if (dirEquals(fallbackDir, LowerRight)) char = '◢' + else if (dirEquals(fallbackDir, LowerLeft)) char = '◣' + else char = '●' + } + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else { + if (dirEquals(fallbackDir, Up)) char = '^' + else if (dirEquals(fallbackDir, Down)) char = 'v' + else if (dirEquals(fallbackDir, Left)) char = '<' + else if (dirEquals(fallbackDir, Right)) char = '>' + else char = '*' + } + } + + canvas[lastPos.x]![lastPos.y] = char + return canvas +} + +/** + * Draw corner characters at path bends (where the direction changes). + * Uses ┌┐└┘ in Unicode mode, + in ASCII mode. + */ +function drawCorners(graph: AsciiGraph, path: GridCoord[]): Canvas { + const canvas = copyCanvas(graph.canvas) + + for (let idx = 1; idx < path.length - 1; idx++) { + const coord = path[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(path[idx - 1]!, coord) + const nextDir = determineDirection(coord, path[idx + 1]!) + + let corner: string + if (!graph.config.useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + canvas[dc.x]![dc.y] = corner + } + + return canvas +} + +/** Draw edge label text centered on the widest path segment. */ +function drawArrowLabel(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + if (edge.text.length === 0) return canvas + + const drawingLine = lineToDrawing(graph, edge.labelLine) + + // Determine if this is an upward edge (target is above source in the path) + // This is used to offset labels on bidirectional edges to prevent overlap + let isUpwardEdge: boolean | undefined + if (edge.path.length >= 2) { + const startY = edge.path[0]!.y + const endY = edge.path[edge.path.length - 1]!.y + // Edge goes up if end Y is less than start Y (smaller Y = higher on screen) + if (endY < startY) { + isUpwardEdge = true + } else if (endY > startY) { + isUpwardEdge = false + } + // If endY === startY, it's horizontal, leave isUpwardEdge undefined + } + + drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge) + return canvas +} + +/** + * Draw text centered on a line segment defined by two drawing coordinates. + * Supports multi-line labels. + * + * When isUpwardEdge is provided, offsets the label vertically to prevent + * overlapping with labels from edges going the opposite direction: + * - Upward edges: label placed in lower portion of segment + * - Downward edges (isUpwardEdge=false): label placed in upper portion + * - No direction (isUpwardEdge=undefined): label centered (default) + */ +function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isUpwardEdge?: boolean): void { + if (line.length < 2) return + const minX = Math.min(line[0]!.x, line[1]!.x) + const maxX = Math.max(line[0]!.x, line[1]!.x) + const minY = Math.min(line[0]!.y, line[1]!.y) + const maxY = Math.max(line[0]!.y, line[1]!.y) + const middleX = minX + Math.floor((maxX - minX) / 2) + let middleY = minY + Math.floor((maxY - minY) / 2) + + // Offset label vertically to prevent overlap on bidirectional edges + // For vertical segments (same X), shift based on edge direction + if (isUpwardEdge !== undefined && minX === maxX) { + const segmentHeight = maxY - minY + const offset = Math.max(1, Math.floor(segmentHeight / 4)) + if (isUpwardEdge) { + // Upward edge: place label in lower portion + middleY = middleY + offset + } else { + // Downward edge: place label in upper portion + middleY = middleY - offset + } + } + + // Support multi-line labels + const lines = splitLines(label) + const startY = middleY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]! + const startX = middleX - Math.floor(displayWidth(lineText) / 2) + drawText(canvas, { x: startX, y: startY + i }, lineText) + } +} + +// ============================================================================ +// Node attachment point helper +// ============================================================================ + +/** + * Get the drawing coordinate where an edge attaches to a node's border. + * Uses grid-allocated dimensions so attachment points align with the actual + * drawn box (which may be wider/taller than the intrinsic shape dimensions + * when sharing a column/row with a larger node). + */ +function getNodeAttachmentPoint( + graph: AsciiGraph, + node: AsciiNode, + dir: Direction, +): DrawingCoord { + const gc = node.gridCoord! + + // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions) + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + // Build dimensions matching the actual drawn box size + const gridDimensions = { + width: w + 1, + height: h + 1, + labelArea: { x: 0, y: 0, width: 0, height: 0 }, + gridColumns: [0, 0, 0] as [number, number, number], + gridRows: [0, 0, 0] as [number, number, number], + } + + const baseCoord = node.drawingCoord! + return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord) +} + +// ============================================================================ +// Bundled edge drawing — for parallel links (A & B --> C) +// ============================================================================ + +/** + * Draw a single edge's segment in a bundle (source → junction for fan-in, + * junction → target for fan-out). + * + * Returns the same tuple format as drawArrow for consistency. + */ +function drawBundledEdgeSegment( + graph: AsciiGraph, + edge: AsciiEdge, + bundle: EdgeBundle, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + const empty = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length === 0) { + return [empty, empty, empty, empty, empty, empty] + } + + // Draw the path segment (pathToJunction) + const pathCanvas = copyCanvas(graph.canvas) + const useAscii = graph.config.useAscii + + // Convert grid coords to drawing coords + // For fan-in: first point is at source node border (use attachment point) + // For fan-out: last point is at target node border (use attachment point) + const drawingPath = edge.pathToJunction.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === 0) { + // First point: use source node's actual border position + return getNodeAttachmentPoint(graph, edge.from, edge.startDir) + } + if (bundle.type === 'fan-out' && idx === edge.pathToJunction!.length - 1) { + // Last point: use target node's actual border position + return getNodeAttachmentPoint(graph, edge.to, edge.endDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints of every segment (offset 1, -1), + // matching non-bundled drawPath behavior. This leaves endpoint + // characters to corner/junction/boxStart canvases, preventing + // line characters from corrupting them via mergeJunctions. + drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style) + } + } + + // Draw corners at path bends + const cornersCanvas = copyCanvas(graph.canvas) + for (let idx = 1; idx < edge.pathToJunction.length - 1; idx++) { + const coord = edge.pathToJunction[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(edge.pathToJunction[idx - 1]!, coord) + const nextDir = determineDirection(coord, edge.pathToJunction[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + // Draw box start connector (for fan-in, from source node) + // The connector is placed at the first point coordinate (box border position) + // since we use offsets 1,-1 for drawLine, the line starts one step past this point + const boxStartCanvas = copyCanvas(graph.canvas) + if (bundle.type === 'fan-in' && edge.pathToJunction.length >= 2) { + const firstPoint = drawingPath[0]! + const dir = determineDirection(edge.pathToJunction[0]!, edge.pathToJunction[1]!) + + if (!useAscii) { + if (dirEquals(dir, Up)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┴' + else if (dirEquals(dir, Down)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┬' + else if (dirEquals(dir, Left)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┤' + else if (dirEquals(dir, Right)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '├' + } + } + + // Label canvas (bundled edges typically don't have labels, but handle it) + const labelCanvas = copyCanvas(graph.canvas) + + return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas] +} + +/** + * Draw the shared path segment of a bundle (junction → target for fan-in, + * source → junction for fan-out). + */ +function drawBundleSharedPath(graph: AsciiGraph, bundle: EdgeBundle): [Canvas, Canvas] { + const pathCanvas = copyCanvas(graph.canvas) + const cornersCanvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) { + return [pathCanvas, cornersCanvas] + } + + const useAscii = graph.config.useAscii + const style = bundle.edges[0]?.style ?? 'solid' + const graphDir = graph.config.graphDirection + + // Convert grid coords to drawing coords + // For fan-in: last point is at target node border + // For fan-out: first point is at source node border + const drawingPath = bundle.sharedPath.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) { + // Last point: use target node's actual border position (entry from above/left) + const entryDir = graphDir === 'TD' ? Up : Left + return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + } + if (bundle.type === 'fan-out' && idx === 0) { + // First point: use source node's actual border position (exit going down/right) + const exitDir = graphDir === 'TD' ? Down : Right + return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments with appropriate offsets + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath. + drawLine(pathCanvas, from, to, 1, -1, useAscii, style) + } + } + + // Draw corners at path bends + for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) { + const coord = bundle.sharedPath[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(bundle.sharedPath[idx - 1]!, coord) + const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + return [pathCanvas, cornersCanvas] +} + +/** + * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target). + */ +function drawBundleArrowhead(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) return canvas + + // Get the last segment direction + const lastIdx = bundle.sharedPath.length - 1 + const secondLast = bundle.sharedPath[lastIdx - 1]! + const last = bundle.sharedPath[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border (not on the border itself). + // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead + // sits at the last drawn point (1 char before the border). + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the arrowhead for a single edge in a fan-out bundle. + */ +function drawBundledEdgeArrowhead(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas + + // Get the last segment direction + const lastIdx = edge.pathToJunction.length - 1 + const secondLast = edge.pathToJunction[lastIdx - 1]! + const last = edge.pathToJunction[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, edge.to, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the junction character where bundled edges merge/split. + * + * Analyzes actual connecting directions to choose the correct character: + * - ┼ (cross): lines from all 4 directions + * - ┬ (T down): lines from left, right, and down + * - ┴ (T up): lines from left, right, and up + * - ├ (T right): lines from up, down, and right + * - ┤ (T left): lines from up, down, and left + */ +function drawJunctionCharacter(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!bundle.junctionPoint) return canvas + + const dc = gridToDrawingCoord(graph, bundle.junctionPoint) + const useAscii = graph.config.useAscii + + // Analyze what directions actually connect to the junction + let hasUp = false + let hasDown = false + let hasLeft = false + let hasRight = false + + // Check shared path direction (where the line continues to/from the shared node) + if (bundle.sharedPath.length >= 2) { + // For fan-in: shared path goes FROM junction TO target (index 0 is junction) + // For fan-out: shared path goes FROM source TO junction (last index is junction) + const junctionIdx = bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1 + const adjacentIdx = bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2 + const sharedDir = determineDirection( + bundle.sharedPath[junctionIdx]!, + bundle.sharedPath[adjacentIdx]! + ) + // This is the direction the shared path GOES from junction + if (dirEquals(sharedDir, Down)) hasDown = true + else if (dirEquals(sharedDir, Up)) hasUp = true + else if (dirEquals(sharedDir, Right)) hasRight = true + else if (dirEquals(sharedDir, Left)) hasLeft = true + } + + // Check each edge's path direction at the junction + for (const edge of bundle.edges) { + if (edge.pathToJunction && edge.pathToJunction.length >= 2) { + // For fan-in: pathToJunction goes FROM source TO junction (last is junction) + // For fan-out: pathToJunction goes FROM junction TO target (first is junction) + const junctionIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 1 + : 0 + const adjacentIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 2 + : 1 + + const arrivalDir = determineDirection( + edge.pathToJunction[adjacentIdx]!, + edge.pathToJunction[junctionIdx]! + ) + // This is the direction the edge ARRIVES at junction from + // e.g., if arrivalDir is Right, the line comes FROM the left + if (dirEquals(arrivalDir, Down)) hasUp = true // arrived going down = came from up + else if (dirEquals(arrivalDir, Up)) hasDown = true + else if (dirEquals(arrivalDir, Right)) hasLeft = true + else if (dirEquals(arrivalDir, Left)) hasRight = true + } + } + + // Select character based on connected directions + let char: string + if (!useAscii) { + if (hasUp && hasDown && hasLeft && hasRight) { + char = '┼' // cross - all 4 directions + } else if (hasDown && hasLeft && hasRight && !hasUp) { + char = '┬' // T pointing down + } else if (hasUp && hasLeft && hasRight && !hasDown) { + char = '┴' // T pointing up + } else if (hasUp && hasDown && hasRight && !hasLeft) { + char = '├' // T pointing right + } else if (hasUp && hasDown && hasLeft && !hasRight) { + char = '┤' // T pointing left + } else if (hasLeft && hasRight) { + char = '─' // horizontal only + } else if (hasUp && hasDown) { + char = '│' // vertical only + } else if (hasDown && hasRight) { + char = '┌' // corner + } else if (hasDown && hasLeft) { + char = '┐' + } else if (hasUp && hasRight) { + char = '└' + } else if (hasUp && hasLeft) { + char = '┘' + } else { + char = '┼' // fallback + } + } else { + char = '+' + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +// ============================================================================ +// Subgraph drawing +// ============================================================================ + +/** Draw a subgraph border rectangle. */ +export function drawSubgraphBox(sg: AsciiSubgraph, graph: AsciiGraph): Canvas { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return mkCanvas(0, 0) + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: width, y: height } + const canvas = mkCanvas(width, height) + + if (!graph.config.useAscii) { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '─' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '─' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '│' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '│' + canvas[from.x]![from.y] = '┌' + canvas[to.x]![from.y] = '┐' + canvas[from.x]![to.y] = '└' + canvas[to.x]![to.y] = '┘' + } else { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '-' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '-' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '|' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '|' + canvas[from.x]![from.y] = '+' + canvas[to.x]![from.y] = '+' + canvas[from.x]![to.y] = '+' + canvas[to.x]![to.y] = '+' + } + + return canvas +} + +/** Draw a subgraph label centered in its header area. Supports multi-line labels. */ +export function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas, DrawingCoord] { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return [mkCanvas(0, 0), { x: 0, y: 0 }] + + const canvas = mkCanvas(width, height) + + // Support multi-line subgraph labels + const lines = splitLines(sg.name) + + // Start at row 1 inside subgraph, expand downward for multiple lines + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const labelY = 1 + i + let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2) + if (labelX < 1) labelX = 1 + + const cells = toCells(line) + for (let j = 0; j < cells.length; j++) { + const cell = cells[j]! + if (cell === WIDE_PAD) continue // written atomically with its lead + const cx = labelX + j + const wide = cells[j + 1] === WIDE_PAD + // Keep wide-glyph pairs atomic at the subgraph edge + if (cx + (wide ? 1 : 0) >= width || labelY >= height) continue + canvas[cx]![labelY] = cell + if (wide) canvas[cx + 1]![labelY] = WIDE_PAD + } + } + + return [canvas, { x: sg.minX, y: sg.minY }] +} + +// ============================================================================ +// Top-level draw orchestrator +// ============================================================================ + +/** Sort subgraphs by nesting depth (shallowest first) for correct layered rendering. */ +function sortSubgraphsByDepth(subgraphs: AsciiSubgraph[]): AsciiSubgraph[] { + function getDepth(sg: AsciiSubgraph): number { + return sg.parent === null ? 0 : 1 + getDepth(sg.parent) + } + const sorted = [...subgraphs] + sorted.sort((a, b) => getDepth(a) - getDepth(b)) + return sorted +} + +// ============================================================================ +// Role tracking helpers for colored output +// ============================================================================ + +/** + * Fill roles for all non-space characters in a canvas region. + * Used after drawing a layer to record what role those characters have. + */ +function fillRolesFromCanvas( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, + role: CharRole, +): void { + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, role) + } + } + } + } +} + +/** + * Fill roles for multiple canvases with the same role. + */ +function fillRolesFromCanvases( + roleCanvas: RoleCanvas, + canvases: Canvas[], + offset: DrawingCoord, + role: CharRole, +): void { + for (const canvas of canvases) { + fillRolesFromCanvas(roleCanvas, canvas, offset, role) + } +} + +/** + * Special handling for node boxes: border chars get 'border' role, text gets 'text' role. + * Detects text by checking if character is alphanumeric or common punctuation. + */ +function fillRolesForNodeBox( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, +): void { + const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c) + + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, isBorderChar(char) ? 'border' : 'text') + } + } + } + } +} + +/** + * Main draw function — renders the entire graph onto the canvas. + * Drawing order matters for correct layering: + * 1. Subgraph borders (bottom layer) + * 2. Node boxes + * 3. Edge paths (lines) + * 4. Edge corners + * 5. Arrowheads + * 6. Box-start junctions + * 7. Edge labels + * 8. Subgraph labels (top layer) + * + * Also fills the roleCanvas with character roles for colored output. + */ +export function drawGraph(graph: AsciiGraph): Canvas { + const useAscii = graph.config.useAscii + const zero: DrawingCoord = { x: 0, y: 0 } + + // Draw subgraph borders + const sortedSgs = sortSubgraphsByDepth(graph.subgraphs) + for (const sg of sortedSgs) { + const sgCanvas = drawSubgraphBox(sg, graph) + const offset: DrawingCoord = { x: sg.minX, y: sg.minY } + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, sgCanvas) + // Subgraph borders get 'border' role + fillRolesFromCanvas(graph.roleCanvas, sgCanvas, offset, 'border') + } + + // Draw node boxes + for (const node of graph.nodes) { + if (!node.drawn && node.drawingCoord && node.drawing) { + graph.canvas = mergeCanvases(graph.canvas, node.drawingCoord, useAscii, node.drawing) + // Node boxes: detect border vs text characters + fillRolesForNodeBox(graph.roleCanvas, node.drawing, node.drawingCoord) + node.drawn = true + } + } + + // Collect all edge drawing layers + const lineCanvases: Canvas[] = [] + const cornerCanvases: Canvas[] = [] + const arrowHeadEndCanvases: Canvas[] = [] + const arrowHeadStartCanvases: Canvas[] = [] + const boxStartCanvases: Canvas[] = [] + const labelCanvases: Canvas[] = [] + const junctionCanvases: Canvas[] = [] + + // Track which bundles have been processed (to draw shared paths only once) + const processedBundles = new Set() + + for (const edge of graph.edges) { + // Handle bundled edges specially + if (edge.bundle && edge.pathToJunction) { + const bundle = edge.bundle + + // Draw this edge's individual path (source → junction for fan-in, junction → target for fan-out) + const [pathC, boxStartC, , , cornersC, labelC] = drawBundledEdgeSegment(graph, edge, bundle) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + + // Draw the bundle's shared path and arrowhead only once + if (!processedBundles.has(bundle)) { + processedBundles.add(bundle) + + // Draw shared path (junction → target for fan-in, source → junction for fan-out) + const [sharedPathC, sharedCornersC] = drawBundleSharedPath(graph, bundle) + lineCanvases.push(sharedPathC) + cornerCanvases.push(sharedCornersC) + + // Draw arrowhead at target for fan-in (once for all edges in bundle) + if (bundle.type === 'fan-in') { + const arrowHeadC = drawBundleArrowhead(graph, bundle) + arrowHeadEndCanvases.push(arrowHeadC) + } + + // Draw junction character + const junctionC = drawJunctionCharacter(graph, bundle) + junctionCanvases.push(junctionC) + } + + // For fan-out bundles, draw arrowhead at each target + if (bundle.type === 'fan-out' && edge.hasArrowEnd) { + const arrowHeadC = drawBundledEdgeArrowhead(graph, edge) + arrowHeadEndCanvases.push(arrowHeadC) + } + } else { + // Non-bundled edge: use standard drawing + const [pathC, boxStartC, arrowHeadEndC, arrowHeadStartC, cornersC, labelC] = drawArrow(graph, edge) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + arrowHeadEndCanvases.push(arrowHeadEndC) + arrowHeadStartCanvases.push(arrowHeadStartC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + } + } + + // Merge edge layers in order and track roles + // Note: arrowHeadStart is merged AFTER boxStart so bidirectional arrows + // properly overwrite the box connector at the source end + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...lineCanvases) + fillRolesFromCanvases(graph.roleCanvas, lineCanvases, zero, 'line') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...cornerCanvases) + fillRolesFromCanvases(graph.roleCanvas, cornerCanvases, zero, 'corner') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...junctionCanvases) + fillRolesFromCanvases(graph.roleCanvas, junctionCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadEndCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadEndCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...boxStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, boxStartCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadStartCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...labelCanvases) + fillRolesFromCanvases(graph.roleCanvas, labelCanvases, zero, 'text') + + // Draw subgraph labels last (on top) + for (const sg of graph.subgraphs) { + if (sg.nodes.length === 0) continue + const [labelCanvas, offset] = drawSubgraphLabel(sg, graph) + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, labelCanvas) + fillRolesFromCanvas(graph.roleCanvas, labelCanvas, offset, 'text') + } + + return graph.canvas +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts new file mode 100644 index 000000000..e9badf65c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-bundling.ts @@ -0,0 +1,328 @@ +// ============================================================================ +// ASCII renderer — edge bundling for parallel links +// +// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and +// groups them into bundles. Bundled edges share a visual junction point +// where they merge/split, creating cleaner diagrams. +// +// This module provides: +// - analyzeEdgeBundles(): Finds and creates bundles from graph edges +// - calculateJunctionPoint(): Computes optimal merge/split locations +// - routeBundledEdges(): Routes edges through junction points +// ============================================================================ + +import type { + AsciiGraph, AsciiNode, AsciiEdge, EdgeBundle, GridCoord, Direction, +} from './types' +import { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types' +import { getPath, mergePath } from './pathfinder' +import { getNodeSubgraph } from './grid' + +// ============================================================================ +// Bundle analysis +// ============================================================================ + +/** + * Analyze graph edges and create bundles for parallel links. + * + * Groups edges by: + * - Fan-in: Multiple edges sharing the same target (A & B --> C) + * - Fan-out: Multiple edges sharing the same source (A --> B & C) + * + * Only creates bundles when: + * - Graph direction is TD (top-down) - LR routing handles merging naturally + * - 2+ edges share the endpoint + * - All edges have the same style (solid/dotted/thick) + * - None of the edges have labels (labels would overlap at junction) + * - Edges are not self-loops + * + * @returns Array of bundles. Each edge can belong to at most one bundle. + */ +export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] { + // Only bundle in TD direction - LR routing handles merging naturally at corners + if (graph.config.graphDirection !== 'TD') { + return [] + } + const bundles: EdgeBundle[] = [] + const bundledEdges = new Set() + + // Group edges by target (fan-in candidates) + const edgesByTarget = new Map() + for (const edge of graph.edges) { + // Skip self-loops + if (edge.from === edge.to) continue + + const existing = edgesByTarget.get(edge.to) ?? [] + existing.push(edge) + edgesByTarget.set(edge.to, existing) + } + + // Create fan-in bundles + for (const [target, edges] of edgesByTarget) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + // Check if all edges are already bundled + if (edges.some(e => bundledEdges.has(e))) continue + + const bundle: EdgeBundle = { + type: 'fan-in', + edges: [...edges], + sharedNode: target, + otherNodes: edges.map(e => e.from), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + // Group edges by source (fan-out candidates) + const edgesBySource = new Map() + for (const edge of graph.edges) { + // Skip self-loops and already bundled edges + if (edge.from === edge.to) continue + if (bundledEdges.has(edge)) continue + + const existing = edgesBySource.get(edge.from) ?? [] + existing.push(edge) + edgesBySource.set(edge.from, existing) + } + + // Create fan-out bundles + for (const [source, edges] of edgesBySource) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + const bundle: EdgeBundle = { + type: 'fan-out', + edges: [...edges], + sharedNode: source, + otherNodes: edges.map(e => e.to), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + return bundles +} + +/** + * Check if a group of edges can be bundled together. + * Returns false if edges have different styles, any have labels, + * or if the edges span subgraph boundaries (which creates complex routing). + */ +function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean { + if (edges.length < 2) return false + + const firstStyle = edges[0]!.style + const firstFromSg = getNodeSubgraph(graph, edges[0]!.from) + const firstToSg = getNodeSubgraph(graph, edges[0]!.to) + + for (const edge of edges) { + // Different styles can't be bundled (would look confusing) + if (edge.style !== firstStyle) return false + + // Edges with labels can't be bundled (labels would overlap at junction) + if (edge.text.length > 0) return false + + // Don't bundle if edges span different subgraph boundaries + // (creates complex routing that doesn't look good) + const fromSg = getNodeSubgraph(graph, edge.from) + const toSg = getNodeSubgraph(graph, edge.to) + if (fromSg !== firstFromSg || toSg !== firstToSg) return false + + // Don't bundle if source and target are in different subgraphs + // (cross-boundary edges have special routing needs) + if (fromSg !== toSg) return false + } + + return true +} + +// ============================================================================ +// Junction point calculation +// ============================================================================ + +/** + * Calculate the optimal junction point for a bundle. + * + * For fan-in (A & B --> C): + * - Junction is placed between the sources and the target + * - In TD: above the target, horizontally centered between sources + * - In LR: left of the target, vertically centered between sources + * + * For fan-out (A --> B & C): + * - Junction is placed between the source and the targets + * - In TD: below the source, horizontally centered between targets + * - In LR: right of the source, vertically centered between targets + */ +export function calculateJunctionPoint( + graph: AsciiGraph, + bundle: EdgeBundle, +): GridCoord { + const dir = graph.config.graphDirection + const sharedCoord = bundle.sharedNode.gridCoord! + const otherCoords = bundle.otherNodes.map(n => n.gridCoord!) + + if (bundle.type === 'fan-in') { + // Junction is BEFORE the shared target + // Calculate center of sources + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction above target, centered between sources + // Place it one row above the target's entry point + const junctionY = sharedCoord.y - 1 + // X is centered between sources, but clamped to shared node's X for alignment + const centerX = Math.floor((minX + maxX) / 2) + 1 // +1 for center of 3x3 block + const junctionX = sharedCoord.x + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction left of target, centered between sources + const junctionX = sharedCoord.x - 1 + const junctionY = sharedCoord.y + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } + } else { + // fan-out: Junction is AFTER the shared source + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction below source, will then split to targets + const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block + const junctionX = sharedCoord.x + 1 // Align with source's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction right of source + const junctionX = sharedCoord.x + 3 + const junctionY = sharedCoord.y + 1 + + return { x: junctionX, y: junctionY } + } + } +} + +// ============================================================================ +// Bundled edge routing +// ============================================================================ + +/** + * Route all edges in a bundle through the junction point. + * + * For fan-in bundles: + * 1. Route each source → junction (stored in edge.pathToJunction) + * 2. Route junction → target (stored in bundle.sharedPath) + * + * For fan-out bundles: + * 1. Route source → junction (stored in bundle.sharedPath) + * 2. Route junction → each target (stored in edge.pathToJunction) + */ +export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void { + const dir = graph.config.graphDirection + + // Calculate and store junction point + bundle.junctionPoint = calculateJunctionPoint(graph, bundle) + const junction = bundle.junctionPoint + + // Determine directions based on graph direction and bundle type + if (bundle.type === 'fan-in') { + // Sources converge to junction, then junction to target + bundle.junctionDir = dir === 'TD' ? Up : Left + bundle.sharedNodeDir = dir === 'TD' ? Down : Right + + // Route junction → target (shared path) + const targetCoord = bundle.sharedNode.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const sharedPath = getPath(graph.grid, junction, targetEntry) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [junction, targetEntry] + + // Route each source → junction + for (const edge of bundle.edges) { + const sourceCoord = edge.from.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const pathToJunction = getPath(graph.grid, sourceExit, junction) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [sourceExit, junction] + + // Set edge directions for proper drawing + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)] + } + } else { + // fan-out: Source to junction, then junction splits to targets + bundle.junctionDir = dir === 'TD' ? Down : Right + bundle.sharedNodeDir = dir === 'TD' ? Up : Left + + // Route source → junction (shared path) + const sourceCoord = bundle.sharedNode.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const sharedPath = getPath(graph.grid, sourceExit, junction) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [sourceExit, junction] + + // Route junction → each target + for (const edge of bundle.edges) { + const targetCoord = edge.to.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const pathToJunction = getPath(graph.grid, junction, targetEntry) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [junction, targetEntry] + + // Set edge directions + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)] + } + } +} + +/** + * Process all bundles in a graph: calculate junction points and route edges. + */ +export function processBundles(graph: AsciiGraph): void { + for (const bundle of graph.bundles) { + routeBundledEdges(graph, bundle) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts new file mode 100644 index 000000000..33229c963 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/edge-routing.ts @@ -0,0 +1,297 @@ +// ============================================================================ +// ASCII renderer — direction system and edge path determination +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go. +// Handles direction constants, edge attachment point selection, +// and dual-path comparison for optimal edge routing. +// ============================================================================ + +import type { GridCoord, Direction, AsciiEdge, AsciiGraph } from './types' +import { + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, + gridCoordDirection, +} from './types' +import { getPath, mergePath } from './pathfinder' +import { getEffectiveDirection, getNodeSubgraph } from './grid' +import { displayWidth } from '../text-metrics' + +// ============================================================================ +// Direction utilities +// ============================================================================ + +export function getOpposite(d: Direction): Direction { + if (d === Up) return Down + if (d === Down) return Up + if (d === Left) return Right + if (d === Right) return Left + if (d === UpperRight) return LowerLeft + if (d === UpperLeft) return LowerRight + if (d === LowerRight) return UpperLeft + if (d === LowerLeft) return UpperRight + return Middle +} + +/** Compare directions by value (not reference). */ +export function dirEquals(a: Direction, b: Direction): boolean { + return a.x === b.x && a.y === b.y +} + +/** + * Determine 8-way direction from one coordinate to another. + * Uses the coordinate difference to pick one of 8 cardinal/ordinal directions. + */ +export function determineDirection(from: { x: number; y: number }, to: { x: number; y: number }): Direction { + if (from.x === to.x) { + return from.y < to.y ? Down : Up + } else if (from.y === to.y) { + return from.x < to.x ? Right : Left + } else if (from.x < to.x) { + return from.y < to.y ? LowerRight : UpperRight + } else { + return from.y < to.y ? LowerLeft : UpperLeft + } +} + +// ============================================================================ +// Start/end direction selection for edges +// ============================================================================ + +/** Self-reference routing (node points to itself). */ +function selfReferenceDirection(graphDirection: string): [Direction, Direction, Direction, Direction] { + if (graphDirection === 'LR') return [Right, Down, Down, Right] + return [Down, Right, Right, Down] +} + +/** + * Determine preferred and alternative start/end directions for an edge. + * Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd]. + * + * The edge routing tries both pairs and picks the shorter path. + * Direction selection depends on relative node positions and graph direction (LR vs TD). + */ +export function determineStartAndEndDir( + edge: AsciiEdge, + graphDirection: string, +): [Direction, Direction, Direction, Direction] { + if (edge.from === edge.to) return selfReferenceDirection(graphDirection) + + const d = determineDirection(edge.from.gridCoord!, edge.to.gridCoord!) + + let preferredDir: Direction + let preferredOppositeDir: Direction + let alternativeDir: Direction + let alternativeOppositeDir: Direction + + const isBackwards = graphDirection === 'LR' + ? (dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft)) + : (dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight)) + + if (dirEquals(d, LowerRight)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Up + } else { + preferredDir = Right; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Left + } + } else if (dirEquals(d, UpperRight)) { + if (graphDirection === 'LR') { + preferredDir = Up; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Down + alternativeDir = Up; alternativeOppositeDir = Left + } + } else if (dirEquals(d, LowerLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Up + } else { + preferredDir = Left; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Right + } + } else if (dirEquals(d, UpperLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Right + } + } else if (isBackwards) { + if (graphDirection === 'LR' && dirEquals(d, Left)) { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Right + } else if (graphDirection === 'TD' && dirEquals(d, Up)) { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Down + } else { + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + } else { + // Default: go in the natural direction + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + + return [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] +} + +// ============================================================================ +// Edge path determination +// ============================================================================ + +/** + * Determine the path for an edge by trying two candidate routes (preferred + alternative) + * and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir. + * + * When both A* paths fail (common for edges crossing subgraph boundaries), falls back + * to a direct path using the start/end points. This ensures edges always have a path + * for arrowhead rendering. + * + * Uses the effective direction for edge routing, respecting subgraph direction overrides + * when both source and target are in the same subgraph. + */ +export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void { + // Determine effective direction for this edge + // If both nodes are in the same subgraph with a direction override, use it + // Otherwise, use the graph's direction (not source's effective direction) + const sourceSg = getNodeSubgraph(graph, edge.from) + const targetSg = getNodeSubgraph(graph, edge.to) + const effectiveDir = (sourceSg && sourceSg === targetSg && sourceSg.direction) + ? sourceSg.direction + : graph.config.graphDirection + + const [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] = + determineStartAndEndDir(edge, effectiveDir) + + // Try preferred path + const prefFrom = gridCoordDirection(edge.from.gridCoord!, preferredDir) + const prefTo = gridCoordDirection(edge.to.gridCoord!, preferredOppositeDir) + let preferredPath = getPath(graph.grid, prefFrom, prefTo) + + // Try alternative path + const altFrom = gridCoordDirection(edge.from.gridCoord!, alternativeDir) + const altTo = gridCoordDirection(edge.to.gridCoord!, alternativeOppositeDir) + let alternativePath = getPath(graph.grid, altFrom, altTo) + + // Case 1: Both paths found — pick the shorter one + if (preferredPath !== null && alternativePath !== null) { + preferredPath = mergePath(preferredPath) + alternativePath = mergePath(alternativePath) + + if (preferredPath.length <= alternativePath.length) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = preferredPath + } else { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = alternativePath + } + return + } + + // Case 2: Only preferred path found + if (preferredPath !== null) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = mergePath(preferredPath) + return + } + + // Case 3: Only alternative path found + if (alternativePath !== null) { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = mergePath(alternativePath) + return + } + + // Case 4: Both paths failed — create a direct fallback path + // This happens for edges crossing subgraph boundaries where A* can't find + // a clear route. We create a direct path from source to target exit points + // so arrowheads can still be rendered correctly. + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = [prefFrom, prefTo] +} + +/** + * Find the best line segment in an edge's path to place a label on. + * Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid + * label collisions when multiple edges share initial segments. + * Falls back to the widest segment if none are suitable. + * Also increases the column width at the label position to fit the text. + */ +export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void { + if (edge.text.length === 0) return + + const lenLabel = displayWidth(edge.text) + const pathLen = edge.path.length + const isVerticalFlow = graph.config.graphDirection === 'TD' + + // Collect all segments with their widths and orientation + const segments: { + line: [GridCoord, GridCoord] + width: number + index: number + isVertical: boolean + }[] = [] + + for (let i = 1; i < pathLen; i++) { + const p1 = edge.path[i - 1]! + const p2 = edge.path[i]! + const line: [GridCoord, GridCoord] = [p1, p2] + const width = calculateLineWidth(graph, line) + // A segment is vertical if X coords are same, horizontal if Y coords are same + const isVertical = p1.x === p2.x + segments.push({ line, width, index: i, isVertical }) + } + + // Find segments wide enough for the label, excluding the first segment + // The first segment is often shared between edges from the same source node + const suitableSegments = segments.filter(s => s.width >= lenLabel && s.index > 1) + + let largestLine: [GridCoord, GridCoord] + + if (suitableSegments.length > 0) { + // Prefer segments near the end of the path (closer to target) + // This avoids the shared initial segments from source + suitableSegments.sort((a, b) => b.index - a.index) + largestLine = suitableSegments[0]!.line + } else { + // Fall back to any suitable segment including the first + const fallbackSegments = segments.filter(s => s.width >= lenLabel) + if (fallbackSegments.length > 0) { + fallbackSegments.sort((a, b) => b.index - a.index) + largestLine = fallbackSegments[0]!.line + } else { + // No segment wide enough — use the widest one + segments.sort((a, b) => b.width - a.width) + largestLine = segments[0]?.line ?? [edge.path[0]!, edge.path[1]!] + } + } + + // Ensure column at midpoint is wide enough for the label + const minX = Math.min(largestLine[0].x, largestLine[1].x) + const maxX = Math.max(largestLine[0].x, largestLine[1].x) + const middleX = minX + Math.floor((maxX - minX) / 2) + + const current = graph.columnWidth.get(middleX) ?? 0 + graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2)) + + edge.labelLine = [largestLine[0], largestLine[1]] +} + +/** Calculate the total character width of a line segment by summing column widths. */ +function calculateLineWidth(graph: AsciiGraph, line: [GridCoord, GridCoord]): number { + let total = 0 + const startX = Math.min(line[0].x, line[1].x) + const endX = Math.max(line[0].x, line[1].x) + for (let x = startX; x <= endX; x++) { + total += graph.columnWidth.get(x) ?? 0 + } + return total +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts new file mode 100644 index 000000000..7db60535f --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/er-diagram.ts @@ -0,0 +1,441 @@ +// ============================================================================ +// ASCII renderer — ER diagrams +// +// Renders erDiagram text to ASCII/Unicode art. +// Each entity is a 2-section box (header | attributes). +// Relationships are drawn as lines with crow's foot notation at endpoints. +// +// Layout: entities are placed in a grid pattern (multiple rows if needed). +// Relationship lines use Manhattan routing between entity boxes. +// ============================================================================ + +import { parseErDiagram } from '../er/parser' +import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { drawMultiBox } from './draw' +import { splitLines } from './multiline-utils' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Entity box content +// ============================================================================ + +/** Format an attribute line: "PK type name" or "FK type name" etc. */ +function formatAttribute(attr: ErAttribute): string { + const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' ' + return `${keyStr}${attr.type} ${attr.name}` +} + +/** Build sections for an entity box: [header], [attributes] */ +function buildEntitySections(entity: ErEntity): string[][] { + // Support multi-line entity names + const header = splitLines(entity.label) + const attrs = entity.attributes.map(formatAttribute) + if (attrs.length === 0) return [header] + return [header, attrs] +} + +// ============================================================================ +// Crow's foot notation +// ============================================================================ + +/** + * Returns the ASCII/Unicode characters for a crow's foot cardinality marker. + * Markers are drawn adjacent to entity boxes at relationship endpoints. + * + * Standard ER notation: + * one: ─┤├─ perpendicular line (exactly one) + * zero-one: ─○┤─ circle + perpendicular (zero or one) + * many: ─<>─ crow's foot (one or more) + * zero-many: ─○<─ circle + crow's foot (zero or more) + * + * @param card - The cardinality type + * @param useAscii - Use ASCII-only characters + * @param isRight - True if this marker is on the right side of the relationship + */ +function getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string { + if (useAscii) { + switch (card) { + case 'one': return '|' + case 'zero-one': return 'o|' + case 'many': return isRight ? '<' : '>' + case 'zero-many': return isRight ? 'o<' : '>o' + } + } else { + // Use cleaner Unicode characters + switch (card) { + case 'one': return '│' + case 'zero-one': return '○│' + case 'many': return isRight ? '╟' : '╢' + case 'zero-many': return isRight ? '○╟' : '╢○' + } + } +} + +// ============================================================================ +// Positioned entity +// ============================================================================ + +interface PlacedEntity { + entity: ErEntity + sections: string[][] + x: number + y: number + width: number + height: number +} + +// ============================================================================ +// Connected Component Detection +// ============================================================================ + +/** + * Find connected components in the ER diagram using DFS. + * Treats relationships as undirected edges for connectivity. + * + * Returns an array of entity ID sets, one per connected component. + */ +function findConnectedComponents(diagram: ErDiagram): Set[] { + const visited = new Set() + const components: Set[] = [] + + // Build undirected adjacency list from relationships + const neighbors = new Map>() + for (const ent of diagram.entities) { + neighbors.set(ent.id, new Set()) + } + for (const rel of diagram.relationships) { + neighbors.get(rel.entity1)?.add(rel.entity2) + neighbors.get(rel.entity2)?.add(rel.entity1) + } + + // DFS to find each component + function dfs(startId: string, component: Set): void { + const stack = [startId] + while (stack.length > 0) { + const nodeId = stack.pop()! + if (visited.has(nodeId)) continue + + visited.add(nodeId) + component.add(nodeId) + + for (const neighbor of neighbors.get(nodeId) ?? []) { + if (!visited.has(neighbor)) { + stack.push(neighbor) + } + } + } + } + + // Find all components + for (const ent of diagram.entities) { + if (!visited.has(ent.id)) { + const component = new Set() + dfs(ent.id, component) + if (component.size > 0) { + components.push(component) + } + } + } + + return components +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** + * Render a Mermaid ER diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string. + */ +export function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseErDiagram(lines) + + if (diagram.entities.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 6 // horizontal gap between entity boxes + const vGap = 4 // vertical gap between rows (for relationship lines) + const componentGap = 6 // vertical gap between disconnected components + + // --- Build entity box dimensions --- + const entitySections = new Map() + const entityBoxW = new Map() + const entityBoxH = new Map() + const entityById = new Map() + + for (const ent of diagram.entities) { + entityById.set(ent.id, ent) + const sections = buildEntitySections(ent) + entitySections.set(ent.id, sections) + + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 + + entityBoxW.set(ent.id, boxW) + entityBoxH.set(ent.id, boxH) + } + + // --- Find connected components --- + const components = findConnectedComponents(diagram) + + // --- Layout: place each component, then stack components vertically --- + const placed = new Map() + let currentY = 0 + + for (const component of components) { + // Get entities in this component (preserve original order for consistency) + const componentEntities = diagram.entities.filter(e => component.has(e.id)) + + // Layout entities within this component horizontally + // Use sqrt-based row limit for larger components + const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length))) + + let currentX = 0 + let maxRowH = 0 + let colCount = 0 + const componentStartY = currentY + + for (const ent of componentEntities) { + const w = entityBoxW.get(ent.id)! + const h = entityBoxH.get(ent.id)! + + if (colCount >= maxPerRow) { + // Wrap to next row within this component + currentY += maxRowH + vGap + currentX = 0 + maxRowH = 0 + colCount = 0 + } + + placed.set(ent.id, { + entity: ent, + sections: entitySections.get(ent.id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + + currentX += w + hGap + maxRowH = Math.max(maxRowH, h) + colCount++ + } + + // Move to next component row (add gap between components) + currentY += maxRowH + componentGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw entity boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Draw relationships --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const e1 = placed.get(rel.entity1) + const e2 = placed.get(rel.entity2) + if (!e1 || !e2) continue + + const lineH = rel.identifying ? H : dashH + const lineV = rel.identifying ? V : dashV + + // Determine connection direction based on relative position. + // Connect from right side of left entity to left side of right entity (horizontal), + // or from bottom of upper entity to top of lower entity (vertical). + const e1CX = e1.x + Math.floor(e1.width / 2) + const e1CY = e1.y + Math.floor(e1.height / 2) + const e2CX = e2.x + Math.floor(e2.width / 2) + const e2CY = e2.y + Math.floor(e2.height / 2) + + // Check if entities are on the same row (horizontal connection) + const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height) + + if (sameRow) { + // Horizontal connection: right side of left entity → left side of right entity + const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1] + const [leftCard, rightCard] = e1CX < e2CX + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startX = left.x + left.width + const endX = right.x - 1 + const lineY = left.y + Math.floor(left.height / 2) + + // Draw horizontal line + for (let x = startX; x <= endX; x++) { + setC(x, lineY, lineH, 'line') + } + + // Draw crow's foot markers at endpoints + // Left marker (at left entity's right edge) - isRight=false + const leftChars = getCrowsFootChars(leftCard, useAscii, false) + for (let i = 0; i < leftChars.length; i++) { + setC(startX + i, lineY, leftChars[i]!, 'arrow') + } + + // Right marker (at right entity's left edge) - isRight=true + const rightChars = getCrowsFootChars(rightCard, useAscii, true) + for (let i = 0; i < rightChars.length; i++) { + setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow') + } + + // Relationship label centered in the gap between the two entities, below the line. + // Clamp label to the gap region [startX, endX] to avoid overwriting box borders. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const gapMid = Math.floor((startX + endX) / 2) + + // Place lines below the relationship line (lineY + 1, lineY + 2, ...) + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! + const cells = toCells(line) + const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2)) + const labelY = lineY + 1 + lineIdx + // Ensure canvas is tall enough + increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) + increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]! + if (cell === WIDE_PAD) continue // written atomically with its lead + const lx = labelStart + i + const wide = cells[i + 1] === WIDE_PAD + // Keep wide-glyph pairs atomic within the gap region + if (lx < startX || lx + (wide ? 1 : 0) > endX) continue + setC(lx, labelY, cell, 'text') + if (wide) setC(lx + 1, labelY, WIDE_PAD, 'text') + } + } + } + } else { + // Vertical connection: bottom of upper entity → top of lower entity + const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1] + const [upperCard, lowerCard] = e1CY < e2CY + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startY = upper.y + upper.height + const endY = lower.y - 1 + const lineX = upper.x + Math.floor(upper.width / 2) + + // Vertical line + for (let y = startY; y <= endY; y++) { + setC(lineX, y, lineV, 'line') + } + + // If horizontal offset needed, add a horizontal segment + const lowerCX = lower.x + Math.floor(lower.width / 2) + if (lineX !== lowerCX) { + const midY = Math.floor((startY + endY) / 2) + // Horizontal segment at midY + const lx = Math.min(lineX, lowerCX) + const rx = Math.max(lineX, lowerCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + // Vertical from midY to lower entity + for (let y = midY + 1; y <= endY; y++) { + setC(lowerCX, y, lineV, 'line') + } + } + + // Crow's foot markers (vertical direction) + // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false) + const upperChars = getCrowsFootChars(upperCard, useAscii, false) + for (let i = 0; i < upperChars.length; i++) { + setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow') + } + + // Lower marker (at lower entity's top edge) - treat as target side (isRight=true) + const targetX = lineX !== lowerCX ? lowerCX : lineX + const lowerChars = getCrowsFootChars(lowerCard, useAscii, true) + for (let i = 0; i < lowerChars.length; i++) { + setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow') + } + + // Relationship label — placed to the right of the vertical line at the midpoint. + // We expand the canvas as needed since labels can extend beyond the initial bounds. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const midY = Math.floor((startY + endY) / 2) + // Center lines vertically around midY + const startLabelY = midY - Math.floor((lines.length - 1) / 2) + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const cells = toCells(lines[lineIdx]!) + const labelX = lineX + 2 + const y = startLabelY + lineIdx + if (y >= 0) { + for (let i = 0; i < cells.length; i++) { + const lx = labelX + i + if (lx >= 0) { + increaseSize(canvas, lx + 1, y + 1) + increaseRoleCanvasSize(rc, lx + 1, y + 1) + setC(lx, y, cells[i]!, 'text') + } + } + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts new file mode 100644 index 000000000..08e2c8051 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/grid.ts @@ -0,0 +1,578 @@ +// ============================================================================ +// ASCII renderer — grid-based layout +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go. +// Places nodes on a logical grid, computes column/row sizes, +// converts grid coordinates to character-level drawing coordinates, +// and handles subgraph bounding boxes. +// ============================================================================ + +import type { + GridCoord, DrawingCoord, Direction, AsciiGraph, AsciiNode, AsciiSubgraph, +} from './types' +import { gridKey } from './types' +import { mkCanvas, setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas' +import { determinePath, determineLabelLine } from './edge-routing' +import { analyzeEdgeBundles, processBundles } from './edge-bundling' +import { drawBox } from './draw' +import { maxLineWidth, lineCount } from './multiline-utils' +import { getShapeDimensions } from './shapes/index' + +// ============================================================================ +// Grid coordinate → drawing coordinate conversion +// ============================================================================ + +/** + * Convert a grid coordinate to a drawing (character) coordinate. + * Sums column widths up to the target column, and row heights up to the target row, + * then centers within the cell. + */ +export function gridToDrawingCoord( + graph: AsciiGraph, + c: GridCoord, + dir?: Direction, +): DrawingCoord { + const target: GridCoord = dir + ? { x: c.x + dir.x, y: c.y + dir.y } + : c + + let x = 0 + for (let col = 0; col < target.x; col++) { + x += graph.columnWidth.get(col) ?? 0 + } + + let y = 0 + for (let row = 0; row < target.y; row++) { + y += graph.rowHeight.get(row) ?? 0 + } + + const colW = graph.columnWidth.get(target.x) ?? 0 + const rowH = graph.rowHeight.get(target.y) ?? 0 + return { + x: x + Math.floor(colW / 2) + graph.offsetX, + y: y + Math.floor(rowH / 2) + graph.offsetY, + } +} + +/** Convert a path of grid coords to drawing coords. */ +export function lineToDrawing(graph: AsciiGraph, line: GridCoord[]): DrawingCoord[] { + return line.map(c => gridToDrawingCoord(graph, c)) +} + +// ============================================================================ +// Node placement on the grid +// ============================================================================ + +/** + * Reserve a 3x3 block in the grid for a node. + * If the requested position is occupied, recursively shift by 4 grid units + * (in the perpendicular direction based on effective direction) until a free spot is found. + * + * @param effectiveDir - Optional direction override. If not provided, uses the node's + * effective direction (subgraph direction if in a subgraph with override, + * otherwise graph direction). + */ +export function reserveSpotInGrid( + graph: AsciiGraph, + node: AsciiNode, + requested: GridCoord, + effectiveDir?: 'LR' | 'TD', +): GridCoord { + // Determine direction for collision handling + const dir = effectiveDir ?? getEffectiveDirection(graph, node) + + if (graph.grid.has(gridKey(requested))) { + // Collision — shift perpendicular to main flow direction + if (dir === 'LR') { + return reserveSpotInGrid(graph, node, { x: requested.x, y: requested.y + 4 }, dir) + } else { + return reserveSpotInGrid(graph, node, { x: requested.x + 4, y: requested.y }, dir) + } + } + + // Reserve the 3x3 block + for (let dx = 0; dx < 3; dx++) { + for (let dy = 0; dy < 3; dy++) { + const reserved: GridCoord = { x: requested.x + dx, y: requested.y + dy } + graph.grid.set(gridKey(reserved), node) + } + } + + node.gridCoord = requested + return requested +} + +// ============================================================================ +// Column width / row height computation +// ============================================================================ + +/** + * Set column widths and row heights for a node's 3x3 grid block. + * Each node occupies 3 columns (border, content, border) and 3 rows. + * Uses shape-aware dimensions to properly size non-rectangular shapes. + */ +export function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void { + const gc = node.gridCoord! + const padding = graph.config.boxBorderPadding + + // Get shape-aware dimensions + const shapeDims = getShapeDimensions(node.shape, node.displayLabel, { + useAscii: graph.config.useAscii, + padding, + }) + + // Use shape-provided grid dimensions + const colWidths = shapeDims.gridColumns + const rowHeights = shapeDims.gridRows + + for (let idx = 0; idx < colWidths.length; idx++) { + const xCoord = gc.x + idx + const current = graph.columnWidth.get(xCoord) ?? 0 + graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!)) + } + + for (let idx = 0; idx < rowHeights.length; idx++) { + const yCoord = gc.y + idx + const current = graph.rowHeight.get(yCoord) ?? 0 + graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!)) + } + + // Padding column/row before the node (spacing between nodes) + if (gc.x > 0) { + const current = graph.columnWidth.get(gc.x - 1) ?? 0 + graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX)) + } + + if (gc.y > 0) { + let basePadding = graph.config.paddingY + // Extra vertical padding for nodes with incoming edges from outside their subgraph + if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) { + const subgraphOverhead = 4 + basePadding += subgraphOverhead + } + const current = graph.rowHeight.get(gc.y - 1) ?? 0 + graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding)) + } +} + +/** Ensure grid has width/height entries for all cells along an edge path. */ +export function increaseGridSizeForPath(graph: AsciiGraph, path: GridCoord[]): void { + for (const c of path) { + if (!graph.columnWidth.has(c.x)) { + graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2)) + } + if (!graph.rowHeight.has(c.y)) { + graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2)) + } + } +} + +// ============================================================================ +// Subgraph helpers +// ============================================================================ + +function isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + return graph.subgraphs.some(sg => sg.nodes.includes(node)) +} + +/** + * Get the innermost subgraph that directly contains this node. + * Returns null if node is not in any subgraph. + */ +export function getNodeSubgraph(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph | null { + // Find the innermost (most deeply nested) subgraph containing the node + let innermost: AsciiSubgraph | null = null + for (const sg of graph.subgraphs) { + if (sg.nodes.includes(node)) { + // Check if this subgraph is deeper (more nested) than current innermost + if (!innermost || isAncestorOrSelf(innermost, sg)) { + innermost = sg + } + } + } + return innermost +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** + * Get the effective direction for a node's layout. + * Returns the subgraph's direction override if the node is in a subgraph with one, + * otherwise returns the graph-level direction. + */ +export function getEffectiveDirection(graph: AsciiGraph, node: AsciiNode): 'LR' | 'TD' { + const sg = getNodeSubgraph(graph, node) + if (sg?.direction) { + return sg.direction + } + return graph.config.graphDirection +} + +/** + * Check if a node has an incoming edge from outside its subgraph + * AND is the topmost such node in its subgraph. + * Used to add extra vertical padding for subgraph borders. + */ +function hasIncomingEdgeFromOutsideSubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return false + + let hasExternalEdge = false + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + hasExternalEdge = true + break + } + } + } + + if (!hasExternalEdge) return false + + // Only return true for the topmost node with an external incoming edge + for (const otherNode of nodeSg.nodes) { + if (otherNode === node || !otherNode.gridCoord) continue + let otherHasExternal = false + for (const edge of graph.edges) { + if (edge.to === otherNode) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + otherHasExternal = true + break + } + } + } + if (otherHasExternal && otherNode.gridCoord.y < node.gridCoord!.y) { + return false + } + } + + return true +} + +// ============================================================================ +// Subgraph bounding boxes +// ============================================================================ + +function calculateSubgraphBoundingBox(graph: AsciiGraph, sg: AsciiSubgraph): void { + if (sg.nodes.length === 0) return + + let minX = 1_000_000 + let minY = 1_000_000 + let maxX = -1_000_000 + let maxY = -1_000_000 + + // Include children's bounding boxes + for (const child of sg.children) { + calculateSubgraphBoundingBox(graph, child) + if (child.nodes.length > 0) { + minX = Math.min(minX, child.minX) + minY = Math.min(minY, child.minY) + maxX = Math.max(maxX, child.maxX) + maxY = Math.max(maxY, child.maxY) + } + } + + // Include node positions + for (const node of sg.nodes) { + if (!node.drawingCoord || !node.drawing) continue + const nodeMinX = node.drawingCoord.x + const nodeMinY = node.drawingCoord.y + const nodeMaxX = nodeMinX + node.drawing.length - 1 + const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1 + minX = Math.min(minX, nodeMinX) + minY = Math.min(minY, nodeMinY) + maxX = Math.max(maxX, nodeMaxX) + maxY = Math.max(maxY, nodeMaxY) + } + + const subgraphPadding = 2 + const subgraphLabelSpace = 2 + sg.minX = minX - subgraphPadding + sg.minY = minY - subgraphPadding - subgraphLabelSpace + sg.maxX = maxX + subgraphPadding + sg.maxY = maxY + subgraphPadding +} + +/** Ensure non-overlapping root subgraphs have minimum spacing. */ +function ensureSubgraphSpacing(graph: AsciiGraph): void { + const minSpacing = 1 + const rootSubgraphs = graph.subgraphs.filter(sg => sg.parent === null && sg.nodes.length > 0) + + for (let i = 0; i < rootSubgraphs.length; i++) { + for (let j = i + 1; j < rootSubgraphs.length; j++) { + const sg1 = rootSubgraphs[i]! + const sg2 = rootSubgraphs[j]! + + // Horizontal overlap → adjust vertical + if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) { + if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) { + sg2.minY = sg1.maxY + minSpacing + 1 + } else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) { + sg1.minY = sg2.maxY + minSpacing + 1 + } + } + // Vertical overlap → adjust horizontal + if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) { + if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) { + sg2.minX = sg1.maxX + minSpacing + 1 + } else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) { + sg1.minX = sg2.maxX + minSpacing + 1 + } + } + } + } +} + +export function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void { + for (const sg of graph.subgraphs) { + calculateSubgraphBoundingBox(graph, sg) + } + ensureSubgraphSpacing(graph) +} + +/** + * Offset all drawing coordinates so subgraph borders don't go negative. + * If any subgraph has negative min coordinates, shift everything positive. + */ +export function offsetDrawingForSubgraphs(graph: AsciiGraph): void { + if (graph.subgraphs.length === 0) return + + let minX = 0 + let minY = 0 + for (const sg of graph.subgraphs) { + minX = Math.min(minX, sg.minX) + minY = Math.min(minY, sg.minY) + } + + const offsetX = -minX + const offsetY = -minY + if (offsetX === 0 && offsetY === 0) return + + graph.offsetX = offsetX + graph.offsetY = offsetY + + for (const sg of graph.subgraphs) { + sg.minX += offsetX + sg.minY += offsetY + sg.maxX += offsetX + sg.maxY += offsetY + } + + for (const node of graph.nodes) { + if (node.drawingCoord) { + node.drawingCoord.x += offsetX + node.drawingCoord.y += offsetY + } + } +} + +// ============================================================================ +// Main layout orchestrator +// ============================================================================ + +/** + * createMapping performs the full grid layout: + * 1. Place root nodes on the grid + * 2. Place child nodes level by level + * 3. Compute column widths and row heights + * 4. Run A* pathfinding for all edges + * 5. Determine label placement + * 6. Convert grid coords → drawing coords + * 7. Generate node box drawings + * 8. Calculate subgraph bounding boxes + */ +export function createMapping(graph: AsciiGraph): void { + const dir = graph.config.graphDirection + const highestPositionPerLevel: number[] = new Array(100).fill(0) + + // Identify root nodes — nodes that aren't the target of any edge + const nodesFound = new Set() + const initialRoots: AsciiNode[] = [] + + for (const node of graph.nodes) { + if (!nodesFound.has(node.name)) { + initialRoots.push(node) + } + nodesFound.add(node.name) + for (const child of getChildren(graph, node)) { + nodesFound.add(child.name) + } + } + + // Filter out subgraph nodes that have incoming edges from external sources. + // This handles the case where subgraph is declared before external nodes + // (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should). + const rootNodes = initialRoots.filter(node => { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return true // external nodes: keep as roots + + // Check if this subgraph node has incoming edges from outside its subgraph + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + return false // has external incoming edge → not a root + } + } + } + return true + }) + + // In LR mode with both external and subgraph roots, separate them + // so subgraph roots are placed one level deeper + let hasExternalRoots = false + let hasSubgraphRootsWithEdges = false + for (const node of rootNodes) { + if (isNodeInAnySubgraph(graph, node)) { + if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true + } else { + hasExternalRoots = true + } + } + const shouldSeparate = dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges + + let externalRootNodes: AsciiNode[] + let subgraphRootNodes: AsciiNode[] = [] + + if (shouldSeparate) { + externalRootNodes = rootNodes.filter(n => !isNodeInAnySubgraph(graph, n)) + subgraphRootNodes = rootNodes.filter(n => isNodeInAnySubgraph(graph, n)) + } else { + externalRootNodes = rootNodes + } + + // Place external root nodes + for (const node of externalRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: 0, y: highestPositionPerLevel[0]! } + : { x: highestPositionPerLevel[0]!, y: 0 } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[0] = highestPositionPerLevel[0]! + 4 + } + + // Place subgraph root nodes at level 4 (one level in from the edge) + if (shouldSeparate && subgraphRootNodes.length > 0) { + const subgraphLevel = 4 + for (const node of subgraphRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel]! } + : { x: highestPositionPerLevel[subgraphLevel]!, y: subgraphLevel } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[subgraphLevel] = highestPositionPerLevel[subgraphLevel]! + 4 + } + } + + // Place child nodes level by level + // Use subgraph direction only when both parent and child are in the same subgraph + // Multi-pass: iterate until all nodes are placed (handles non-topological node order) + // Note: when shouldSeparate, externalRootNodes + subgraphRootNodes = rootNodes + // otherwise, externalRootNodes = rootNodes and subgraphRootNodes is empty + let placedCount = externalRootNodes.length + subgraphRootNodes.length + while (placedCount < graph.nodes.length) { + const prevCount = placedCount + for (const node of graph.nodes) { + if (node.gridCoord === null) continue // skip unplaced nodes + const gc = node.gridCoord + + for (const child of getChildren(graph, node)) { + if (child.gridCoord !== null) continue // already placed + + // Determine direction for this edge (parent -> child) + // Use subgraph direction only if both are in the same subgraph with override + const parentSg = getNodeSubgraph(graph, node) + const childSg = getNodeSubgraph(graph, child) + const edgeDir = (parentSg && parentSg === childSg && parentSg.direction) + ? parentSg.direction + : graph.config.graphDirection + + const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4 + + // Determine position based on direction context + let highestPosition: number + if (edgeDir !== graph.config.graphDirection) { + // Cross-direction: use parent's perpendicular coordinate + // This keeps children aligned with parent when direction changes + highestPosition = edgeDir === 'LR' ? gc.y : gc.x + } else { + // Same direction: use level tracker + highestPosition = highestPositionPerLevel[childLevel]! + } + + const requested: GridCoord = edgeDir === 'LR' + ? { x: childLevel, y: highestPosition } + : { x: highestPosition, y: childLevel } + reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir) + + // Only update level tracker for same-direction placements + if (edgeDir === graph.config.graphDirection) { + highestPositionPerLevel[childLevel] = highestPosition + 4 + } + placedCount++ + } + } + // Safety: break if no progress made (handles disconnected nodes) + if (placedCount === prevCount) break + } + + // Compute column widths and row heights + for (const node of graph.nodes) { + setColumnWidth(graph, node) + } + + // Analyze edges for bundling (parallel links like A & B --> C) + // This groups edges that share sources or targets for cleaner visualization + graph.bundles = analyzeEdgeBundles(graph) + + // Route bundled edges through junction points + processBundles(graph) + + // Route non-bundled edges via A* and determine label positions + for (const edge of graph.edges) { + // Skip edges that were already routed as part of a bundle + if (edge.bundle && edge.path.length > 0) { + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + continue + } + + determinePath(graph, edge) + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + } + + // Convert grid coords → drawing coords and generate box drawings + for (const node of graph.nodes) { + node.drawingCoord = gridToDrawingCoord(graph, node.gridCoord!) + node.drawing = drawBox(node, graph) + } + + // Set canvas size and compute subgraph bounding boxes + setCanvasSizeToGrid(graph.canvas, graph.columnWidth, graph.rowHeight) + setRoleCanvasSizeToGrid(graph.roleCanvas, graph.columnWidth, graph.rowHeight) + calculateSubgraphBoundingBoxes(graph) + offsetDrawingForSubgraphs(graph) +} + +// ============================================================================ +// Graph traversal helpers +// ============================================================================ + +/** Get all edges originating from a node. */ +function getEdgesFromNode(graph: AsciiGraph, node: AsciiNode): AsciiGraph['edges'] { + return graph.edges.filter(e => e.from.name === node.name) +} + +/** Get all direct children of a node (targets of outgoing edges). */ +function getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] { + return getEdgesFromNode(graph, node).map(e => e.to) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts new file mode 100644 index 000000000..d208d2a1d --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/index.ts @@ -0,0 +1,187 @@ +// ============================================================================ +// beautiful-mermaid — ASCII renderer public API +// +// Renders Mermaid diagrams to ASCII or Unicode box-drawing art. +// No external dependencies — pure TypeScript. +// +// Supported diagram types: +// - Flowcharts (graph TD / flowchart LR) — grid-based layout with A* pathfinding +// - State diagrams (stateDiagram-v2) — same pipeline as flowcharts +// - Sequence diagrams (sequenceDiagram) — column-based timeline layout +// - Class diagrams (classDiagram) — level-based UML layout +// - ER diagrams (erDiagram) — grid layout with crow's foot notation +// +// Usage: +// import { renderMermaidASCII } from 'beautiful-mermaid' +// const ascii = renderMermaidASCII('graph LR\n A --> B') +// ============================================================================ + +import { parseMermaid } from '../parser' +import { convertToAsciiGraph } from './converter' +import { createMapping } from './grid' +import { drawGraph } from './draw' +import { canvasToString, flipCanvasVertically, flipRoleCanvasVertically } from './canvas' +import { renderSequenceAscii } from './sequence' +import { renderClassAscii } from './class-diagram' +import { renderErAscii } from './er-diagram' +import { renderXYChartAscii } from './xychart' +import { detectColorMode, DEFAULT_ASCII_THEME } from './ansi' +import type { AsciiConfig, AsciiTheme, ColorMode } from './types' +import type { Direction } from '../types' + +// Re-export types for external use +export type { AsciiTheme, ColorMode } +export { DEFAULT_ASCII_THEME, detectColorMode } + +export interface AsciiRenderOptions { + /** true = ASCII chars (+,-,|,>), false = Unicode box-drawing (┌,─,│,►). Default: false */ + useAscii?: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX?: number + /** Vertical spacing between nodes. Default: 5 */ + paddingY?: number + /** Padding inside node boxes. Default: 1 */ + boxBorderPadding?: number + /** + * Force the layout direction, overriding the direction parsed from the + * diagram source. Applies to the flowchart + state-diagram grid pipeline + * (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a + * wide `LR` graph into a narrow viewport by laying it out top-down. + * Default: undefined (use the source's own direction). + */ + direction?: Direction + /** + * Color mode for output. + * - 'none': No colors (plain text) + * - 'auto': Auto-detect (terminal ANSI capabilities, or HTML in browsers) + * - 'ansi16': 16-color ANSI + * - 'ansi256': 256-color xterm + * - 'truecolor': 24-bit RGB + * - 'html': HTML tags with inline color styles (for browser rendering) + * Default: 'auto' + */ + colorMode?: ColorMode | 'auto' + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: Partial +} + +/** + * Detect the diagram type from the mermaid source text. + * Mirrors the detection logic in src/index.ts for the SVG renderer. + */ +function detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' { + const firstLine = text.trim().split('\n')[0]?.trim().toLowerCase() ?? '' + + if (/^xychart(-beta)?\b/.test(firstLine)) return 'xychart' + if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence' + if (/^classdiagram\s*$/.test(firstLine)) return 'class' + if (/^erdiagram\s*$/.test(firstLine)) return 'er' + + // Default: flowchart/state (handled by parseMermaid internally) + return 'flowchart' +} + +/** + * Render Mermaid diagram text to an ASCII/Unicode string. + * + * Synchronous — no async layout engine needed (unlike the SVG renderer). + * Auto-detects diagram type from the header line and dispatches to + * the appropriate renderer. + * + * @param text - Mermaid source text (any supported diagram type) + * @param options - Rendering options + * @returns Multi-line ASCII/Unicode string + * + * @example + * ```ts + * const result = renderMermaidAscii(` + * graph LR + * A --> B --> C + * `, { useAscii: true }) + * + * // Output: + * // +---+ +---+ +---+ + * // | | | | | | + * // | A |---->| B |---->| C | + * // | | | | | | + * // +---+ +---+ +---+ + * ``` + */ +export function renderMermaidASCII( + text: string, + options: AsciiRenderOptions = {}, +): string { + const config: AsciiConfig = { + useAscii: options.useAscii ?? false, + paddingX: options.paddingX ?? 5, + paddingY: options.paddingY ?? 5, + boxBorderPadding: options.boxBorderPadding ?? 1, + graphDirection: 'TD', // default, overridden for flowcharts below + } + + // Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode) + const colorMode: ColorMode = options.colorMode === 'auto' || options.colorMode === undefined + ? detectColorMode() + : options.colorMode + + // Merge user theme with defaults + const theme: AsciiTheme = { ...DEFAULT_ASCII_THEME, ...options.theme } + + const diagramType = detectDiagramType(text) + + switch (diagramType) { + case 'xychart': + return renderXYChartAscii(text, config, colorMode, theme) + + case 'sequence': + return renderSequenceAscii(text, config, colorMode, theme) + + case 'class': + return renderClassAscii(text, config, colorMode, theme) + + case 'er': + return renderErAscii(text, config, colorMode, theme) + + case 'flowchart': + default: { + // Flowchart + state diagram pipeline (original) + const parsed = parseMermaid(text) + + // Honor an explicit direction override. Applied before normalization so + // the LR/TD split and the BT vertical-flip below behave exactly as if the + // source had authored this direction. + if (options.direction) { + parsed.direction = options.direction + } + + // Normalize direction for grid layout. + // BT is laid out as TD then flipped vertically after drawing. + // RL is treated as LR (full RL support not yet implemented). + if (parsed.direction === 'LR' || parsed.direction === 'RL') { + config.graphDirection = 'LR' + } else { + config.graphDirection = 'TD' + } + + const graph = convertToAsciiGraph(parsed, config) + createMapping(graph) + drawGraph(graph) + + // BT: flip the finished canvas vertically so the flow runs bottom→top. + // The grid layout ran as TD; flipping + character remapping produces BT. + if (parsed.direction === 'BT') { + flipCanvasVertically(graph.canvas) + flipRoleCanvasVertically(graph.roleCanvas) + } + + return canvasToString(graph.canvas, { + roleCanvas: graph.roleCanvas, + colorMode, + theme, + }) + } + } +} + +/** Lowercase alias kept as the public name used by the pi-utils wrapper. */ +export const renderMermaidAscii = renderMermaidASCII diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts new file mode 100644 index 000000000..f66791f96 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/multiline-utils.ts @@ -0,0 +1,78 @@ +// ============================================================================ +// ASCII renderer — multi-line text utilities +// +// Shared utilities for handling multi-line labels (containing \n from
          tags) +// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and +// centered rendering across all diagram types. +// ============================================================================ + +import type { Canvas } from './types' +import { drawText } from './canvas' +import { displayWidth } from '../text-metrics' + +/** + * Split a label into lines. + * Labels are already normalized by parsers (br tags → \n). + */ +export function splitLines(label: string): string[] { + return label.split('\n') +} + +/** + * Get the maximum line width for sizing calculations. + * Used to determine column widths for multi-line labels. + */ +export function maxLineWidth(label: string): number { + const lines = splitLines(label) + return Math.max(...lines.map(l => displayWidth(l)), 0) +} + +/** + * Get the number of lines for height calculations. + * Used to determine row heights for multi-line labels. + */ +export function lineCount(label: string): number { + return splitLines(label).length +} + +/** + * Draw multi-line text centered at (cx, cy). + * Expands vertically from the center point. + * Each line is horizontally centered independently. + */ +export function drawMultilineTextCentered( + canvas: Canvas, + label: string, + cx: number, + cy: number +): void { + const lines = splitLines(label) + const totalHeight = lines.length + // Center vertically: start y positions lines evenly around cy + const startY = cy - Math.floor((totalHeight - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + // Center each line horizontally + const startX = cx - Math.floor(displayWidth(line) / 2) + // Force overwrite for node labels (they take priority) + drawText(canvas, { x: startX, y: startY + i }, line, true) + } +} + +/** + * Draw multi-line text left-aligned starting at (x, y). + * Each subsequent line is placed one row below. + */ +export function drawMultilineTextLeft( + canvas: Canvas, + label: string, + x: number, + y: number +): void { + const lines = splitLines(label) + for (let i = 0; i < lines.length; i++) { + // Force overwrite for node labels (they take priority) + drawText(canvas, { x, y: y + i }, lines[i]!, true) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts new file mode 100644 index 000000000..d7e40ddfc --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts @@ -0,0 +1,215 @@ +// ============================================================================ +// ASCII renderer — A* pathfinding for edge routing +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/arrow.go. +// Uses A* search with a corner-penalizing heuristic to find clean +// paths between nodes on the grid. Prefers straight lines over zigzags. +// ============================================================================ + +import type { GridCoord, AsciiNode } from './types' +import { gridKey, gridCoordEquals } from './types' + +// ============================================================================ +// Priority queue (min-heap) for A* open set +// ============================================================================ + +interface PQItem { + coord: GridCoord + priority: number +} + +/** + * Simple min-heap priority queue. + * For the grid sizes we handle (~100s of cells), this is more than fast enough. + */ +class MinHeap { + private items: PQItem[] = [] + + get length(): number { + return this.items.length + } + + push(item: PQItem): void { + this.items.push(item) + this.bubbleUp(this.items.length - 1) + } + + pop(): PQItem | undefined { + if (this.items.length === 0) return undefined + const top = this.items[0]! + const last = this.items.pop()! + if (this.items.length > 0) { + this.items[0] = last + this.sinkDown(0) + } + return top + } + + private bubbleUp(i: number): void { + while (i > 0) { + const parent = (i - 1) >> 1 + if (this.items[i]!.priority < this.items[parent]!.priority) { + ;[this.items[i], this.items[parent]] = [this.items[parent]!, this.items[i]!] + i = parent + } else { + break + } + } + } + + private sinkDown(i: number): void { + const n = this.items.length + while (true) { + let smallest = i + const left = 2 * i + 1 + const right = 2 * i + 2 + if (left < n && this.items[left]!.priority < this.items[smallest]!.priority) { + smallest = left + } + if (right < n && this.items[right]!.priority < this.items[smallest]!.priority) { + smallest = right + } + if (smallest !== i) { + ;[this.items[i], this.items[smallest]] = [this.items[smallest]!, this.items[i]!] + i = smallest + } else { + break + } + } + } +} + +// ============================================================================ +// A* heuristic +// ============================================================================ + +/** + * Manhattan distance with a +1 penalty when both dx and dy are non-zero. + * This encourages the pathfinder to prefer straight lines and minimize corners. + */ +export function heuristic(a: GridCoord, b: GridCoord): number { + const absX = Math.abs(a.x - b.x) + const absY = Math.abs(a.y - b.y) + if (absX === 0 || absY === 0) { + return absX + absY + } + return absX + absY + 1 +} + +// ============================================================================ +// A* pathfinding +// ============================================================================ + +/** 4-directional movement (no diagonals in grid pathfinding). */ +const MOVE_DIRS: GridCoord[] = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, +] + +/** Check if a grid cell is unoccupied and has non-negative coordinates. */ +function isFreeInGrid(grid: Map, c: GridCoord): boolean { + if (c.x < 0 || c.y < 0) return false + return !grid.has(gridKey(c)) +} + +/** + * Find a path from `from` to `to` on the grid using A*. + * Returns the path as an array of GridCoords, or null if no path exists. + */ +export function getPath( + grid: Map, + from: GridCoord, + to: GridCoord, +): GridCoord[] | null { + const pq = new MinHeap() + pq.push({ coord: from, priority: 0 }) + + const costSoFar = new Map() + costSoFar.set(gridKey(from), 0) + + const cameFrom = new Map() + cameFrom.set(gridKey(from), null) + + while (pq.length > 0) { + const current = pq.pop()!.coord + + if (gridCoordEquals(current, to)) { + // Reconstruct path by walking backwards through cameFrom + const path: GridCoord[] = [] + let c: GridCoord | null = current + while (c !== null) { + path.unshift(c) + c = cameFrom.get(gridKey(c)) ?? null + } + return path + } + + const currentCost = costSoFar.get(gridKey(current))! + + for (const dir of MOVE_DIRS) { + const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y } + + // Allow moving to the destination even if it's occupied (it's a node boundary) + if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) { + continue + } + + const newCost = currentCost + 1 + const nextKey = gridKey(next) + const existingCost = costSoFar.get(nextKey) + + if (existingCost === undefined || newCost < existingCost) { + costSoFar.set(nextKey, newCost) + const priority = newCost + heuristic(next, to) + pq.push({ coord: next, priority }) + cameFrom.set(nextKey, current) + } + } + } + + return null // No path found +} + +/** + * Simplify a path by removing intermediate waypoints on straight segments. + * E.g., [(0,0), (1,0), (2,0), (2,1)] becomes [(0,0), (2,0), (2,1)]. + * This reduces the number of line-drawing operations. + */ +export function mergePath(path: GridCoord[]): GridCoord[] { + if (path.length <= 2) return path + + const toRemove = new Set() + let step0 = path[0]! + let step1 = path[1]! + + for (let idx = 2; idx < path.length; idx++) { + const step2 = path[idx]! + const prevDx = step1.x - step0.x + const prevDy = step1.y - step0.y + const dx = step2.x - step1.x + const dy = step2.y - step1.y + + // Same direction — the middle point is redundant + if (prevDx === dx && prevDy === dy) { + // In Go: indexToRemove = append(indexToRemove, idx+1) but idx is 0-based from path[2:] + // which corresponds to index idx in the full path. Go uses idx+1 because idx iterates + // from 0 in the [2:] slice, mapping to full-array index idx+1. + // Actually re-checking Go code: the loop is `for idx, step2 := range path[2:]` + // so idx=0 → path[2], and it removes idx+1 which is index 1 in the full array. + // Wait, that doesn't look right. Let me re-read: + // step0 = path[0], step1 = path[1] + // for idx, step2 := range path[2:] { ... indexToRemove = append(indexToRemove, idx+1) ... } + // When idx=0, step2=path[2], and it removes index 1 (step1 = path[1]) if directions match + // So it removes the middle point (step1) which is at index idx+1 in the original array + // when counting from the 2-ahead loop. Let me just track which middle indices to remove. + toRemove.add(idx - 1) // Remove the middle point (step1's position) + } + + step0 = step1 + step1 = step2 + } + + return path.filter((_, i) => !toRemove.has(i)) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts new file mode 100644 index 000000000..619678b4e --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/sequence.ts @@ -0,0 +1,460 @@ +// ============================================================================ +// ASCII renderer — sequence diagrams +// +// Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout. +// Each actor occupies a column with a vertical lifeline; messages are horizontal +// arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups. +// +// Layout is fundamentally different from flowcharts — no grid or A* pathfinding. +// Instead: actors → columns, messages → rows, all positioned linearly. +// ============================================================================ + +import { parseSequenceDiagram } from '../sequence/parser' +import type { SequenceDiagram, Block } from '../sequence/types' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas' +import { splitLines, maxLineWidth, lineCount } from './multiline-utils' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +/** Classify a box-drawing character as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +/** + * Render a Mermaid sequence diagram to ASCII/Unicode text. + * + * Pipeline: parse → layout (columns + rows) → draw onto canvas → string. + */ +export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseSequenceDiagram(lines) + + if (diagram.actors.length === 0) return '' + + const useAscii = config.useAscii + + // Box-drawing characters + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const TL = useAscii ? '+' : '┌' + const TR = useAscii ? '+' : '┐' + const BL = useAscii ? '+' : '└' + const BR = useAscii ? '+' : '┘' + const JT = useAscii ? '+' : '┬' // top junction on lifeline + const JB = useAscii ? '+' : '┴' // bottom junction on lifeline + const JL = useAscii ? '+' : '├' // left junction + const JR = useAscii ? '+' : '┤' // right junction + + // ---- LAYOUT: compute lifeline X positions ---- + + const actorIdx = new Map() + diagram.actors.forEach((a, i) => actorIdx.set(a.id, i)) + + const boxPad = 1 + // Use max line width for multi-line actor labels + const actorBoxWidths = diagram.actors.map(a => maxLineWidth(a.label) + 2 * boxPad + 2) + const halfBox = actorBoxWidths.map(w => Math.ceil(w / 2)) + // Calculate actor box heights based on number of lines in label + const actorBoxHeights = diagram.actors.map(a => lineCount(a.label) + 2) // lines + top/bottom border + const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning + + // Compute minimum gap between adjacent lifelines based on message labels. + // For messages spanning multiple actors, distribute the required width across gaps. + const adjMaxWidth: number[] = new Array(Math.max(diagram.actors.length - 1, 0)).fill(0) + + for (const msg of diagram.messages) { + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + if (fi === ti) continue // self-messages don't affect spacing + const lo = Math.min(fi, ti) + const hi = Math.max(fi, ti) + // Required gap per span = (max line width + arrow decorations) / number of gaps + const needed = maxLineWidth(msg.label) + 4 + const numGaps = hi - lo + const perGap = Math.ceil(needed / numGaps) + for (let g = lo; g < hi; g++) { + adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap) + } + } + + // Compute lifeline x-positions (greedy left-to-right) + const llX: number[] = [halfBox[0]!] + for (let i = 1; i < diagram.actors.length; i++) { + const gap = Math.max( + halfBox[i - 1]! + halfBox[i]! + 2, + adjMaxWidth[i - 1]! + 2, + 10, + ) + llX[i] = llX[i - 1]! + gap + } + + // ---- LAYOUT: compute vertical positions for messages ---- + + // For each message index, track the y where its arrow is drawn. + // Also track block start/end y positions and divider y positions. + const msgArrowY: number[] = [] + const msgLabelY: number[] = [] + const blockStartY = new Map() + const blockEndY = new Map() + const divYMap = new Map() // "blockIdx:divIdx" → y + const notePositions: Array<{ x: number; y: number; width: number; height: number; lines: string[] }> = [] + + let curY = actorBoxH // start right below header boxes + + for (let m = 0; m < diagram.messages.length; m++) { + // Block openings at this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.startIndex === m) { + curY += 2 // 1 blank + 1 header row + blockStartY.set(b, curY - 1) + } + } + + // Dividers at this message index + for (let b = 0; b < diagram.blocks.length; b++) { + for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) { + if (diagram.blocks[b]!.dividers[d]!.index === m) { + curY += 1 + divYMap.set(`${b}:${d}`, curY) + curY += 1 + } + } + } + + curY += 1 // blank row before message + + const msg = diagram.messages[m]! + const isSelf = msg.from === msg.to + + // Calculate height needed for multi-line message labels + const msgLineCount = lineCount(msg.label) + + if (isSelf) { + // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm + msgLabelY[m] = curY + 1 + msgArrowY[m] = curY + curY += 2 + msgLineCount // top-arm + label lines + bottom-arm + } else { + // Normal message: label row(s) then arrow row + msgLabelY[m] = curY + msgArrowY[m] = curY + msgLineCount // arrow goes after all label lines + curY += msgLineCount + 1 // label lines + arrow row + } + + // Notes after this message + for (let n = 0; n < diagram.notes.length; n++) { + if (diagram.notes[n]!.afterIndex === m) { + curY += 1 + const note = diagram.notes[n]! + const nLines = splitLines(note.text) + const nWidth = Math.max(...nLines.map(l => displayWidth(l))) + 4 + const nHeight = nLines.length + 2 + + // Determine x position based on note.position + const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0 + let nx: number + if (note.position === 'left') { + nx = llX[aIdx]! - nWidth - 1 + } else if (note.position === 'right') { + nx = llX[aIdx]! + 2 + } else { + // 'over' — center over actor(s) + if (note.actorIds.length >= 2) { + const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx + nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2) + } else { + nx = llX[aIdx]! - Math.floor(nWidth / 2) + } + } + nx = Math.max(0, nx) + + notePositions.push({ x: nx, y: curY, width: nWidth, height: nHeight, lines: nLines }) + curY += nHeight + } + } + + // Block closings after this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.endIndex === m) { + curY += 1 + blockEndY.set(b, curY) + curY += 1 + } + } + } + + curY += 1 // gap before footer + const footerY = curY + const totalH = footerY + actorBoxH + + // Total canvas width + const lastLL = llX[llX.length - 1] ?? 0 + const lastHalf = halfBox[halfBox.length - 1] ?? 0 + let totalW = lastLL + lastHalf + 2 + + // Ensure canvas is wide enough for self-message labels and notes + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + if (msg.from === msg.to) { + const fi = actorIdx.get(msg.from)! + const selfRight = llX[fi]! + 6 + 2 + displayWidth(msg.label) + totalW = Math.max(totalW, selfRight + 1) + } + } + for (const np of notePositions) { + totalW = Math.max(totalW, np.x + np.width + 1) + } + + const canvas = mkCanvas(totalW, totalH - 1) + const rc = mkRoleCanvas(totalW, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + /** + * Write label cells starting at x0, keeping wide-glyph pairs atomic: + * a glyph and its WIDE_PAD continuation land together or not at all, + * clamped to [minX, maxXExcl) and the canvas bounds. + */ + function setCells(x0: number, y: number, cells: string[], role: CharRole, minX = 0, maxXExcl = canvas.length): void { + const limit = Math.min(maxXExcl, canvas.length) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]! + if (cell === WIDE_PAD) continue // written atomically with its lead + const x = x0 + i + const wide = cells[i + 1] === WIDE_PAD + if (x < minX || x + (wide ? 1 : 0) >= limit) continue + setC(x, y, cell, role) + if (wide) setC(x + 1, y, WIDE_PAD, role) + } + } + + // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ---- + + function drawActorBox(cx: number, topY: number, label: string): void { + const lines = splitLines(label) + const maxW = maxLineWidth(label) + const w = maxW + 2 * boxPad + 2 + const h = lines.length + 2 // lines + top/bottom border + const left = cx - Math.floor(w / 2) + + // Top border + setC(left, topY, TL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border') + setC(left + w - 1, topY, TR, 'border') + + // Content lines (centered horizontally within the box) + for (let i = 0; i < lines.length; i++) { + const row = topY + 1 + i + setC(left, row, V, 'border') + setC(left + w - 1, row, V, 'border') + // Center this line within the box + const line = lines[i]! + const cells = toCells(line) + const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2) + setCells(ls, row, cells, 'text') + } + + // Bottom border + const bottomY = topY + h - 1 + setC(left, bottomY, BL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border') + setC(left + w - 1, bottomY, BR, 'border') + } + + // ---- DRAW: lifelines ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const x = llX[i]! + for (let y = actorBoxH; y <= footerY; y++) { + setC(x, y, V, 'line') + } + } + + // ---- DRAW: actor header + footer boxes (drawn over lifelines) ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const actor = diagram.actors[i]! + drawActorBox(llX[i]!, 0, actor.label) + drawActorBox(llX[i]!, footerY, actor.label) + + // Lifeline junctions on box borders (Unicode only) + if (!useAscii) { + setC(llX[i]!, actorBoxH - 1, JT, 'junction') + setC(llX[i]!, footerY, JB, 'junction') + } + } + + // ---- DRAW: messages ---- + + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + const fromX = llX[fi]! + const toX = llX[ti]! + const isSelf = fi === ti + const isDashed = msg.lineStyle === 'dashed' + const isFilled = msg.arrowHead === 'filled' + + // Arrow line character (solid vs dashed) + const lineChar = isDashed ? (useAscii ? '.' : '╌') : H + + if (isSelf) { + // Self-message: 3-row loop to the right of the lifeline + // ├──┐ (row 0 = msgArrowY) + // │ │ Label (row 1) + // │◄─┘ (row 2) + const y0 = msgArrowY[m]! + const loopW = Math.max(4, 4) + + // Row 0: start junction + horizontal + top-right corner + setC(fromX, y0, JL, 'junction') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0, lineChar, 'line') + setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner') + + // Row 1: vertical on right side + label + setC(fromX + loopW, y0 + 1, V, 'line') + const labelX = fromX + loopW + 2 + const selfCells = toCells(msg.label) + setCells(labelX, y0 + 1, selfCells, 'text', 0, totalW) + + // Row 2: arrow-back + horizontal + bottom-right corner + const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') + setC(fromX, y0 + 2, arrowChar, 'arrow') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, 'line') + setC(fromX + loopW, y0 + 2, useAscii ? '+' : '┘', 'corner') + } else { + // Normal message: label on row above, arrow on row below + const labelY = msgLabelY[m]! + const arrowY = msgArrowY[m]! + const leftToRight = fromX < toX + + // Draw label centered between the two lifelines (supports multi-line) + const midX = Math.floor((fromX + toX) / 2) + const msgLines = splitLines(msg.label) + + for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { + const cells = toCells(msgLines[lineIdx]!) + const labelStart = midX - Math.floor(cells.length / 2) + const y = labelY + lineIdx + setCells(labelStart, y, cells, 'text', 0, totalW) + } + + // Draw arrow line + if (leftToRight) { + for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, 'line') + // Arrowhead at destination + const ah = isFilled ? (useAscii ? '>' : '▶') : (useAscii ? '>' : '▷') + setC(toX, arrowY, ah, 'arrow') + } else { + for (let x = toX + 1; x < fromX; x++) setC(x, arrowY, lineChar, 'line') + const ah = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') + setC(toX, arrowY, ah, 'arrow') + } + } + } + + // ---- DRAW: blocks (loop, alt, opt, par, etc.) ---- + + for (let b = 0; b < diagram.blocks.length; b++) { + const block = diagram.blocks[b]! + const topY = blockStartY.get(b) + const botY = blockEndY.get(b) + if (topY === undefined || botY === undefined) continue + + // Find the leftmost/rightmost lifelines involved in this block's messages + let minLX = totalW + let maxLX = 0 + for (let m = block.startIndex; m <= block.endIndex; m++) { + if (m >= diagram.messages.length) break + const msg = diagram.messages[m]! + const f = actorIdx.get(msg.from) ?? 0 + const t = actorIdx.get(msg.to) ?? 0 + minLX = Math.min(minLX, llX[Math.min(f, t)]!) + maxLX = Math.max(maxLX, llX[Math.max(f, t)]!) + } + + const bLeft = Math.max(0, minLX - 4) + const bRight = Math.min(totalW - 1, maxLX + 4) + + // Top border with block type label + setC(bLeft, topY, TL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border') + setC(bRight, topY, TR, 'border') + // Write block header label over the top border (supports multi-line) + const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type + const hdrLines = splitLines(hdrLabel) + + for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { + const cells = toCells(hdrLines[lineIdx]!) + setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight) + } + + // Bottom border + setC(bLeft, botY, BL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border') + setC(bRight, botY, BR, 'border') + + // Side borders + for (let y = topY + 1; y < botY; y++) { + setC(bLeft, y, V, 'border') + setC(bRight, y, V, 'border') + } + + // Dividers + for (let d = 0; d < block.dividers.length; d++) { + const dY = divYMap.get(`${b}:${d}`) + if (dY === undefined) continue + const dashChar = isDashedH() + setC(bLeft, dY, JL, 'junction') + for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line') + setC(bRight, dY, JR, 'junction') + // Divider label + const dLabel = block.dividers[d]!.label + if (dLabel) { + const dCells = toCells(`[${dLabel}]`) + setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight) + } + } + } + + // ---- DRAW: notes ---- + + for (const np of notePositions) { + // Ensure canvas is big enough + increaseSize(canvas, np.x + np.width, np.y + np.height) + increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height) + // Top border + setC(np.x, np.y, TL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border') + setC(np.x + np.width - 1, np.y, TR, 'border') + // Content rows + for (let l = 0; l < np.lines.length; l++) { + const ly = np.y + 1 + l + setC(np.x, ly, V, 'border') + setC(np.x + np.width - 1, ly, V, 'border') + const cells = toCells(np.lines[l]!) + setCells(np.x + 2, ly, cells, 'text') + } + // Bottom border + const by = np.y + np.height - 1 + setC(np.x, by, BL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border') + setC(np.x + np.width - 1, by, BR, 'border') + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) + + // ---- Helper: dashed horizontal character ---- + function isDashedH(): string { + return useAscii ? '-' : '╌' + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts new file mode 100644 index 000000000..ecfe6ad96 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/circle.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Circle shape renderer — uses corner decorators instead of curves +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Circle shape renderer. + * Uses circle markers (◯) at corners to indicate circular shape semantics. + * + * Renders as: + * ◯─────────◯ + * │ Label │ + * ◯─────────◯ + */ +export const circleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('circle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts new file mode 100644 index 000000000..8bcd50f38 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/corners.ts @@ -0,0 +1,127 @@ +// ============================================================================ +// Corner character lookup table for shape rendering +// ============================================================================ +// +// All shapes are rendered as rectangles with distinctive corner characters +// to indicate shape type. This eliminates diagonal characters while keeping +// shapes visually distinguishable. + +import type { AsciiNodeShape } from '../types' + +/** + * Corner characters for a shape in both Unicode and ASCII modes. + */ +export interface CornerChars { + /** Top-left corner */ + tl: string + /** Top-right corner */ + tr: string + /** Bottom-left corner */ + bl: string + /** Bottom-right corner */ + br: string +} + +/** + * Shape corner configuration with both Unicode and ASCII variants. + */ +export interface ShapeCorners { + unicode: CornerChars + ascii: CornerChars +} + +/** + * Corner character lookup table for all shape types. + * + * Design principles: + * - All shapes use orthogonal box structure (no diagonals) + * - Corner characters indicate shape semantics + * - ASCII fallbacks use available punctuation + */ +export const SHAPE_CORNERS: Record = { + // Standard rectangular shapes + rectangle: { + unicode: { tl: '┌', tr: '┐', bl: '└', br: '┘' }, + ascii: { tl: '+', tr: '+', bl: '+', br: '+' }, + }, + rounded: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Circular shapes - use circle markers at corners + circle: { + unicode: { tl: '◯', tr: '◯', bl: '◯', br: '◯' }, + ascii: { tl: 'o', tr: 'o', bl: 'o', br: 'o' }, + }, + doublecircle: { + unicode: { tl: '◎', tr: '◎', bl: '◎', br: '◎' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, + + // Diamond - decision nodes + diamond: { + unicode: { tl: '◇', tr: '◇', bl: '◇', br: '◇' }, + ascii: { tl: '<', tr: '>', bl: '<', br: '>' }, + }, + + // Hexagon - process nodes (crop corners — monospace-safe, distinct from rectangle) + hexagon: { + unicode: { tl: '⌜', tr: '⌝', bl: '⌞', br: '⌟' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + + // Stadium/pill shape + stadium: { + unicode: { tl: '(', tr: ')', bl: '(', br: ')' }, + ascii: { tl: '(', tr: ')', bl: '(', br: ')' }, + }, + + // Subroutine - double vertical bars + subroutine: { + unicode: { tl: '╟', tr: '╢', bl: '╟', br: '╢' }, + ascii: { tl: '|', tr: '|', bl: '|', br: '|' }, + }, + + // Cylinder/database + cylinder: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Asymmetric/flag - pointer on left side + asymmetric: { + unicode: { tl: '▷', tr: '┐', bl: '▷', br: '┘' }, + ascii: { tl: '>', tr: '+', bl: '>', br: '+' }, + }, + + // Trapezoid - wider at bottom (top corners slope inward) + trapezoid: { + unicode: { tl: '/', tr: '\\', bl: '└', br: '┘' }, + ascii: { tl: '/', tr: '\\', bl: '+', br: '+' }, + }, + + // Trapezoid-alt - wider at top (bottom corners slope inward) + 'trapezoid-alt': { + unicode: { tl: '┌', tr: '┐', bl: '\\', br: '/' }, + ascii: { tl: '+', tr: '+', bl: '\\', br: '/' }, + }, + + // State diagram pseudostates (special handling, not corner-based) + 'state-start': { + unicode: { tl: '●', tr: '●', bl: '●', br: '●' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + 'state-end': { + unicode: { tl: '◉', tr: '◉', bl: '◉', br: '◉' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, +} + +/** + * Get corner characters for a shape type. + */ +export function getCorners(shape: AsciiNodeShape, useAscii: boolean): CornerChars { + const corners = SHAPE_CORNERS[shape] ?? SHAPE_CORNERS.rectangle + return useAscii ? corners.ascii : corners.unicode +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts new file mode 100644 index 000000000..7c54c7787 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Diamond shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Diamond shape renderer. + * Uses diamond markers (◇) at corners to indicate decision node semantics. + * + * Renders as: + * ◇─────────◇ + * │ Label │ + * ◇─────────◇ + */ +export const diamondRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('diamond', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts new file mode 100644 index 000000000..fe22b9bf5 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Hexagon shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Hexagon shape renderer. + * Uses hexagon markers (⬡) at corners to indicate process node semantics. + * + * Renders as: + * ⬡─────────⬡ + * │ Label │ + * ⬡─────────⬡ + */ +export const hexagonRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('hexagon', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts new file mode 100644 index 000000000..c505c0347 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/index.ts @@ -0,0 +1,101 @@ +// ============================================================================ +// Shape registry — pluggable ASCII shape renderers +// ============================================================================ + +import type { AsciiNodeShape, Canvas, DrawingCoord, Direction } from '../types' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } from './types' + +// Import all shape renderers +import { rectangleRenderer } from './rectangle' +import { diamondRenderer } from './diamond' +import { circleRenderer } from './circle' +import { stateStartRenderer, stateEndRenderer } from './state' +import { roundedRenderer } from './rounded' +import { stadiumRenderer } from './stadium' +import { hexagonRenderer } from './hexagon' +import { + subroutineRenderer, + doublecircleRenderer, + cylinderRenderer, + asymmetricRenderer, + trapezoidRenderer, + trapezoidAltRenderer, +} from './special' + +// Re-export types +export type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } + +/** + * Global shape registry — maps shape types to their renderers. + * Rectangle is the default fallback for unregistered shapes. + */ +export const shapeRegistry: ShapeRegistry = new Map([ + // Core shapes + ['rectangle', rectangleRenderer], + ['rounded', roundedRenderer], + ['diamond', diamondRenderer], + ['stadium', stadiumRenderer], + ['circle', circleRenderer], + + // Batch 1 additions + ['subroutine', subroutineRenderer], + ['doublecircle', doublecircleRenderer], + ['hexagon', hexagonRenderer], + + // Batch 2 additions + ['cylinder', cylinderRenderer], + ['asymmetric', asymmetricRenderer], + ['trapezoid', trapezoidRenderer], + ['trapezoid-alt', trapezoidAltRenderer], + + // State diagram pseudo-states + ['state-start', stateStartRenderer], + ['state-end', stateEndRenderer], +]) + +/** + * Get the renderer for a shape type, falling back to rectangle. + */ +export function getShapeRenderer(shape: AsciiNodeShape): ShapeRenderer { + return shapeRegistry.get(shape) ?? rectangleRenderer +} + +/** + * Render a node shape to a canvas. + * This is the main entry point for shape rendering. + */ +export function renderShape( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): Canvas { + const renderer = getShapeRenderer(shape) + const dimensions = renderer.getDimensions(label, options) + return renderer.render(label, dimensions, options) +} + +/** + * Get dimensions for a shape given a label. + * Used during layout to determine node size. + */ +export function getShapeDimensions( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): ShapeDimensions { + const renderer = getShapeRenderer(shape) + return renderer.getDimensions(label, options) +} + +/** + * Get edge attachment point for a shape. + */ +export function getShapeAttachmentPoint( + shape: AsciiNodeShape, + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const renderer = getShapeRenderer(shape) + return renderer.getAttachmentPoint(dir, dimensions, baseCoord) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts new file mode 100644 index 000000000..118607b54 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts @@ -0,0 +1,175 @@ +// ============================================================================ +// Rectangle shape renderer — standard box with corners +// ============================================================================ +// +// This module provides the base box rendering used by all rectangular shapes. +// The renderBox() function accepts custom corner characters, allowing different +// shapes to reuse the same rendering logic with different visual markers. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' +import { type CornerChars, getCorners } from './corners' +import { displayWidth, toCells } from '../../text-metrics' + +// ============================================================================ +// Shared dimension calculation +// ============================================================================ + +/** + * Calculate standard box dimensions for any rectangular shape. + * Used by rectangle, circle, diamond, hexagon, etc. + */ +export function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + // Width: 2*padding + maxLineWidth + 2 border chars + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + + // Height: lineCount + 2*padding + 2 border chars + // Ensure innerHeight is odd for symmetric vertical centering + const rawInnerHeight = lineCount + 2 * options.padding + const innerHeight = rawInnerHeight % 2 === 0 ? rawInnerHeight + 1 : rawInnerHeight + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + // Grid layout: [border=1, content, border=1] + gridColumns: [1, innerWidth, 1], + gridRows: [1, innerHeight, 1], + } +} + +// ============================================================================ +// Shared box rendering +// ============================================================================ + +/** + * Render a box with custom corner characters. + * This is the core rendering function used by all rectangular shapes. + * + * @param label - Text to display in the box + * @param dimensions - Pre-calculated dimensions + * @param corners - Corner characters (tl, tr, bl, br) + * @param useAscii - Whether to use ASCII or Unicode for lines + */ +export function renderBox( + label: string, + dimensions: ShapeDimensions, + corners: CornerChars, + useAscii: boolean +): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const from = { x: 0, y: 0 } + const to = { x: width - 1, y: height - 1 } + + // Line characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + + // Draw horizontal lines (top and bottom) + for (let x = from.x + 1; x < to.x; x++) { + canvas[x]![from.y] = hLine + canvas[x]![to.y] = hLine + } + + // Draw vertical lines (left and right) + for (let y = from.y + 1; y < to.y; y++) { + canvas[from.x]![y] = vLine + canvas[to.x]![y] = vLine + } + + // Draw corners + canvas[from.x]![from.y] = corners.tl + canvas[to.x]![from.y] = corners.tr + canvas[from.x]![to.y] = corners.bl + canvas[to.x]![to.y] = corners.br + + // Center the multi-line label + const lines = splitLines(label) + const w = width - 1 // Match original grid-based width calculation + const h = height - 1 + const centerY = Math.floor(h / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const cells = toCells(line) + const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 + for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0]!.length) { + canvas[x]![y] = cells[j]! + } + } + } + + return canvas +} + +// ============================================================================ +// Shared attachment point calculation +// ============================================================================ + +/** + * Calculate edge attachment point for rectangular shapes. + * All box-based shapes use the same attachment logic. + */ +export function getBoxAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + if (dirEquals(dir, UpperLeft)) return { x: baseCoord.x, y: baseCoord.y } + if (dirEquals(dir, UpperRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y } + if (dirEquals(dir, LowerLeft)) return { x: baseCoord.x, y: baseCoord.y + height - 1 } + if (dirEquals(dir, LowerRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y + height - 1 } + // Middle + return { x: centerX, y: centerY } +} + +// ============================================================================ +// Rectangle renderer +// ============================================================================ + +/** + * Rectangle shape renderer — the default box shape. + * Renders as: + * ┌─────────┐ + * │ Label │ + * └─────────┘ + */ +export const rectangleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const corners = getCorners('rectangle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts new file mode 100644 index 000000000..ab996ef84 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Rounded rectangle shape renderer — uses rounded corner decorators +// ============================================================================ + +import type { ShapeRenderer } from './types' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' + +/** + * Rounded rectangle shape renderer. + * Uses rounded corner markers (╭╮╰╯) to indicate soft edges. + * + * Renders as: + * ╭─────────╮ + * │ Label │ + * ╰─────────╯ + */ +export const roundedRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('rounded', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts new file mode 100644 index 000000000..af08c7795 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/special.ts @@ -0,0 +1,296 @@ +// ============================================================================ +// Special shape renderers — subroutine, doublecircle, cylinder, etc. +// ============================================================================ +// +// Some shapes have unique internal structure (subroutine, cylinder) and keep +// custom rendering. Others use the corner decorator pattern for simplicity. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle' +import { getCorners } from './corners' +import { displayWidth, toCells } from '../../text-metrics' + +// ============================================================================ +// Subroutine — keeps custom double-border rendering +// ============================================================================ + +/** + * Subroutine shape renderer — double-bordered rectangle. + * Renders as: + * ┌┬─────────┬┐ + * ││ Label ││ + * └┴─────────┴┘ + */ +export const subroutineRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Double borders on each side + const innerHeight = lineCount + 2 * options.padding + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top border + canvas[0]![0] = options.useAscii ? '+' : '┌' + canvas[1]![0] = options.useAscii ? '+' : '┬' + for (let x = 2; x < width - 2; x++) canvas[x]![0] = hChar + canvas[width - 2]![0] = options.useAscii ? '+' : '┬' + canvas[width - 1]![0] = options.useAscii ? '+' : '┐' + + // Sides with double border + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = vChar + canvas[1]![y] = vChar + canvas[width - 2]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Bottom border + canvas[0]![height - 1] = options.useAscii ? '+' : '└' + canvas[1]![height - 1] = options.useAscii ? '+' : '┴' + for (let x = 2; x < width - 2; x++) canvas[x]![height - 1] = hChar + canvas[width - 2]![height - 1] = options.useAscii ? '+' : '┴' + canvas[width - 1]![height - 1] = options.useAscii ? '+' : '┘' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const cells = toCells(line) + const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) + for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { + canvas[x]![y] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Double circle — uses corner decorators +// ============================================================================ + +/** + * Double circle shape renderer. + * Uses double circle markers (◎) at corners. + * + * Renders as: + * ◎─────────◎ + * │ Label │ + * ◎─────────◎ + */ +export const doublecircleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('doublecircle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Cylinder — keeps custom rendering for database appearance +// ============================================================================ + +/** + * Cylinder shape renderer — database symbol. + * Renders as: + * ╭─────╮ + * │─────│ + * │ DB │ + * │─────│ + * ╰─────╯ + */ +export const cylinderRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + const innerHeight = lineCount + 2 * options.padding + 2 // Extra for curved top/bottom + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 2 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [1, innerWidth, 1], + gridRows: [2, innerHeight - 2, 2], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top ellipse + canvas[0]![0] = options.useAscii ? '.' : '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = options.useAscii ? '.' : '╮' + + // Second row - bottom of top ellipse + canvas[0]![1] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![1] = hChar + canvas[width - 1]![1] = vChar + + // Middle section + for (let y = 2; y < height - 2; y++) { + canvas[0]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Second to last row - top of bottom ellipse + canvas[0]![height - 2] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![height - 2] = hChar + canvas[width - 1]![height - 2] = vChar + + // Bottom ellipse + canvas[0]![height - 1] = options.useAscii ? '\'' : '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = options.useAscii ? '\'' : '╯' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const cells = toCells(line) + const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) + for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { + canvas[x]![y] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Asymmetric (flag) — uses corner decorators +// ============================================================================ + +/** + * Asymmetric (flag/banner) shape renderer. + * Uses arrow markers (▷) on left corners. + * + * Renders as: + * ▷─────────┐ + * │ Label │ + * ▷─────────┘ + */ +export const asymmetricRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('asymmetric', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid shape renderer — wider at bottom. + * Uses slope markers (◸◹) on top corners. + * + * Renders as: + * ◸─────────◹ + * │ Label │ + * └─────────┘ + */ +export const trapezoidRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid-alt — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid-alt shape renderer — wider at top. + * Uses slope markers (◺◿) on bottom corners. + * + * Renders as: + * ┌─────────┐ + * │ Label │ + * ◺─────────◿ + */ +export const trapezoidAltRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid-alt', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts new file mode 100644 index 000000000..f7a1a09de --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts @@ -0,0 +1,114 @@ +// ============================================================================ +// Stadium (pill) shape renderer — special parentheses-based rendering +// ============================================================================ +// +// Stadium has unique rendering: single-line is inline `(Label)`, multi-line +// uses parentheses or rounded corners. This differs from other shapes that +// use corner decorators with box lines. + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { mkCanvas } from '../canvas' +import { splitLines } from '../multiline-utils' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { getBoxAttachmentPoint } from './rectangle' +import { displayWidth, toCells } from '../../text-metrics' + +/** + * Stadium (pill) shape renderer. + * + * Single-line: ( Label ) + * + * Multi-line unicode: + * ╭──────────╮ + * │ Label │ + * ╰──────────╯ + * + * Multi-line ASCII: + * (----------) + * ( Label ) + * (----------) + */ +export const stadiumRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Extra for rounded ends + const innerHeight = lineCount + 2 * options.padding + const height = Math.max(innerHeight + 2, 3) + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerY = Math.floor(height / 2) + const hChar = options.useAscii ? '-' : '─' + + if (height === 3) { + // Single row pill: ( Label ) + canvas[0]![centerY] = '(' + canvas[width - 1]![centerY] = ')' + } else if (!options.useAscii) { + // Multi-row stadium with rounded corners (unicode) + canvas[0]![0] = '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = '╮' + + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = '│' + canvas[width - 1]![y] = '│' + } + + canvas[0]![height - 1] = '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = '╯' + } else { + // Multi-row stadium ASCII — parentheses on all sides + for (let y = 0; y < height; y++) { + canvas[0]![y] = '(' + canvas[width - 1]![y] = ')' + } + for (let x = 1; x < width - 1; x++) { + canvas[x]![0] = hChar + canvas[x]![height - 1] = hChar + } + } + + // Center the label + const lines = splitLines(label) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const cells = toCells(line) + const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) + for (let j = 0; j < cells.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y >= 0 && y < height) { + canvas[x]![y] = cells[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts new file mode 100644 index 000000000..0e7cc7d59 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/state.ts @@ -0,0 +1,192 @@ +// ============================================================================ +// State pseudo-state renderers — UML start and end states +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction } from '../types' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight } from '../types' +import { mkCanvas } from '../canvas' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types' +import { dirEquals } from '../edge-routing' + +/** + * State start pseudo-state renderer — filled circle in rounded box. + * Renders as: + * ╭───╮ + * │ ● │ (Unicode) + * ╰───╯ + * + * .---. + * | * | (ASCII) + * '---' + * + * This represents the UML initial pseudo-state. + */ +export const stateStartRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // Start state is a 5x3 rounded box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode rounded box with filled circle: ╭───╮ │ ● │ ╰───╯ + canvas[0]![0] = '╭' + canvas[1]![0] = '─' + canvas[2]![0] = '─' + canvas[3]![0] = '─' + canvas[4]![0] = '╮' + + canvas[0]![1] = '│' + canvas[centerX]![1] = '●' + canvas[4]![1] = '│' + + canvas[0]![2] = '╰' + canvas[1]![2] = '─' + canvas[2]![2] = '─' + canvas[3]![2] = '─' + canvas[4]![2] = '╯' + } else { + // ASCII rounded box: .---. | * | '---' + canvas[0]![0] = '.' + canvas[1]![0] = '-' + canvas[2]![0] = '-' + canvas[3]![0] = '-' + canvas[4]![0] = '.' + + canvas[0]![1] = '|' + canvas[centerX]![1] = '*' + canvas[4]![1] = '|' + + canvas[0]![2] = '\'' + canvas[1]![2] = '-' + canvas[2]![2] = '-' + canvas[3]![2] = '-' + canvas[4]![2] = '\'' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} + +/** + * State end pseudo-state renderer — bullseye in double-bordered box. + * Renders as: + * ╔═══╗ + * ║ ◎ ║ (Unicode) + * ╚═══╝ + * + * #===# + * # * # (ASCII) + * #===# + * + * This represents the UML final state. The double border distinguishes it + * from the start state's single rounded border. + */ +export const stateEndRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // End state is a 5x3 double-bordered box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode double-bordered box with bullseye: ╔═══╗ ║ ◎ ║ ╚═══╝ + canvas[0]![0] = '╔' + canvas[1]![0] = '═' + canvas[2]![0] = '═' + canvas[3]![0] = '═' + canvas[4]![0] = '╗' + + canvas[0]![1] = '║' + canvas[centerX]![1] = '◎' + canvas[4]![1] = '║' + + canvas[0]![2] = '╚' + canvas[1]![2] = '═' + canvas[2]![2] = '═' + canvas[3]![2] = '═' + canvas[4]![2] = '╝' + } else { + // ASCII double-bordered box: #===# # * # #===# + canvas[0]![0] = '#' + canvas[1]![0] = '=' + canvas[2]![0] = '=' + canvas[3]![0] = '=' + canvas[4]![0] = '#' + + canvas[0]![1] = '#' + canvas[centerX]![1] = '*' + canvas[4]![1] = '#' + + canvas[0]![2] = '#' + canvas[1]![2] = '=' + canvas[2]![2] = '=' + canvas[3]![2] = '=' + canvas[4]![2] = '#' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts new file mode 100644 index 000000000..31fe93faa --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/shapes/types.ts @@ -0,0 +1,73 @@ +// ============================================================================ +// Shape renderer types — interface for pluggable ASCII shape renderers +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction, AsciiNodeShape } from '../types' + +/** + * Dimensions calculated for a shape, used by layout and rendering. + */ +export interface ShapeDimensions { + /** Total width in characters including borders */ + width: number + /** Total height in characters including borders */ + height: number + /** Label area bounds (where text can be placed) */ + labelArea: { + x: number + y: number + width: number + height: number + } + /** Grid column widths for the 3-column layout [left, center, right] */ + gridColumns: [number, number, number] + /** Grid row heights for the 3-row layout [top, middle, bottom] */ + gridRows: [number, number, number] +} + +/** + * Options passed to shape renderers. + */ +export interface ShapeRenderOptions { + /** Use ASCII chars (+,-,|) vs Unicode box-drawing (┌,─,│) */ + useAscii: boolean + /** Padding inside the shape */ + padding: number +} + +/** + * Interface for pluggable shape renderers. + * Each shape type implements this interface. + */ +export interface ShapeRenderer { + /** + * Calculate dimensions for this shape given a label. + * Used during layout to determine node size. + */ + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions + + /** + * Render the shape to a canvas. + * Returns a standalone canvas containing just the shape. + */ + render( + label: string, + dimensions: ShapeDimensions, + options: ShapeRenderOptions + ): Canvas + + /** + * Get the edge attachment point for a given direction. + * Used by edge routing to determine where edges connect. + */ + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord +} + +/** + * Registry of shape renderers keyed by shape type. + */ +export type ShapeRegistry = Map diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts new file mode 100644 index 000000000..9adfde734 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/types.ts @@ -0,0 +1,273 @@ +// ============================================================================ +// ASCII renderer — type definitions +// +// Ported from AlexanderGrooff/mermaid-ascii (Go). +// These types model the grid-based coordinate system, 2D text canvas, +// and graph structures used by the ASCII/Unicode renderer. +// ============================================================================ + +import type { NodeShape } from '../types' + +// Re-export NodeShape for convenience +export type { NodeShape } + +/** + * Shape type for ASCII rendering — maps parser shapes to ASCII renderers. + * Most shapes from the parser are supported, with fallback to 'rectangle'. + */ +export type AsciiNodeShape = NodeShape + +/** Logical grid coordinate — nodes occupy 3x3 blocks on this grid. */ +export interface GridCoord { + x: number + y: number +} + +/** Character-level coordinate on the 2D text canvas. */ +export interface DrawingCoord { + x: number + y: number +} + +/** + * Direction constants model positions on a node's 3x3 grid block. + * Each node occupies grid cells [x..x+2, y..y+2]. + * Directions are offsets into that block, used for edge attachment points. + * + * (0,0) UL (1,0) Up (2,0) UR + * (0,1) Left (1,1) Mid (2,1) Right + * (0,2) LL (1,2) Down (2,2) LR + */ +export interface Direction { + readonly x: number + readonly y: number +} + +export const Up: Direction = { x: 1, y: 0 } +export const Down: Direction = { x: 1, y: 2 } +export const Left: Direction = { x: 0, y: 1 } +export const Right: Direction = { x: 2, y: 1 } +export const UpperRight: Direction = { x: 2, y: 0 } +export const UpperLeft: Direction = { x: 0, y: 0 } +export const LowerRight: Direction = { x: 2, y: 2 } +export const LowerLeft: Direction = { x: 0, y: 2 } +export const Middle: Direction = { x: 1, y: 1 } + +/** All named directions for iteration. */ +export const ALL_DIRECTIONS: readonly Direction[] = [ + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, +] + +/** + * 2D text canvas — column-major (canvas[x][y]). + * Each cell holds a single character (or space). + */ +export type Canvas = string[][] + +/** A node in the ASCII graph, positioned on the grid. */ +export interface AsciiNode { + /** Unique identity key — the original node ID from the parser (e.g. "A", "B"). */ + name: string + /** Human-readable label for rendering inside the box (e.g. "Web Server"). */ + displayLabel: string + /** Node shape from the parser (e.g. "rectangle", "diamond", "circle"). */ + shape: AsciiNodeShape + index: number + gridCoord: GridCoord | null + drawingCoord: DrawingCoord | null + drawing: Canvas | null + drawn: boolean + styleClassName: string + styleClass: AsciiStyleClass +} + +/** Style class for colored node text (ported from Go's classDef). */ +export interface AsciiStyleClass { + name: string + styles: Record +} + +/** Edge line style for ASCII rendering. */ +export type AsciiEdgeStyle = 'solid' | 'dotted' | 'thick' + +/** An edge in the ASCII graph, with a routed path. */ +export interface AsciiEdge { + from: AsciiNode + to: AsciiNode + text: string + path: GridCoord[] + labelLine: GridCoord[] + startDir: Direction + endDir: Direction + /** Line style: solid (default), dotted (-.->) or thick (==>) */ + style: AsciiEdgeStyle + /** Whether to render an arrowhead at the start (source end) of the edge */ + hasArrowStart: boolean + /** Whether to render an arrowhead at the end (target end) of the edge */ + hasArrowEnd: boolean + /** Bundle this edge belongs to (if any). Set during bundling analysis. */ + bundle?: EdgeBundle + /** + * For bundled edges: path from source/target to the junction point. + * The full visual path is: pathToJunction + bundle.sharedPath (for fan-in) + * or bundle.sharedPath + pathToJunction (for fan-out). + */ + pathToJunction?: GridCoord[] +} + +/** A subgraph container with bounding box for rendering. */ +export interface AsciiSubgraph { + name: string + nodes: AsciiNode[] + parent: AsciiSubgraph | null + children: AsciiSubgraph[] + minX: number + minY: number + maxX: number + maxY: number + /** Optional direction override for layout within this subgraph (LR or TD). */ + direction?: 'LR' | 'TD' +} + +/** Configuration for ASCII rendering. */ +export interface AsciiConfig { + /** true = ASCII chars (+,-,|), false = Unicode box-drawing (┌,─,│). Default: false */ + useAscii: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX: number + /** Vertical spacing between nodes. Default: 5 */ + paddingY: number + /** Padding inside node boxes. Default: 1 */ + boxBorderPadding: number + /** Graph direction: "LR" or "TD". */ + graphDirection: 'LR' | 'TD' +} + +/** Full ASCII graph state used during layout and rendering. */ +export interface AsciiGraph { + nodes: AsciiNode[] + edges: AsciiEdge[] + canvas: Canvas + /** Role canvas — tracks the role of each character for colored output. */ + roleCanvas: RoleCanvas + /** Grid occupancy map — maps "x,y" keys to node references. */ + grid: Map + columnWidth: Map + rowHeight: Map + subgraphs: AsciiSubgraph[] + config: AsciiConfig + /** Offset applied to all drawing coords to accommodate subgraph borders. */ + offsetX: number + offsetY: number + /** Edge bundles for parallel link visualization. Set during bundling analysis. */ + bundles: EdgeBundle[] +} + +// ============================================================================ +// Coordinate helpers +// ============================================================================ + +export function gridCoordEquals(a: GridCoord, b: GridCoord): boolean { + return a.x === b.x && a.y === b.y +} + +export function drawingCoordEquals(a: DrawingCoord, b: DrawingCoord): boolean { + return a.x === b.x && a.y === b.y +} + +/** Apply a direction offset to a grid coordinate (move into the 3x3 block). */ +export function gridCoordDirection(c: GridCoord, dir: Direction): GridCoord { + return { x: c.x + dir.x, y: c.y + dir.y } +} + +/** Key for storing GridCoord in a Map. */ +export function gridKey(c: GridCoord): string { + return `${c.x},${c.y}` +} + +/** Default empty style class. */ +export const EMPTY_STYLE: AsciiStyleClass = { name: '', styles: {} } + +// ============================================================================ +// Character role types for colored output +// ============================================================================ + +/** + * Role of a character in the ASCII diagram, used for theming. + * Each role maps to a different color when colors are enabled. + */ +export type CharRole = + | 'text' // Node labels, edge labels + | 'border' // Node box borders, subgraph borders + | 'line' // Edge lines (paths between nodes) + | 'arrow' // Arrowheads (▲▼◄► or ^v<>) + | 'corner' // Corner characters at path bends + | 'junction' // Junction characters (┬┴├┤ where edges meet boxes) + +/** + * Role canvas — parallel to Canvas, tracks the role of each character. + * Same column-major structure: roleCanvas[x][y] gives the role at (x, y). + * null means the character has no role (whitespace). + */ +export type RoleCanvas = (CharRole | null)[][] + +/** + * Theme colors for ASCII output — hex color strings. + * Derived from the SVG theme system for visual consistency. + */ +export interface AsciiTheme { + /** Text color (node labels, edge labels) */ + fg: string + /** Box border color (node borders, subgraph borders) */ + border: string + /** Edge line color (paths between nodes) */ + line: string + /** Arrowhead color (▲▼◄► or ^v<>) */ + arrow: string + /** Theme accent color (optional, used by xycharts for series 0) */ + accent?: string + /** Background color (optional, used by xycharts for dark-mode-aware shading) */ + bg?: string + /** Corner character color (optional, defaults to line) */ + corner?: string + /** Junction character color (optional, defaults to border) */ + junction?: string +} + +/** Color mode for output. */ +export type ColorMode = + | 'none' // No colors (plain text) + | 'ansi16' // 16-color ANSI (basic terminals) + | 'ansi256' // 256-color ANSI (xterm) + | 'truecolor' // 24-bit RGB (modern terminals) + | 'html' // HTML tags with inline color styles (browsers) + +// ============================================================================ +// Edge bundling types +// ============================================================================ + +/** + * Edge bundle — groups edges that share a common source or target. + * Used to visually merge parallel links before they reach the shared node. + * + * For fan-in (A & B --> C): multiple sources converge to one target. + * For fan-out (A --> B & C): one source diverges to multiple targets. + */ +export interface EdgeBundle { + /** Bundle type: fan-in = many→one, fan-out = one→many */ + type: 'fan-in' | 'fan-out' + /** Edges in this bundle */ + edges: AsciiEdge[] + /** The common node (target for fan-in, source for fan-out) */ + sharedNode: AsciiNode + /** The non-shared nodes (sources for fan-in, targets for fan-out) */ + otherNodes: AsciiNode[] + /** Junction point where edges merge/split — set during routing */ + junctionPoint: GridCoord | null + /** Path from junction to shared node (drawn once for all edges) */ + sharedPath: GridCoord[] + /** Direction when entering/exiting the junction */ + junctionDir: Direction + /** Direction when entering/exiting the shared node */ + sharedNodeDir: Direction +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts new file mode 100644 index 000000000..dcbb25867 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/validate.ts @@ -0,0 +1,120 @@ +/** + * ASCII Rendering Validation Utilities + * + * Provides validation functions for ASCII diagram output, + * including diagonal line detection to ensure orthogonal-only routing. + */ + +/** + * Characters that represent diagonal lines in ASCII and Unicode modes. + * These should never appear in properly rendered diagrams. + */ +export const DIAGONAL_CHARS = { + ascii: ['/', '\\'], + unicode: ['\u2571', '\u2572'], // ╱ ╲ + all: ['/', '\\', '\u2571', '\u2572'], +} as const + +/** + * Position of a diagonal character in ASCII output. + */ +export interface DiagonalPosition { + line: number + col: number + char: string +} + +/** + * Check if ASCII output contains any diagonal line characters. + * Returns true if diagonals are found (which is an error condition). + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns true if diagonal characters are present, false otherwise + */ +export function hasDiagonalLines(asciiOutput: string): boolean { + return DIAGONAL_CHARS.all.some((char) => asciiOutput.includes(char)) +} + +/** + * Find all diagonal line character positions in ASCII output. + * Useful for debugging when diagonals are detected. + * + * Skips diagonal characters that appear inside node labels (between box borders). + * This prevents false positives from labels like "feature/auth" or "release/1.0". + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns Array of positions where diagonal characters were found + */ +export function findDiagonalLines(asciiOutput: string): DiagonalPosition[] { + const positions: DiagonalPosition[] = [] + const lines = asciiOutput.split('\n') + + // Box-drawing characters that indicate node boundaries + const boxBorders = new Set(['│', '┤', '├', '║', '┃', '|']) + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]! + + // Find all box border positions in this line + const borderPositions: number[] = [] + for (let col = 0; col < line.length; col++) { + if (boxBorders.has(line[col]!)) { + borderPositions.push(col) + } + } + + for (let col = 0; col < line.length; col++) { + const char = line[col]! + if (DIAGONAL_CHARS.all.includes(char as '/' | '\\' | '╱' | '╲')) { + // Check if this position is inside a node (between two box borders) + // Find the nearest borders before and after this position + let insideNode = false + for (let i = 0; i < borderPositions.length - 1; i++) { + const leftBorder = borderPositions[i]! + const rightBorder = borderPositions[i + 1]! + if (col > leftBorder && col < rightBorder) { + // This diagonal char is between two borders - likely inside a node label + insideNode = true + break + } + } + + if (!insideNode) { + positions.push({ + line: lineNum + 1, // 1-indexed for human readability + col: col + 1, + char, + }) + } + } + } + } + + return positions +} + +/** + * Assert that ASCII output contains no diagonal lines. + * Throws an error with detailed position information if diagonals are found. + * + * @param asciiOutput - The rendered ASCII diagram string + * @param context - Optional context string for error message (e.g., diagram name) + * @throws Error if diagonal characters are present + */ +export function assertNoDiagonals(asciiOutput: string, context?: string): void { + if (!hasDiagonalLines(asciiOutput)) { + return + } + + const positions = findDiagonalLines(asciiOutput) + const contextStr = context ? ` in "${context}"` : '' + const positionStr = positions + .map((p) => ` Line ${p.line}, Col ${p.col}: '${p.char}'`) + .join('\n') + + throw new Error( + `Diagonal lines detected${contextStr}. ` + + `Edges must use orthogonal Manhattan routing (90° bends only).\n` + + `Found ${positions.length} diagonal character(s):\n${positionStr}` + ) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts new file mode 100644 index 000000000..08088d4f4 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/xychart.ts @@ -0,0 +1,875 @@ +// ============================================================================ +// ASCII renderer — XY Chart +// +// Renders xychart-beta diagrams to ASCII/Unicode text art. +// Uses the parsed XYChart type directly (not PositionedXYChart) since +// pixel coordinates don't map to character grids. +// +// Bar charts: █ (Unicode) or # (ASCII) block characters. +// Line charts: continuous staircase routing with rounded corners (╭╮╰╯│─). +// +// Multi-series support: each series gets a distinct color from a palette. +// ============================================================================ + +import { parseXYChart } from '../xychart/parser' +import type { XYChart } from '../xychart/types' +import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types' +import { colorizeText } from './ansi' +import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' + +// ============================================================================ +// Constants +// ============================================================================ + +const PLOT_WIDTH = 60 +const PLOT_HEIGHT = 20 + +// Unicode box-drawing characters +const UNI = { + hLine: '─', + vLine: '│', + origin: '┼', + yTick: '┤', + xTick: '┬', + bar: '█', + grid: '·', + cornerTL: '╭', // top-left: down+right + cornerTR: '╮', // top-right: down+left + cornerBL: '╰', // bottom-left: up+right + cornerBR: '╯', // bottom-right: up+left +} as const + +// ASCII fallback characters +const ASC = { + hLine: '-', + vLine: '|', + origin: '+', + yTick: '+', + xTick: '+', + bar: '#', + grid: '.', + cornerTL: '+', + cornerTR: '+', + cornerBL: '+', + cornerBR: '+', +} as const + +// ============================================================================ +// Multi-series color support +// ============================================================================ + +/** Per-cell hex color override canvas. Parallel to RoleCanvas. */ +type HexCanvas = (string | null)[][] + +/** Generate an array of hex colors, one per series. */ +function getSeriesColors(total: number, theme: AsciiTheme): string[] { + const accent = theme.accent ?? CHART_ACCENT_FALLBACK + if (total <= 1) return [accent] + return Array.from({ length: total }, (_, i) => getSeriesColor(i, accent, theme.bg)) +} + +/** Map a CharRole to its hex color from the theme (for canvasToString fallback). */ +function roleToHex(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +export function renderXYChartAscii( + text: string, + config: AsciiConfig, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const chart = parseXYChart(lines) + const ch = config.useAscii ? ASC : UNI + + if (chart.horizontal) { + return renderHorizontal(chart, ch, colorMode, theme) + } + return renderVertical(chart, ch, colorMode, theme) +} + +// ============================================================================ +// Vertical chart layout + rendering +// ============================================================================ + +function renderVertical( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + const yRange = chart.yAxis.range! + const yTicks = niceTickValues(yRange.min, yRange.max) + const yLabels = yTicks.map(v => formatTickValue(v)) + const yGutter = Math.max(...yLabels.map(l => displayWidth(l))) + 1 + + const plotW = Math.max(PLOT_WIDTH, dataCount * 6) + const plotH = PLOT_HEIGHT + const bandW = Math.floor(plotW / dataCount) + const catLabels = getCategoryLabels(chart, dataCount) + + // Canvas dimensions + const hasTitle = !!chart.title + const hasXTitle = !!chart.xAxis.title + const hasLegend = chart.series.length > 1 + const titleRow = hasTitle ? 0 : -1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = yGutter + 1 // +1 for axis character + const totalW = plotLeft + bandW * dataCount + 2 + const xAxisRow = plotTop + plotH + const xLabelRow = xAxisRow + 1 + const xTitleRow = hasXTitle ? xLabelRow + 1 : -1 + const totalH = xLabelRow + 1 + (hasXTitle ? 1 : 0) + (hasLegend && !hasTitle ? 0 : 0) + + // Create canvas + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Scales + const valueToRow = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return Math.round(t * (plotH - 1)) + } + const bandCenter = (i: number): number => plotLeft + Math.floor(bandW * (i + 0.5)) + + // 1. Title + if (hasTitle && titleRow >= 0) { + writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // 2. Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // 3. Y-axis line + ticks + labels + for (let row = 0; row < plotH; row++) { + const displayRow = plotTop + (plotH - 1 - row) + set(canvas, roles, displayRow, plotLeft - 1, ch.vLine, 'border') + } + // Origin + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + const label = formatTickValue(tick) + // Tick mark on axis + set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border') + // Label + const labelStart = yGutter - displayWidth(label) + writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text') + } + + // 4. X-axis line + ticks + labels + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (let i = 0; i < dataCount; i++) { + const cx = bandCenter(i) + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + // Label below + const label = catLabels[i]! + const labelStart = cx - Math.floor(displayWidth(label) / 2) + writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text') + } + + // 5. X-axis title + if (hasXTitle && xTitleRow >= 0) { + const title = chart.xAxis.title! + writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') + } + + // 6. Grid lines (subtle horizontal dots at y-tick positions) + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + if (get(canvas, displayRow, c) === ' ') { + set(canvas, roles, displayRow, c, ch.grid, 'line') + } + } + } + + // 7. Bars — track global series index for per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const usable = Math.max(1, bandW - 2) + const singleBarW = Math.max(1, Math.min(Math.floor(usable / barCount), 8)) + const groupW = singleBarW * barCount + (barCount - 1) + const baseRow = valueToRow(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const cx = bandCenter(i) + const groupLeft = cx - Math.floor(groupW / 2) + const bx = groupLeft + bIdx * (singleBarW + 1) + const valRow = valueToRow(entry.data[i]!) + const fromRow = Math.min(baseRow, valRow) + const toRow = Math.max(baseRow, valRow) + + for (let row = fromRow; row <= toRow; row++) { + const displayRow = plotTop + (plotH - 1 - row) + for (let c = bx; c < bx + singleBarW; c++) { + set(canvas, roles, displayRow, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // 8. Lines (staircase routing with rounded corners) + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawStaircaseLine(canvas, roles, entry.data, bandCenter, valueToRow, plotTop, plotH, plotLeft, bandW * dataCount, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Horizontal chart layout + rendering +// ============================================================================ + +function renderHorizontal( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + const yRange = chart.yAxis.range! + const valueTicks = niceTickValues(yRange.min, yRange.max) + const catLabels = getCategoryLabels(chart, dataCount) + const catGutter = Math.max(...catLabels.map(l => displayWidth(l))) + 1 + + const plotW = Math.max(PLOT_WIDTH, 40) + const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)) + const plotH = bandH * dataCount + + const hasTitle = !!chart.title + const hasYTitle = !!chart.yAxis.title + const hasLegend = chart.series.length > 1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = catGutter + 1 + const totalW = plotLeft + plotW + 2 + const totalH = plotTop + plotH + 2 + (hasYTitle ? 1 : 0) + const xAxisRow = plotTop + plotH + + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Value scale (horizontal) + const valueToCol = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return plotLeft + Math.round(t * (plotW - 1)) + } + const bandMid = (i: number): number => plotTop + Math.floor(bandH * (i + 0.5)) + + // Title + if (hasTitle) { + writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') + } + + // Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // Y-axis (category axis on left) + for (let r = plotTop; r < plotTop + plotH; r++) { + set(canvas, roles, r, plotLeft - 1, ch.vLine, 'border') + } + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (let i = 0; i < dataCount; i++) { + const my = bandMid(i) + const label = catLabels[i]! + const labelStart = catGutter - displayWidth(label) + writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text') + } + + // X-axis (value axis on bottom) + for (let c = plotLeft; c < plotLeft + plotW; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (const tick of valueTicks) { + const cx = valueToCol(tick) + if (cx < plotLeft || cx >= plotLeft + plotW) continue + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + const label = formatTickValue(tick) + writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, 'text') + } + + // Y-axis title + if (hasYTitle) { + const title = chart.yAxis.title! + writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') + } + + // Grid lines (vertical at value tick positions) + for (const tick of valueTicks) { + const cx = valueToCol(tick) + if (cx < plotLeft || cx >= plotLeft + plotW) continue + for (let r = plotTop; r < plotTop + plotH; r++) { + if (get(canvas, r, cx) === ' ') { + set(canvas, roles, r, cx, ch.grid, 'line') + } + } + } + + // Bars (horizontal) — with per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const singleBarH = 1 + const groupH = singleBarH * barCount + (barCount - 1) + const baseCol = valueToCol(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const my = bandMid(i) + const groupTop = my - Math.floor(groupH / 2) + const by = groupTop + bIdx * (singleBarH + 1) + const valCol = valueToCol(entry.data[i]!) + const fromCol = Math.min(baseCol, valCol) + const toCol = Math.max(baseCol, valCol) + + for (let r = by; r < by + singleBarH; r++) { + for (let c = fromCol; c <= toCol; c++) { + set(canvas, roles, r, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // Lines (horizontal staircase: value on x, category on y) — with per-series colors + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawHorizontalStaircaseLine(canvas, roles, entry.data, bandMid, valueToCol, plotTop, plotH, plotLeft, plotW, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Staircase line drawing — vertical charts +// +// Connects data points with flat segments (─) at each value's row, +// vertical segments (│) between rows, and rounded corners (╭╮╰╯) +// at transitions. The vertical step happens at the midpoint column +// between adjacent data points. +// ============================================================================ + +function drawStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandCenter: (i: number) => number, + valueToRow: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotTotalW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + col: bandCenter(i), + row: valueToRow(v), + })) + + // Helper to draw on the canvas (row 0 = bottom, displayed inverted) + const drawAt = (col: number, row: number, char: string) => { + const displayRow = plotTop + (plotH - 1 - row) + if (displayRow >= 0 && col >= plotLeft && col < plotLeft + plotTotalW) { + set(canvas, roles, displayRow, col, char, 'arrow', hexCanvas, hexColor) + } + } + + // Single point: just draw a flat segment + if (points.length === 1) { + drawAt(points[0]!.col, points[0]!.row, ch.hLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.row === p2.row) { + // Flat: draw ─ across + for (let c = p1.col; c <= p2.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + continue + } + + const midCol = Math.round((p1.col + p2.col) / 2) + const goingUp = p2.row > p1.row + + // 1. Flat at p1's row from p1.col to midCol-1 + for (let c = p1.col; c < midCol; c++) { + drawAt(c, p1.row, ch.hLine) + } + + // 2. Corner at (midCol, p1.row) + // goingUp: ─ from LEFT, │ going UP → LEFT+TOP = ╯ (cornerBR) + // goingDown: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + if (goingUp) { + drawAt(midCol, p1.row, ch.cornerBR) // ╯ + } else { + drawAt(midCol, p1.row, ch.cornerTR) // ╮ + } + + // 3. Vertical from p1.row to p2.row (exclusive of endpoints) + const minRow = Math.min(p1.row, p2.row) + const maxRow = Math.max(p1.row, p2.row) + for (let row = minRow + 1; row < maxRow; row++) { + drawAt(midCol, row, ch.vLine) + } + + // 4. Corner at (midCol, p2.row) + // goingUp: │ from BOTTOM, ─ going RIGHT → BOT+RIGHT = ╭ (cornerTL) + // goingDown: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + if (goingUp) { + drawAt(midCol, p2.row, ch.cornerTL) // ╭ + } else { + drawAt(midCol, p2.row, ch.cornerBL) // ╰ + } + + // 5. Flat at p2's row from midCol+1 to p2.col + for (let c = midCol + 1; c <= p2.col; c++) { + drawAt(c, p2.row, ch.hLine) + } + + // Leading flat for first segment (before p1.col) + if (i === 0) { + const leadStart = Math.max(plotLeft, p1.col - Math.floor((p2.col - p1.col) / 4)) + for (let c = leadStart; c < p1.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + } + + // Trailing flat for last segment (after p2.col) + if (i === points.length - 2) { + const trailEnd = Math.min(plotLeft + plotTotalW - 1, p2.col + Math.floor((p2.col - p1.col) / 4)) + for (let c = p2.col + 1; c <= trailEnd; c++) { + drawAt(c, p2.row, ch.hLine) + } + } + } +} + +// ============================================================================ +// Staircase line drawing — horizontal charts +// +// Same staircase approach but with axes swapped: +// data values map to columns (horizontal position) and categories map to +// rows (vertical position). Flat segments are vertical (│), transitions +// are horizontal (─), with the same rounded corners. +// ============================================================================ + +function drawHorizontalStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandMid: (i: number) => number, + valueToCol: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + row: bandMid(i), + col: valueToCol(v), + })) + + const drawAt = (row: number, col: number, char: string) => { + if (row >= plotTop && row < plotTop + plotH && col >= plotLeft && col < plotLeft + plotW) { + set(canvas, roles, row, col, char, 'arrow', hexCanvas, hexColor) + } + } + + if (points.length === 1) { + drawAt(points[0]!.row, points[0]!.col, ch.vLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.col === p2.col) { + // Same value: draw │ down + for (let r = p1.row; r <= p2.row; r++) { + drawAt(r, p1.col, ch.vLine) + } + continue + } + + const midRow = Math.round((p1.row + p2.row) / 2) + const goingRight = p2.col > p1.col + + // 1. Vertical at p1's col from p1.row to midRow-1 + for (let r = p1.row; r < midRow; r++) { + drawAt(r, p1.col, ch.vLine) + } + + // 2. Corner at (midRow, p1.col) + // goingRight: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + // goingLeft: │ from TOP, ─ going LEFT → TOP+LEFT = ╯ (cornerBR) + if (goingRight) { + drawAt(midRow, p1.col, ch.cornerBL) // ╰ + } else { + drawAt(midRow, p1.col, ch.cornerBR) // ╯ + } + + // 3. Horizontal from p1.col to p2.col (exclusive) + const minCol = Math.min(p1.col, p2.col) + const maxCol = Math.max(p1.col, p2.col) + for (let c = minCol + 1; c < maxCol; c++) { + drawAt(midRow, c, ch.hLine) + } + + // 4. Corner at (midRow, p2.col) + // goingRight: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + // goingLeft: ─ from RIGHT, │ going DOWN → RIGHT+BOT = ╭ (cornerTL) + if (goingRight) { + drawAt(midRow, p2.col, ch.cornerTR) // ╮ + } else { + drawAt(midRow, p2.col, ch.cornerTL) // ╭ + } + + // 5. Vertical at p2's col from midRow+1 to p2.row + for (let r = midRow + 1; r <= p2.row; r++) { + drawAt(r, p2.col, ch.vLine) + } + } +} + +// ============================================================================ +// Legend — shows series symbols with per-series colors +// ============================================================================ + +function drawLegend( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + chart: XYChart, + row: number, + totalW: number, + ch: typeof UNI | typeof ASC, + seriesColors: string[], +): void { + // Build legend items with global series indices + type LegendItem = { symbol: string; label: string; globalIdx: number } + const items: LegendItem[] = [] + let barIdx = 0, lineIdx = 0 + for (let si = 0; si < chart.series.length; si++) { + const s = chart.series[si]! + if (s.type === 'bar') { + items.push({ symbol: ch.bar, label: `Bar ${barIdx + 1}`, globalIdx: si }) + barIdx++ + } else { + items.push({ symbol: ch.hLine, label: `Line ${lineIdx + 1}`, globalIdx: si }) + lineIdx++ + } + } + + // Calculate total legend width: "symbol space label symbol space label ..." + let totalLen = 0 + for (let i = 0; i < items.length; i++) { + if (i > 0) totalLen += 2 // gap between items + totalLen += 1 + 1 + displayWidth(items[i]!.label) // symbol + space + label + } + + const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)) + let col = startCol + + for (let i = 0; i < items.length; i++) { + if (i > 0) col += 2 // gap + const item = items[i]! + // Symbol with series-specific color + set(canvas, roles, row, col, item.symbol, 'arrow', hexCanvas, seriesColors[item.globalIdx]) + col += 1 + // Space (already ' ' from canvas init) + col += 1 + // Label text + writeText(canvas, roles, row, col, item.label, 'text') + col += displayWidth(item.label) + } +} + +// ============================================================================ +// Canvas utilities +// ============================================================================ + +function createCanvas(width: number, height: number): Canvas { + return Array.from({ length: width }, () => Array.from({ length: height }, () => ' ')) +} + +function createRoleCanvas(width: number, height: number): RoleCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function createHexCanvas(width: number, height: number): HexCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function set( + canvas: Canvas, roles: RoleCanvas, row: number, col: number, + char: string, role: CharRole, + hexCanvas?: HexCanvas, hex?: string | null, +): void { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + canvas[col]![row] = char + roles[col]![row] = role + if (hexCanvas && hex) hexCanvas[col]![row] = hex + } +} + +function get(canvas: Canvas, row: number, col: number): string { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + return canvas[col]![row]! + } + return ' ' +} + +function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void { + const cells = toCells(text) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]! + if (cell === WIDE_PAD) continue // written atomically with its lead + const col = startCol + i + const wide = cells[i + 1] === WIDE_PAD + // Keep wide-glyph pairs atomic at canvas bounds + if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue + set(canvas, roles, row, col, cell, role) + if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role) + } +} + +// ============================================================================ +// Canvas → string (with per-cell hex color support) +// ============================================================================ + +function canvasToString( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + if (canvas.length === 0) return '' + const height = canvas[0]!.length + const width = canvas.length + const lines: string[] = [] + + for (let row = 0; row < height; row++) { + const chars: string[] = [] + const rowRoles: (CharRole | null)[] = [] + const rowHex: (string | null)[] = [] + for (let col = 0; col < width; col++) { + const c = canvas[col]![row]! + // Skip wide-glyph continuation cells: the glyph itself spans 2 columns + if (c === WIDE_PAD) continue + chars.push(c) + rowRoles.push(roles[col]![row]!) + rowHex.push(hexCanvas[col]![row]!) + } + // Trim trailing spaces + let end = chars.length - 1 + while (end >= 0 && chars[end] === ' ') end-- + if (end < 0) { + lines.push('') + } else { + lines.push(colorizeRow( + chars.slice(0, end + 1), + rowRoles.slice(0, end + 1), + rowHex.slice(0, end + 1), + theme, + colorMode, + )) + } + } + + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop() + } + + return lines.join('\n') +} + +/** + * Colorize a row of characters, using hex color overrides where available + * and falling back to role-based theme colors otherwise. + * Groups consecutive same-color characters for efficient escape sequences. + */ +function colorizeRow( + chars: string[], + roles: (CharRole | null)[], + hexOverrides: (string | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') return chars.join('') + + let result = '' + let currentColor: string | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + + if (char === ' ') { + // Flush buffer before whitespace + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + buffer = '' + currentColor = null + } + result += ' ' + continue + } + + // Effective color: hex override > role-based > null + const hexOvr = hexOverrides[i] ?? null + const roleVal = roles[i] ?? null + const color = hexOvr ?? (roleVal ? roleToHex(roleVal, theme) : null) + + if (color === currentColor) { + buffer += char + } else { + // Flush previous group + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + buffer = char + currentColor = color + } + } + + // Flush remaining + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + + return result +} + +// ============================================================================ +// Helpers (chart-level) +// ============================================================================ + +function getDataCount(chart: XYChart): number { + if (chart.xAxis.categories) return chart.xAxis.categories.length + for (const s of chart.series) { + if (s.data.length > 0) return s.data.length + } + return 0 +} + +function getCategoryLabels(chart: XYChart, count: number): string[] { + if (chart.xAxis.categories) return chart.xAxis.categories + if (chart.xAxis.range) { + const { min, max } = chart.xAxis.range + const step = count > 1 ? (max - min) / (count - 1) : 0 + return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i)) + } + return Array.from({ length: count }, (_, i) => String(i + 1)) +} + +/** Generate nice tick values for a numeric range. */ +function niceTickValues(min: number, max: number): number[] { + const range = max - min + if (range <= 0) return [min] + + const rawInterval = range / 6 + const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval))) + const residual = rawInterval / magnitude + let niceInterval: number + if (residual <= 1.5) niceInterval = magnitude + else if (residual <= 3) niceInterval = 2 * magnitude + else if (residual <= 7) niceInterval = 5 * magnitude + else niceInterval = 10 * magnitude + + const start = Math.ceil(min / niceInterval) * niceInterval + const ticks: number[] = [] + for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) { + ticks.push(Math.round(v * 1e10) / 1e10) + } + return ticks +} + +function formatTickValue(v: number): string { + if (Number.isInteger(v)) return String(v) + return v.toFixed(Math.abs(v) < 10 ? 1 : 0) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/class/parser.ts b/packages/utils/src/vendor/mermaid-ascii/class/parser.ts new file mode 100644 index 000000000..ad8e28ec0 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/class/parser.ts @@ -0,0 +1,290 @@ +import type { ClassDiagram, ClassNode, ClassRelationship, ClassMember, RelationshipType, ClassNamespace } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// Class diagram parser +// +// Parses Mermaid classDiagram syntax into a ClassDiagram structure. +// +// Supported syntax: +// class Animal { +String name; +eat() void } +// class Shape { <> } +// Animal <|-- Dog (inheritance) +// Car *-- Engine (composition) +// Car o-- Wheel (aggregation) +// A --> B (association) +// A ..> B (dependency) +// A ..|> B (realization) +// A "1" --> "*" B : label (with cardinality + label) +// Animal : +String name (inline attribute) +// namespace MyNamespace { class A { } } +// ============================================================================ + +/** + * Parse a Mermaid class diagram. + * Expects the first line to be "classDiagram". + */ +export function parseClassDiagram(lines: string[]): ClassDiagram { + const diagram: ClassDiagram = { + classes: [], + relationships: [], + namespaces: [], + } + + // Track classes by ID for deduplication + const classMap = new Map() + // Track namespace nesting + let currentNamespace: ClassNamespace | null = null + // Track class body parsing + let currentClass: ClassNode | null = null + let braceDepth = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside a class body block --- + if (currentClass && braceDepth > 0) { + if (line === '}') { + braceDepth-- + if (braceDepth === 0) { + currentClass = null + } + continue + } + + // Check for annotation like <> + const annotMatch = line.match(/^<<(\w+)>>$/) + if (annotMatch) { + currentClass.annotation = annotMatch[1]! + continue + } + + // Parse member: visibility, name, type, optional parens for method + const member = parseMember(line) + if (member) { + if (member.isMethod) { + currentClass.methods.push(member.member) + } else { + currentClass.attributes.push(member.member) + } + } + continue + } + + // --- Namespace block start --- + const nsMatch = line.match(/^namespace\s+(\S+)\s*\{$/) + if (nsMatch) { + currentNamespace = { name: nsMatch[1]!, classIds: [] } + continue + } + + // --- Namespace end --- + if (line === '}' && currentNamespace) { + diagram.namespaces.push(currentNamespace) + currentNamespace = null + continue + } + + // --- Class block start: `class ClassName {` or `class ClassName` --- + const classBlockMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/) + if (classBlockMatch) { + const id = classBlockMatch[1]! + const generic = classBlockMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + currentClass = cls + braceDepth = 1 + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Standalone class declaration (no body): `class ClassName` --- + const classOnlyMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/) + if (classOnlyMatch) { + const id = classOnlyMatch[1]! + const generic = classOnlyMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Inline annotation: `class ClassName { <> }` (single line) --- + const inlineAnnotMatch = line.match(/^class\s+(\S+?)\s*\{\s*<<(\w+)>>\s*\}$/) + if (inlineAnnotMatch) { + const cls = ensureClass(classMap, inlineAnnotMatch[1]!) + cls.annotation = inlineAnnotMatch[2]! + continue + } + + // --- Inline attribute: `ClassName : +String name` --- + const inlineAttrMatch = line.match(/^(\S+?)\s*:\s*(.+)$/) + if (inlineAttrMatch) { + // Make sure this isn't a relationship line (those have arrows) + const rest = inlineAttrMatch[2]! + if (!rest.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)) { + const cls = ensureClass(classMap, inlineAttrMatch[1]!) + const member = parseMember(rest) + if (member) { + if (member.isMethod) { + cls.methods.push(member.member) + } else { + cls.attributes.push(member.member) + } + } + continue + } + } + + // --- Relationship --- + // Pattern: [FROM] ["card"] ARROW ["card"] [TO] [: label] + // Arrows: <|--, *--, o--, -->, ..|>, ..> + // Can also be reversed: --o, --*, --|> + const rel = parseRelationship(line) + if (rel) { + // Ensure both classes exist + ensureClass(classMap, rel.from) + ensureClass(classMap, rel.to) + diagram.relationships.push(rel) + continue + } + } + + diagram.classes = [...classMap.values()] + return diagram +} + +/** Ensure a class exists in the map, creating a default if needed */ +function ensureClass(classMap: Map, id: string): ClassNode { + let cls = classMap.get(id) + if (!cls) { + cls = { id, label: id, attributes: [], methods: [] } + classMap.set(id, cls) + } + return cls +} + +/** Parse a class member line (attribute or method) */ +function parseMember(line: string): { member: ClassMember; isMethod: boolean } | null { + const trimmed = line.trim().replace(/;$/, '') + if (!trimmed) return null + + // Extract visibility prefix + let visibility: ClassMember['visibility'] = '' + let rest = trimmed + if (/^[+\-#~]/.test(rest)) { + visibility = rest[0] as ClassMember['visibility'] + rest = rest.slice(1).trim() + } + + // Check if it's a method (has parentheses) + const methodMatch = rest.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/) + if (methodMatch) { + const name = methodMatch[1]!.trim() + const params = methodMatch[2]?.trim() || undefined // Store the parameter string + const type = methodMatch[3]?.trim() + // Check for static ($) or abstract (*) markers + const isStatic = name.endsWith('$') || rest.includes('$') + const isAbstract = name.endsWith('*') || rest.includes('*') + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: true, + params, + }, + isMethod: true, + } + } + + // It's an attribute: [Type] name or name Type + // Common patterns: "String name", "+int age", "name" + const parts = rest.split(/\s+/) + let name: string + let type: string | undefined + + if (parts.length >= 2) { + // "Type name" pattern + type = parts[0] + name = parts.slice(1).join(' ') + } else { + name = parts[0] ?? rest + } + + const isStatic = name.endsWith('$') + const isAbstract = name.endsWith('*') + + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: false, + }, + isMethod: false, + } +} + +/** Parse a relationship line into a ClassRelationship */ +function parseRelationship(line: string): ClassRelationship | null { + // Relationship regex — handles all arrow types with optional cardinality and labels + // Pattern: FROM ["card"] ARROW ["card"] TO [: label] + const match = line.match( + /^(\S+?)\s+(?:"([^"]*?)"\s+)?(<\|--|<\|\.\.|\*--|o--|-->|--\*|--o|--\|>|\.\.>|\.\.\|>|<--|<\.\.?|--)\s+(?:"([^"]*?)"\s+)?(\S+?)(?:\s*:\s*(.+))?$/ + ) + if (!match) return null + + const from = match[1]! + const rawFromCardinality = match[2] + const fromCardinality = rawFromCardinality ? normalizeBrTags(rawFromCardinality) : undefined + const arrow = match[3]!.trim() + const rawToCardinality = match[4] + const toCardinality = rawToCardinality ? normalizeBrTags(rawToCardinality) : undefined + const to = match[5]! + const rawLabel = match[6]?.trim() + const label = rawLabel ? normalizeBrTags(rawLabel) : undefined + + const parsed = parseArrow(arrow) + if (!parsed) return null + + return { from, to, type: parsed.type, markerAt: parsed.markerAt, label, fromCardinality, toCardinality } +} + +/** + * Map arrow syntax to relationship type and marker placement side. + * Prefix markers (`<|--`, `*--`, `o--`) place the UML shape at the 'from' end. + * Suffix markers (`..|>`, `-->`, `..>`, `--*`, `--o`) place it at the 'to' end. + */ +function parseArrow(arrow: string): { type: RelationshipType; markerAt: 'from' | 'to' } | null { + // Trim whitespace that might be captured by the regex + const a = arrow.trim() + switch (a) { + case '<|--': return { type: 'inheritance', markerAt: 'from' } + case '--|>': return { type: 'inheritance', markerAt: 'to' } + case '<|..': return { type: 'realization', markerAt: 'from' } + case '..|>': return { type: 'realization', markerAt: 'to' } + case '*--': return { type: 'composition', markerAt: 'from' } + case '--*': return { type: 'composition', markerAt: 'to' } + case 'o--': return { type: 'aggregation', markerAt: 'from' } + case '--o': return { type: 'aggregation', markerAt: 'to' } + case '-->': return { type: 'association', markerAt: 'to' } + case '<--': return { type: 'association', markerAt: 'from' } + case '..>': return { type: 'dependency', markerAt: 'to' } + case '<..': return { type: 'dependency', markerAt: 'from' } + case '--': return { type: 'association', markerAt: 'to' } + default: return null + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/class/types.ts b/packages/utils/src/vendor/mermaid-ascii/class/types.ts new file mode 100644 index 000000000..b4f96b104 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/class/types.ts @@ -0,0 +1,121 @@ +// ============================================================================ +// Class diagram types +// +// Models the parsed and positioned representations of a Mermaid class diagram. +// Class diagrams show UML class relationships, inheritance, composition, etc. +// ============================================================================ + +/** Parsed class diagram — logical structure from mermaid text */ +export interface ClassDiagram { + /** All class definitions */ + classes: ClassNode[] + /** Relationships between classes */ + relationships: ClassRelationship[] + /** Optional namespace groupings */ + namespaces: ClassNamespace[] +} + +export interface ClassNode { + id: string + label: string + /** Annotation like <>, <>, <>, <> */ + annotation?: string + /** Class attributes (fields/properties) */ + attributes: ClassMember[] + /** Class methods (functions) */ + methods: ClassMember[] +} + +export interface ClassMember { + /** Visibility: + public, - private, # protected, ~ package */ + visibility: '+' | '-' | '#' | '~' | '' + /** Member name */ + name: string + /** Type annotation (e.g., "String", "int", "void") */ + type?: string + /** Whether the member is static (underlined in UML) */ + isStatic?: boolean + /** Whether the member is abstract (italic in UML) */ + isAbstract?: boolean + /** Whether the member is a method (renders with parentheses) */ + isMethod?: boolean + /** Method parameters (e.g., "data", "key, val") — only for methods */ + params?: string +} + +/** Relationship types following UML conventions */ +export type RelationshipType = + | 'inheritance' // A <|-- B (solid line, hollow triangle) + | 'composition' // A *-- B (solid line, filled diamond) + | 'aggregation' // A o-- B (solid line, hollow diamond) + | 'association' // A --> B (solid line, open arrow) + | 'dependency' // A ..> B (dashed line, open arrow) + | 'realization' // A ..|> B (dashed line, hollow triangle) + +export interface ClassRelationship { + from: string + to: string + type: RelationshipType + /** + * Which end of the relationship line has the UML marker (triangle, diamond, arrow). + * Determined by the arrow syntax direction: + * - Prefix markers like `<|--`, `*--`, `o--` → 'from' (marker on left/from side) + * - Suffix markers like `..|>`, `-->`, `..>`, `--*`, `--o` → 'to' (marker on right/to side) + */ + markerAt: 'from' | 'to' + /** Label on the relationship line */ + label?: string + /** Cardinality at the "from" end (e.g., "1", "*", "0..1") */ + fromCardinality?: string + /** Cardinality at the "to" end */ + toCardinality?: string +} + +export interface ClassNamespace { + name: string + classIds: string[] +} + +// ============================================================================ +// Positioned class diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedClassDiagram { + width: number + height: number + classes: PositionedClassNode[] + relationships: PositionedClassRelationship[] +} + +export interface PositionedClassNode { + id: string + label: string + annotation?: string + attributes: ClassMember[] + methods: ClassMember[] + x: number + y: number + width: number + height: number + /** Height of the header section (name + annotation) */ + headerHeight: number + /** Height of the attributes section */ + attrHeight: number + /** Height of the methods section */ + methodHeight: number +} + +export interface PositionedClassRelationship { + from: string + to: string + type: RelationshipType + /** Which end of the line has the UML marker — propagated from ClassRelationship */ + markerAt: 'from' | 'to' + label?: string + fromCardinality?: string + toCardinality?: string + /** Path points from source to target */ + points: Array<{ x: number; y: number }> + /** Dagre-computed label center position (avoids overlaps between nearby edges) */ + labelPosition?: { x: number; y: number } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/er/parser.ts b/packages/utils/src/vendor/mermaid-ascii/er/parser.ts new file mode 100644 index 000000000..100685832 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/er/parser.ts @@ -0,0 +1,181 @@ +import type { ErDiagram, ErEntity, ErAttribute, ErRelationship, Cardinality } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// ER diagram parser +// +// Parses Mermaid erDiagram syntax into an ErDiagram structure. +// +// Supported syntax: +// CUSTOMER ||--o{ ORDER : places +// CUSTOMER { +// string name PK +// int age +// string email UK "user email" +// } +// +// Cardinality notation: +// || exactly one +// o| zero or one (also |o) +// }| one or more (also |{) +// o{ zero or more (also {o) +// +// Line style: +// -- identifying (solid line) +// .. non-identifying (dashed line) +// ============================================================================ + +/** + * Parse a Mermaid ER diagram. + * Expects the first line to be "erDiagram". + */ +export function parseErDiagram(lines: string[]): ErDiagram { + const diagram: ErDiagram = { + entities: [], + relationships: [], + } + + // Track entities by ID for deduplication + const entityMap = new Map() + // Track entity body parsing + let currentEntity: ErEntity | null = null + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside entity body --- + if (currentEntity) { + if (line === '}') { + currentEntity = null + continue + } + + // Attribute line: type name [PK|FK|UK] ["comment"] + const attr = parseAttribute(line) + if (attr) { + currentEntity.attributes.push(attr) + } + continue + } + + // --- Entity block start: `ENTITY_NAME {` --- + const entityBlockMatch = line.match(/^(\S+)\s*\{$/) + if (entityBlockMatch) { + const id = entityBlockMatch[1]! + const entity = ensureEntity(entityMap, id) + currentEntity = entity + continue + } + + // --- Relationship: `ENTITY1 cardinality1--cardinality2 ENTITY2 : label` --- + const rel = parseRelationshipLine(line) + if (rel) { + // Ensure both entities exist + ensureEntity(entityMap, rel.entity1) + ensureEntity(entityMap, rel.entity2) + diagram.relationships.push(rel) + continue + } + } + + diagram.entities = [...entityMap.values()] + return diagram +} + +/** Ensure an entity exists in the map */ +function ensureEntity(entityMap: Map, id: string): ErEntity { + let entity = entityMap.get(id) + if (!entity) { + entity = { id, label: id, attributes: [] } + entityMap.set(id, entity) + } + return entity +} + +/** Parse an attribute line inside an entity block */ +function parseAttribute(line: string): ErAttribute | null { + // Format: type name [PK|FK|UK [...]] ["comment"] + const match = line.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/) + if (!match) return null + + const type = match[1]! + const name = match[2]! + const rest = match[3]?.trim() ?? '' + + // Extract key constraints (PK, FK, UK) and optional comment + const keys: ErAttribute['keys'] = [] + let comment: string | undefined + + // Extract quoted comment first (supports
          tags) + const commentMatch = rest.match(/"([^"]*)"/) + if (commentMatch) { + comment = normalizeBrTags(commentMatch[1]!) + } + + // Extract key constraints + const restWithoutComment = rest.replace(/"[^"]*"/, '').trim() + for (const part of restWithoutComment.split(/\s+/)) { + const upper = part.toUpperCase() + if (upper === 'PK' || upper === 'FK' || upper === 'UK') { + keys.push(upper as 'PK' | 'FK' | 'UK') + } + } + + return { type, name, keys, comment } +} + +/** + * Parse a relationship line. + * + * Cardinality symbols on each side of the line style: + * Left side (entity1): || |o o| }| |{ o{ {o + * Line: -- (identifying) or .. (non-identifying) + * Right side (entity2): || o| |o |{ }| {o o{ + * + * Full pattern example: CUSTOMER ||--o{ ORDER : places + */ +function parseRelationshipLine(line: string): ErRelationship | null { + // Match: ENTITY1 ENTITY2 : label + const match = line.match(/^(\S+)\s+([|o}{]+(?:--|\.\.)[|o}{]+)\s+(\S+)\s*:\s*(.+)$/) + if (!match) return null + + const entity1 = match[1]! + const cardinalityStr = match[2]! + const entity2 = match[3]! + // Strip surrounding quotes if present, then normalize br tags + const rawLabel = match[4]!.trim().replace(/^["']|["']$/g, '') + const label = normalizeBrTags(rawLabel) + + // Split the cardinality string into left side, line style, right side + const lineMatch = cardinalityStr.match(/^([|o}{]+)(--|\.\.?)([|o}{]+)$/) + if (!lineMatch) return null + + const leftStr = lineMatch[1]! + const lineStyle = lineMatch[2]! + const rightStr = lineMatch[3]! + + const cardinality1 = parseCardinality(leftStr) + const cardinality2 = parseCardinality(rightStr) + const identifying = lineStyle === '--' + + if (!cardinality1 || !cardinality2) return null + + return { entity1, entity2, cardinality1, cardinality2, label, identifying } +} + +/** Parse a cardinality notation string into a Cardinality type */ +function parseCardinality(str: string): Cardinality | null { + // Normalize: sort the characters to handle both orders (e.g., |o and o|) + const sorted = str.split('').sort().join('') + + // Exact one: || → sorted "||" + if (sorted === '||') return 'one' + // Zero or one: o| or |o → sorted "o|" (o=111 < |=124 in char codes) + if (sorted === 'o|') return 'zero-one' + // One or more: }| or |{ → sorted "|}" or "{|" + if (sorted === '|}' || sorted === '{|') return 'many' + // Zero or more: o{ or {o → sorted "{o" or "o{" + if (sorted === '{o' || sorted === 'o{') return 'zero-many' + + return null +} diff --git a/packages/utils/src/vendor/mermaid-ascii/er/types.ts b/packages/utils/src/vendor/mermaid-ascii/er/types.ts new file mode 100644 index 000000000..702f3c8c8 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/er/types.ts @@ -0,0 +1,91 @@ +// ============================================================================ +// ER diagram types +// +// Models the parsed and positioned representations of a Mermaid ER diagram. +// ER diagrams show database entities, their attributes, and relationships. +// ============================================================================ + +/** Parsed ER diagram — logical structure from mermaid text */ +export interface ErDiagram { + /** All entity definitions */ + entities: ErEntity[] + /** Relationships between entities */ + relationships: ErRelationship[] +} + +export interface ErEntity { + id: string + /** Display name (same as id unless aliased) */ + label: string + /** Entity attributes (columns) */ + attributes: ErAttribute[] +} + +export interface ErAttribute { + /** Data type (string, int, varchar, etc.) */ + type: string + /** Attribute name */ + name: string + /** Key constraints: PK, FK, UK */ + keys: Array<'PK' | 'FK' | 'UK'> + /** Optional comment */ + comment?: string +} + +/** + * Cardinality notation (crow's foot): + * 'one' || exactly one + * 'zero-one' |o zero or one + * 'many' }| one or more + * 'zero-many' o{ zero or more + */ +export type Cardinality = 'one' | 'zero-one' | 'many' | 'zero-many' + +export interface ErRelationship { + entity1: string + entity2: string + /** Cardinality at entity1's end */ + cardinality1: Cardinality + /** Cardinality at entity2's end */ + cardinality2: Cardinality + /** Relationship verb/label (e.g., "places", "contains") */ + label: string + /** Whether the relationship is identifying (solid line) or non-identifying (dashed) */ + identifying: boolean +} + +// ============================================================================ +// Positioned ER diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedErDiagram { + width: number + height: number + entities: PositionedErEntity[] + relationships: PositionedErRelationship[] +} + +export interface PositionedErEntity { + id: string + label: string + attributes: ErAttribute[] + x: number + y: number + width: number + height: number + /** Height of the header row */ + headerHeight: number + /** Height per attribute row */ + rowHeight: number +} + +export interface PositionedErRelationship { + entity1: string + entity2: string + cardinality1: Cardinality + cardinality2: Cardinality + label: string + identifying: boolean + /** Path points from entity1 to entity2 */ + points: Array<{ x: number; y: number }> +} diff --git a/packages/utils/src/vendor/mermaid-ascii/index.ts b/packages/utils/src/vendor/mermaid-ascii/index.ts new file mode 100644 index 000000000..f668b735d --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/index.ts @@ -0,0 +1,14 @@ +// ============================================================================ +// Mermaid → ASCII renderer (vendored) +// +// First-party copy of the ASCII rendering pipeline from `beautiful-mermaid` +// (MIT, Copyright (c) 2026 Craft Docs — see ./NOTICE). The SVG pipeline and +// its `elkjs` graph-layout dependency are omitted; the ASCII renderers use +// their own grid layout + A* edge routing and need no external deps. Terminal +// display-width math is delegated to `Bun.stringWidth` (see ./text-metrics). +// +// Public surface: renderMermaidASCII + AsciiRenderOptions (incl. the +// `direction` override) and the theme/color-mode types. +// ============================================================================ + +export * from './ascii/index' diff --git a/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts b/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts new file mode 100644 index 000000000..743efc9e7 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/multiline-utils.ts @@ -0,0 +1,30 @@ +// ============================================================================ +// Label normalization +// +// Shared by the diagram parsers (flowchart/state, class, ER, sequence) to +// normalize raw Mermaid label text before it reaches the ASCII renderers. +// The SVG-only multi-line tspan renderers from upstream are not vendored. +// ============================================================================ + +/** + * Normalize label text for terminal ASCII output: strip surrounding quotes, + * convert
          tags and literal newline escapes to newlines, and reduce + * inline formatting (HTML bold/italic/underline/strike tags and the markdown + * bold, italic, and strikethrough markers) to plain text. The ASCII renderer + * has no styled spans, so preserving the markup would print raw tags and + * markers inside node boxes. + */ +export function normalizeBrTags(label: string): string { + // Strip surrounding double quotes (Mermaid uses them for special chars in labels) + const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label + return unquoted + .replace(//gi, '\n') + .replace(/\\n/g, '\n') + .replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '') + // Drop inline HTML formatting tags — ASCII output has no styled spans + .replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '') + // Reduce markdown emphasis to its inner text (order matters: ** before *) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(? l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + + if (lines.length === 0) { + throw new Error('Empty mermaid diagram') + } + + // Detect diagram type from header + const header = lines[0]! + + // State diagram: "stateDiagram-v2" or "stateDiagram" + if (/^stateDiagram(-v2)?\s*$/i.test(header)) { + return parseStateDiagram(lines) + } + + // Flowchart: "graph TD" or "flowchart LR" + return parseFlowchart(lines) +} + +// ============================================================================ +// Flowchart parser +// ============================================================================ + +function parseFlowchart(lines: string[]): MermaidGraph { + const headerMatch = lines[0]!.match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i) + if (!headerMatch) { + throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`) + } + + const direction = headerMatch[1]!.toUpperCase() as Direction + + const graph: MermaidGraph = { + direction, + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Subgraph stack for nested subgraphs. + const subgraphStack: MermaidSubgraph[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- classDef: `classDef name prop:val,prop:val` --- + const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/) + if (classDefMatch) { + const name = classDefMatch[1]! + const propsStr = classDefMatch[2]! + const props = parseStyleProps(propsStr) + graph.classDefs.set(name, props) + continue + } + + // --- class assignment: `class A,B className` --- + const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/) + if (classAssignMatch) { + const nodeIds = classAssignMatch[1]!.split(',').map(s => s.trim()) + const className = classAssignMatch[2]! + for (const id of nodeIds) { + graph.classAssignments.set(id, className) + } + continue + } + + // --- style statement: `style A,B fill:#f00,stroke:#333` --- + const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/) + if (styleMatch) { + const nodeIds = styleMatch[1]!.split(',').map(s => s.trim()) + const props = parseStyleProps(styleMatch[2]!) + for (const id of nodeIds) { + graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props }) + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- direction override inside subgraph: `direction LR` --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch && subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + continue + } + + // --- subgraph start: `subgraph Label` or `subgraph id [Label]` --- + const subgraphMatch = line.match(/^subgraph\s+(.+)$/) + if (subgraphMatch) { + const rest = subgraphMatch[1]!.trim() + // Check for "subgraph id [Label]" form + // ID can contain hyphens (e.g. "us-east"), so use [\w-]+ not \w+ + const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/) + let id: string + let label: string + if (bracketMatch) { + id = bracketMatch[1]! + label = normalizeBrTags(bracketMatch[2]!) + } else { + // Use the label text as id (slugified) + label = normalizeBrTags(rest) + id = rest.replace(/\s+/g, '_').replace(/[^\w]/g, '') + } + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + subgraphStack.push(sg) + continue + } + + // --- subgraph end --- + if (line === 'end') { + const completed = subgraphStack.pop() + if (completed) { + if (subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- Edge/node definitions --- + parseEdgeLine(line, graph, subgraphStack) + } + + return graph +} + +// ============================================================================ +// State diagram parser +// +// Supported syntax: +// stateDiagram-v2 +// s1 : Description +// state "Description" as s1 +// s1 --> s2 : label +// [*] --> s1 (start pseudostate) +// s1 --> [*] (end pseudostate) +// state CompositeState { +// inner1 --> inner2 +// } +// ============================================================================ + +function parseStateDiagram(lines: string[]): MermaidGraph { + const graph: MermaidGraph = { + direction: 'TD', + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Track composite state nesting (like subgraphs) + const compositeStack: MermaidSubgraph[] = [] + // Track all composite state IDs to avoid creating duplicate nodes + const compositeStateIds = new Set() + // Counter for unique [*] pseudostate IDs + let startCount = 0 + let endCount = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- direction override --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + } else { + graph.direction = dirMatch[1]!.toUpperCase() as Direction + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- composite state start: `state CompositeState {` --- + const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u) + if (compositeMatch) { + const label = compositeMatch[1] ?? compositeMatch[2]! + const id = compositeMatch[2]! + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + compositeStack.push(sg) + // Track this ID to avoid creating a duplicate node for the composite state + compositeStateIds.add(id) + // Remove any existing node that was created when parsing transitions before + // this composite state definition (e.g., "A --> Processing" before "state Processing {") + graph.nodes.delete(id) + continue + } + + // --- composite state end --- + if (line === '}') { + const completed = compositeStack.pop() + if (completed) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- state alias: `state "Description" as s1` (without brace) --- + const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u) + if (stateAliasMatch) { + const label = normalizeBrTags(stateAliasMatch[1]!) + const id = stateAliasMatch[2]! + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + + // --- transition: `s1 --> s2` or `s1 --> s2 : label` or `[*] --> s1` --- + const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u) + if (transitionMatch) { + let sourceId = transitionMatch[1]! + let targetId = transitionMatch[3]! + const rawTransitionLabel = transitionMatch[4]?.trim() + const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : undefined + + // Handle [*] pseudostates — each occurrence gets a unique ID + if (sourceId === '[*]') { + startCount++ + sourceId = `_start${startCount > 1 ? startCount : ''}` + registerStateNode(graph, compositeStack, { id: sourceId, label: '', shape: 'state-start' }) + } else if (!compositeStateIds.has(sourceId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, sourceId) + } + + if (targetId === '[*]') { + endCount++ + targetId = `_end${endCount > 1 ? endCount : ''}` + registerStateNode(graph, compositeStack, { id: targetId, label: '', shape: 'state-end' }) + } else if (!compositeStateIds.has(targetId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, targetId) + } + + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style: 'solid', + hasArrowStart: false, + hasArrowEnd: true, + }) + continue + } + + // --- state description: `s1 : Description` --- + const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u) + if (stateDescMatch) { + const id = stateDescMatch[1]! + const label = normalizeBrTags(stateDescMatch[2]!.trim()) + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + } + + return graph +} + +/** Register a state node and track in composite state if applicable */ +function registerStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(node.id)) { + current.nodeIds.push(node.id) + } + } +} + +/** Ensure a state node exists with default rounded shape */ +function ensureStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + id: string +): void { + if (!graph.nodes.has(id)) { + registerStateNode(graph, compositeStack, { id, label: id, shape: 'rounded' }) + } else { + // Track in composite if applicable + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(id)) { + current.nodeIds.push(id) + } + } + } +} + +// ============================================================================ +// Shared utilities +// ============================================================================ + +/** Parse "fill:#f00,stroke:#333" style property strings into a Record */ +function parseStyleProps(propsStr: string): Record { + // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`) + const cleaned = propsStr.replace(/;\s*$/, '') + const props: Record = {} + for (const pair of cleaned.split(',')) { + const colonIdx = pair.indexOf(':') + if (colonIdx > 0) { + const key = pair.slice(0, colonIdx).trim() + const val = pair.slice(colonIdx + 1).trim() + if (key && val) { + props[key] = val + } + } + } + return props +} + +// ============================================================================ +// Flowchart edge line parser +// +// Handles chained edges like: A[Label] --> B(Label) -.-> C{Label} +// Also handles & parallel links: A & B --> C & D +// ============================================================================ + +/** + * Arrow regex — matches all arrow operators with optional labels. + * + * Supported operators: + * --> --- solid arrow / solid line + * -.-> -.- dotted arrow / dotted line + * ==> === thick arrow / thick line + * <--> <-.-> <==> bidirectional variants + * + * Optional label: -->|label text| + */ +const ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/ + +/** + * Text-embedded label regex — matches "-- label -->", "-. label .->", "== label ==>" syntax. + * Tried as fallback when ARROW_REGEX doesn't match. + * + * Based on PR #36 by @liuxiaopai-ai (https://github.com/lukilabs/beautiful-mermaid/pull/36) + */ +const TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/ + +/** + * Node shape patterns — ordered from most specific delimiters to least. + * Multi-char delimiters must be tried before single-char to avoid false matches. + */ +const NODE_PATTERNS: Array<{ regex: RegExp; shape: NodeShape }> = [ + // Triple delimiters (must be first) + { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: 'doublecircle' }, // A(((text))) + + // Double delimiters with mixed brackets + { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: 'stadium' }, // A([text]) + { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: 'circle' }, // A((text)) + { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: 'subroutine' }, // A[[text]] + { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: 'cylinder' }, // A[(text)] + + // Trapezoid variants — must come before plain [text] + { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: 'trapezoid' }, // A[/text\] + { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: 'trapezoid-alt' }, // A[\text/] + + // Asymmetric flag shape + { regex: /^([\w-]+)>(.+?)\]/, shape: 'asymmetric' }, // A>text] + + // Double curly braces (hexagon) — must come before single {text} + { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: 'hexagon' }, // A{{text}} + + // Single-char delimiters (last — most common, least specific) + { regex: /^([\w-]+)\[(.+?)\]/, shape: 'rectangle' }, // A[text] + { regex: /^([\w-]+)\((.+?)\)/, shape: 'rounded' }, // A(text) + { regex: /^([\w-]+)\{(.+?)\}/, shape: 'diamond' }, // A{text} +] + +/** Regex for a bare node reference (just an ID, no shape brackets) */ +const BARE_NODE_REGEX = /^([\w-]+)/ + +/** Regex for ::: class shorthand suffix — matches :::className immediately after a node */ +const CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/ + +/** + * Parse a line that contains node definitions and edges. + * Handles chaining: A --> B --> C produces edges A→B and B→C. + * Handles parallel links: A & B --> C & D produces 4 edges. + */ +function parseEdgeLine( + line: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): void { + let remaining = line.trim() + + // Parse the first node group (possibly with & separators) + const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!firstGroup || firstGroup.ids.length === 0) return + + remaining = firstGroup.remaining.trim() + let prevGroupIds = firstGroup.ids + + // Parse arrow + node-group pairs until the line is exhausted + while (remaining.length > 0) { + let hasArrowStart: boolean + let style: EdgeStyle + let hasArrowEnd: boolean + let edgeLabel: string | undefined + + const arrowMatch = remaining.match(ARROW_REGEX) + if (arrowMatch) { + hasArrowStart = Boolean(arrowMatch[1]) + const arrowOp = arrowMatch[2]! + const rawEdgeLabel = arrowMatch[3]?.trim() + edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : undefined + remaining = remaining.slice(arrowMatch[0].length).trim() + style = arrowStyleFromOp(arrowOp) + hasArrowEnd = arrowOp.endsWith('>') + } else { + // Fallback: text-embedded label syntax (-- Yes -->, -. Maybe .->, == Sure ==>) + const textMatch = remaining.match(TEXT_ARROW_REGEX) + if (!textMatch) break + hasArrowStart = Boolean(textMatch[1]) + const rawLabel = textMatch[3]!.trim() + edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : undefined + const openOp = textMatch[2]! + const closeOp = textMatch[4]! + remaining = remaining.slice(textMatch[0].length).trim() + style = textArrowStyleFromOps(openOp, closeOp) + hasArrowEnd = closeOp.endsWith('>') + } + + // Parse the next node group + const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!nextGroup || nextGroup.ids.length === 0) break + + remaining = nextGroup.remaining.trim() + + // Emit Cartesian product of edges: every source × every target + for (const sourceId of prevGroupIds) { + for (const targetId of nextGroup.ids) { + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style, + hasArrowStart, + hasArrowEnd, + }) + } + } + + prevGroupIds = nextGroup.ids + } +} + +interface ConsumedNodeGroup { + ids: string[] + remaining: string +} + +/** + * Consume one or more nodes separated by `&`. + * E.g. "A & B & C --> ..." returns ids: ['A', 'B', 'C'] + */ +function consumeNodeGroup( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNodeGroup | null { + const first = consumeNode(text, graph, subgraphStack) + if (!first) return null + + const ids = [first.id] + let remaining = first.remaining.trim() + + // Check for & separators + while (remaining.startsWith('&')) { + remaining = remaining.slice(1).trim() + const next = consumeNode(remaining, graph, subgraphStack) + if (!next) break + ids.push(next.id) + remaining = next.remaining.trim() + } + + return { ids, remaining } +} + +interface ConsumedNode { + id: string + remaining: string +} + +/** + * Try to consume a node definition from the start of `text`. + * If the node has a shape+label (e.g. A[Text]), it's registered in the graph. + * If it's a bare reference (e.g. A), we look it up or create a default. + * Also handles ::: class shorthand suffix. + */ +function consumeNode( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNode | null { + let id: string | null = null + let remaining: string = text + + // Try each node pattern (shape-qualified) + for (const { regex, shape } of NODE_PATTERNS) { + const match = text.match(regex) + if (match) { + id = match[1]! + const label = normalizeBrTags(match[2]!) + registerNode(graph, subgraphStack, { id, label, shape }) + remaining = text.slice(match[0].length) + break + } + } + + // Bare node reference — only register if node doesn't exist yet. + // If it already exists, do NOT track it in the current subgraph; + // nodes belong to the subgraph where they're first defined. + if (id === null) { + const bareMatch = text.match(BARE_NODE_REGEX) + if (bareMatch) { + id = bareMatch[1]! + if (!graph.nodes.has(id)) { + registerNode(graph, subgraphStack, { id, label: id, shape: 'rectangle' }) + } + remaining = text.slice(bareMatch[0].length) + } + } + + if (id === null) return null + + // Check for ::: class shorthand suffix immediately after the node + const classMatch = remaining.match(CLASS_SHORTHAND_REGEX) + if (classMatch) { + graph.classAssignments.set(id, classMatch[1]!) + remaining = remaining.slice(classMatch[0].length) + } + + return { id, remaining } +} + +/** Register a node in the graph and track it in the current subgraph */ +function registerNode( + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + trackInSubgraph(subgraphStack, node.id) +} + +/** Add node ID to the innermost subgraph if we're inside one */ +function trackInSubgraph(subgraphStack: MermaidSubgraph[], nodeId: string): void { + if (subgraphStack.length > 0) { + const current = subgraphStack[subgraphStack.length - 1]! + if (!current.nodeIds.includes(nodeId)) { + current.nodeIds.push(nodeId) + } + } +} + +/** Map arrow operator string to edge style (ignoring direction) */ +function arrowStyleFromOp(op: string): EdgeStyle { + if (op === '-.->') return 'dotted' + if (op === '-.-') return 'dotted' + if (op === '==>') return 'thick' + if (op === '===') return 'thick' + // '-->'' and '---' are both solid + return 'solid' +} + +/** Map text-embedded arrow open/close operators to edge style */ +function textArrowStyleFromOps(openOp: string, closeOp: string): EdgeStyle { + if (openOp === '-.' || closeOp === '.->' || closeOp === '-.-') return 'dotted' + if (openOp === '==' || closeOp === '==>' || closeOp === '===') return 'thick' + return 'solid' +} diff --git a/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts b/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts new file mode 100644 index 000000000..11262f368 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/sequence/parser.ts @@ -0,0 +1,207 @@ +import type { SequenceDiagram, Actor, Message, Block, Note } from './types' +import { normalizeBrTags } from '../multiline-utils' + +// ============================================================================ +// Sequence diagram parser +// +// Parses Mermaid sequenceDiagram syntax into a SequenceDiagram structure. +// +// Supported syntax: +// participant A as Alice +// actor B as Bob +// A->>B: Solid arrow +// A-->>B: Dashed arrow +// A-)B: Open arrow +// A--)B: Dashed open arrow +// A->>+B: Activate target +// A-->>-B: Deactivate source +// loop Label ... end +// alt Label ... else Label ... end +// opt Label ... end +// par Label ... and Label ... end +// Note left of A: Text +// Note right of A: Text +// Note over A,B: Text +// ============================================================================ + +/** + * Parse a Mermaid sequence diagram. + * Expects the first line to be "sequenceDiagram". + */ +export function parseSequenceDiagram(lines: string[]): SequenceDiagram { + const diagram: SequenceDiagram = { + actors: [], + messages: [], + blocks: [], + notes: [], + } + + // Track actor IDs to auto-create actors referenced in messages + const actorIds = new Set() + // Track block nesting with a stack + const blockStack: Array<{ type: Block['type']; label: string; startIndex: number; dividers: Block['dividers'] }> = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Participant / Actor declaration --- + // "participant A as Alice" or "participant Alice" + // "actor B as Bob" or "actor Bob" + const actorMatch = line.match(/^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/) + if (actorMatch) { + const type = actorMatch[1] as 'participant' | 'actor' + const id = actorMatch[2]! + const rawLabel = actorMatch[3]?.trim() ?? id + const label = normalizeBrTags(rawLabel) + if (!actorIds.has(id)) { + actorIds.add(id) + diagram.actors.push({ id, label, type }) + } + continue + } + + // --- Note --- + // "Note left of A: text" / "Note right of A: text" / "Note over A,B: text" + const noteMatch = line.match(/^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i) + if (noteMatch) { + const posStr = noteMatch[1]!.toLowerCase() + const actorsStr = noteMatch[2]!.trim() + const text = normalizeBrTags(noteMatch[3]!.trim()) + const noteActorIds = actorsStr.split(',').map(s => s.trim()) + + // Ensure actors exist + for (const aid of noteActorIds) { + ensureActor(diagram, actorIds, aid) + } + + let position: 'left' | 'right' | 'over' = 'over' + if (posStr === 'left of') position = 'left' + else if (posStr === 'right of') position = 'right' + + diagram.notes.push({ + actorIds: noteActorIds, + text, + position, + afterIndex: diagram.messages.length - 1, + }) + continue + } + + // --- Block start: loop, alt, opt, par, critical, break, rect --- + const blockMatch = line.match(/^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/) + if (blockMatch) { + const blockType = blockMatch[1] as Block['type'] + const rawBlockLabel = blockMatch[2]?.trim() ?? '' + const label = normalizeBrTags(rawBlockLabel) + blockStack.push({ + type: blockType, + label, + startIndex: diagram.messages.length, + dividers: [], + }) + continue + } + + // --- Block divider: else, and --- + const dividerMatch = line.match(/^(else|and)\s*(.*)$/) + if (dividerMatch && blockStack.length > 0) { + const rawDividerLabel = dividerMatch[2]?.trim() ?? '' + const label = normalizeBrTags(rawDividerLabel) + blockStack[blockStack.length - 1]!.dividers.push({ + index: diagram.messages.length, + label, + }) + continue + } + + // --- Block end --- + if (line === 'end' && blockStack.length > 0) { + const completed = blockStack.pop()! + diagram.blocks.push({ + type: completed.type, + label: completed.label, + startIndex: completed.startIndex, + endIndex: Math.max(diagram.messages.length - 1, completed.startIndex), + dividers: completed.dividers, + }) + continue + } + + // --- Message --- + // Patterns: A->>B, A-->>B, A-)B, A--)B, with optional +/- activation + // Format: FROM ARROW TO: LABEL + const msgMatch = line.match( + /^(\S+?)\s*(--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(\S+?)\s*:\s*(.+)$/ + ) + if (msgMatch) { + const from = msgMatch[1]! + const arrow = msgMatch[2]! + const activationMark = msgMatch[3] + const to = msgMatch[4]! + const label = normalizeBrTags(msgMatch[5]!.trim()) + + // Ensure both actors exist + ensureActor(diagram, actorIds, from) + ensureActor(diagram, actorIds, to) + + // Determine line style and arrow head from the arrow operator + const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid' + // ">>" = filled arrow, ")" or ">" alone = open arrow, "x" = cross (treat as filled) + const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open' + + const msg: Message = { + from, + to, + label, + lineStyle, + arrowHead, + } + + // Activation/deactivation via +/- prefix on target + if (activationMark === '+') msg.activate = true + if (activationMark === '-') msg.deactivate = true + + diagram.messages.push(msg) + continue + } + + // --- Simplified message format: A->>B: Label (fallback with more relaxed regex) --- + const simpleMsgMatch = line.match( + /^(\S+?)\s*(->>|-->>|-\)|--\)|-x|--x|->|-->)\s*([+-]?)(\S+?)\s*:\s*(.+)$/ + ) + if (simpleMsgMatch) { + const from = simpleMsgMatch[1]! + const arrow = simpleMsgMatch[2]! + const activationMark = simpleMsgMatch[3] + const to = simpleMsgMatch[4]! + const label = normalizeBrTags(simpleMsgMatch[5]!.trim()) + + ensureActor(diagram, actorIds, from) + ensureActor(diagram, actorIds, to) + + const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid' + const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open' + + const msg: Message = { from, to, label, lineStyle, arrowHead } + if (activationMark === '+') msg.activate = true + if (activationMark === '-') msg.deactivate = true + + diagram.messages.push(msg) + continue + } + + // --- activate / deactivate explicit commands --- + // These are handled implicitly via +/- on messages but can also appear standalone + // For now, we skip explicit activate/deactivate lines (they affect rendering only) + } + + return diagram +} + +/** Ensure an actor exists, creating a default participant if not */ +function ensureActor(diagram: SequenceDiagram, actorIds: Set, id: string): void { + if (!actorIds.has(id)) { + actorIds.add(id) + diagram.actors.push({ id, label: id, type: 'participant' }) + } +} diff --git a/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts b/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts new file mode 100644 index 000000000..af8a8fc23 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/sequence/types.ts @@ -0,0 +1,146 @@ +// ============================================================================ +// Sequence diagram types +// +// Models the parsed and positioned representations of a Mermaid sequence diagram. +// Sequence diagrams show actor interactions over time (vertical timeline). +// ============================================================================ + +/** Parsed sequence diagram — logical structure from mermaid text */ +export interface SequenceDiagram { + /** Ordered list of actors/participants */ + actors: Actor[] + /** Messages between actors in chronological order */ + messages: Message[] + /** Structural blocks (loop, alt, opt, par, critical) */ + blocks: Block[] + /** Notes attached to actors */ + notes: Note[] +} + +export interface Actor { + id: string + label: string + /** 'participant' renders as a box, 'actor' renders as a stick figure */ + type: 'participant' | 'actor' +} + +export interface Message { + from: string + to: string + label: string + /** Arrow style: solid line or dashed line */ + lineStyle: 'solid' | 'dashed' + /** Arrow head: filled (closed) or open */ + arrowHead: 'filled' | 'open' + /** Activate the target lifeline (+) */ + activate?: boolean + /** Deactivate the source lifeline (-) */ + deactivate?: boolean +} + +export interface Block { + /** Block type keyword */ + type: 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect' + /** Label for the block header */ + label: string + /** Index of the first message inside this block */ + startIndex: number + /** Index of the last message inside this block (inclusive) */ + endIndex: number + /** For alt/par blocks: indices where "else"/"and" dividers appear (message indices) */ + dividers: Array<{ index: number; label: string }> +} + +export interface Note { + /** Which actor(s) the note is attached to */ + actorIds: string[] + /** Note text content */ + text: string + /** Position relative to the actor(s) */ + position: 'left' | 'right' | 'over' + /** Message index after which this note appears */ + afterIndex: number +} + +// ============================================================================ +// Positioned sequence diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedSequenceDiagram { + width: number + height: number + actors: PositionedActor[] + lifelines: Lifeline[] + messages: PositionedMessage[] + activations: Activation[] + blocks: PositionedBlock[] + notes: PositionedNote[] +} + +export interface PositionedActor { + id: string + label: string + type: 'participant' | 'actor' + /** Center x of the actor box */ + x: number + /** Top y of the actor box */ + y: number + width: number + height: number +} + +/** Vertical dashed line from actor to bottom of diagram */ +export interface Lifeline { + actorId: string + x: number + topY: number + bottomY: number +} + +export interface PositionedMessage { + from: string + to: string + label: string + lineStyle: 'solid' | 'dashed' + arrowHead: 'filled' | 'open' + /** Start point (from actor's lifeline) */ + x1: number + /** End point (to actor's lifeline) */ + x2: number + /** Vertical position */ + y: number + /** Whether this is a self-message (same actor) */ + isSelf: boolean +} + +/** Narrow rectangle on a lifeline showing active processing */ +export interface Activation { + actorId: string + x: number + topY: number + bottomY: number + width: number +} + +export interface PositionedBlock { + type: Block['type'] + label: string + x: number + y: number + width: number + height: number + /** Divider lines within the block (for alt/par) */ + dividers: Array<{ y: number; label: string }> +} + +export interface PositionedNote { + text: string + x: number + y: number + width: number + height: number + /** Actor IDs this note is attached to (for SVG attribution) */ + actors?: string[] + /** Note position relative to actors (for SVG attribution) */ + position?: 'left' | 'right' | 'over' +} diff --git a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts new file mode 100644 index 000000000..77c89e7b0 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts @@ -0,0 +1,71 @@ +// ============================================================================ +// Terminal display width — used by the ASCII renderer +// +// Terminals render most characters in 1 column, but East Asian wide +// characters (CJK ideographs, Hangul, kana, fullwidth forms) and +// emoji-presentation glyphs occupy 2 columns. The ASCII canvas is a +// cell-per-column grid, so its width math counts display columns rather +// than code units. +// +// Width is delegated to Bun.stringWidth (wcwidth-style, locale-insensitive): +// East Asian Wide/Fullwidth and emoji-presentation graphemes (incl. ZWJ +// sequences and regional-indicator flags) measure 2; box-drawing, the +// renderer's narrow arrowheads (◀ ▶ ►), and East Asian Ambiguous glyphs +// measure 1. This matches the renderer's structural glyph set exactly. +// +// Text is measured in grapheme clusters (Intl.Segmenter): a cluster that +// renders 2 columns is stored as its full string in one canvas cell +// followed by WIDE_PAD in the continuation cell it covers. The ASCII +// serializers drop WIDE_PAD cells when joining a row, so the emitted line +// occupies exactly as many terminal columns as the canvas has cells. +// ============================================================================ + +/** + * Placeholder occupying the second cell of a fullwidth glyph on the ASCII + * canvas. U+0000 cannot appear in parsed Mermaid labels, is treated as + * occupied label content by canvas merging, and is stripped at + * serialization time. Invariant: a WIDE_PAD cell always sits immediately + * right of its lead cell; canvas writes keep the pair atomic. + */ +export const WIDE_PAD = '\u0000' + +const graphemeSegmenter = new Intl.Segmenter() + +/** + * Display width of a string in terminal columns, summed over grapheme + * clusters so it always equals `toCells(text).length`. ASCII-only strings + * take a fast path. + */ +export function displayWidth(text: string): number { + let ascii = true + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 0x7e) { + ascii = false + break + } + } + if (ascii) return text.length + + let width = 0 + for (const seg of graphemeSegmenter.segment(text)) { + width += Bun.stringWidth(seg.segment) >= 2 ? 2 : 1 + } + return width +} + +/** + * Expand a string into ASCII-canvas cells: each 2-column grapheme cluster + * is stored whole in one cell and followed by WIDE_PAD, so that + * `cells.length === displayWidth(text)`. Per-character placement loops can + * iterate the result with plain cell offsets. + */ +export function toCells(text: string): string[] { + const cells: string[] = [] + for (const seg of graphemeSegmenter.segment(text)) { + cells.push(seg.segment) + if (Bun.stringWidth(seg.segment) >= 2) { + cells.push(WIDE_PAD) + } + } + return cells +} diff --git a/packages/utils/src/vendor/mermaid-ascii/types.ts b/packages/utils/src/vendor/mermaid-ascii/types.ts new file mode 100644 index 000000000..4e424621c --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/types.ts @@ -0,0 +1,164 @@ +// ============================================================================ +// Parsed graph — logical structure extracted from Mermaid text +// ============================================================================ + +export interface MermaidGraph { + direction: Direction + nodes: Map + edges: MermaidEdge[] + subgraphs: MermaidSubgraph[] + classDefs: Map> + /** Maps node IDs to their class names (from `class X className` or `:::className` shorthand) */ + classAssignments: Map + /** Maps node IDs to inline styles (from `style X fill:#f00,stroke:#333`) */ + nodeStyles: Map> + /** Maps edge indices (or 'default') to inline styles from `linkStyle` directives */ + linkStyles: Map> +} + +export type Direction = 'TD' | 'TB' | 'LR' | 'BT' | 'RL' + +export interface MermaidNode { + id: string + label: string + shape: NodeShape +} + +export type NodeShape = + | 'rectangle' + | 'rounded' + | 'diamond' + | 'stadium' + | 'circle' + // Batch 1 additions + | 'subroutine' // [[text]] — double-bordered rectangle + | 'doublecircle' // (((text))) — concentric circles + | 'hexagon' // {{text}} — six-sided polygon + // Batch 2 additions + | 'cylinder' // [(text)] — database cylinder + | 'asymmetric' // >text] — flag/banner shape + | 'trapezoid' // [/text\] — wider bottom + | 'trapezoid-alt' // [\text/] — wider top + // Batch 3 state diagram pseudostates + | 'state-start' // filled circle (start pseudostate) + | 'state-end' // bullseye circle (end pseudostate) + +export interface MermaidEdge { + source: string + target: string + label?: string + style: EdgeStyle + /** Whether to render an arrowhead at the start (source end) of the edge */ + hasArrowStart: boolean + /** Whether to render an arrowhead at the end (target end) of the edge */ + hasArrowEnd: boolean +} + +export type EdgeStyle = 'solid' | 'dotted' | 'thick' + +export interface MermaidSubgraph { + id: string + label: string + nodeIds: string[] + children: MermaidSubgraph[] + /** Optional direction override for this subgraph's internal layout */ + direction?: Direction +} + +// ============================================================================ +// Positioned graph — after ELK layout, ready for SVG rendering +// ============================================================================ + +export interface PositionedGraph { + width: number + height: number + nodes: PositionedNode[] + edges: PositionedEdge[] + groups: PositionedGroup[] +} + +export interface PositionedNode { + id: string + label: string + shape: NodeShape + x: number + y: number + width: number + height: number + /** Inline styles resolved from classDef + explicit `style` statements — override theme defaults */ + inlineStyle?: Record +} + +export interface PositionedEdge { + source: string + target: string + label?: string + style: EdgeStyle + hasArrowStart: boolean + hasArrowEnd: boolean + /** Full path including bends — array of {x, y} points */ + points: Point[] + /** Layout-computed label center position (avoids label-label collisions) */ + labelPosition?: Point + /** Inline styles resolved from `linkStyle` directives — override theme defaults */ + inlineStyle?: Record +} + +export interface Point { + x: number + y: number +} + +export interface PositionedGroup { + id: string + label: string + x: number + y: number + width: number + height: number + children: PositionedGroup[] +} + +// ============================================================================ +// Render options — user-facing configuration +// +// Color theming uses CSS custom properties: --bg and --fg are required, +// optional enrichment variables (--line, --accent, --muted, --surface, +// --border) add richer color from Shiki themes or custom palettes. +// See src/theme.ts for the full variable system. +// ============================================================================ + +export interface RenderOptions { + /** Background color → CSS variable --bg. Default: '#FFFFFF' */ + bg?: string + /** Foreground / primary text color → CSS variable --fg. Default: '#27272A' */ + fg?: string + + // -- Optional enrichment colors (fall back to color-mix from bg/fg) -- + + /** Edge/connector color → CSS variable --line */ + line?: string + /** Arrow heads, highlights → CSS variable --accent */ + accent?: string + /** Secondary text, edge labels → CSS variable --muted */ + muted?: string + /** Node/box fill tint → CSS variable --surface */ + surface?: string + /** Node/group stroke color → CSS variable --border */ + border?: string + + /** Font family for all text. Default: 'Inter' */ + font?: string + /** Canvas padding in px. Default: 40 */ + padding?: number + /** Horizontal spacing between sibling nodes. Default: 24 */ + nodeSpacing?: number + /** Vertical spacing between layers. Default: 40 */ + layerSpacing?: number + /** Spacing between disconnected components. Default: nodeSpacing (24) */ + componentSpacing?: number + /** Render with transparent background (no background style on SVG). Default: false */ + transparent?: boolean + /** Enable hover tooltips on chart data points (xychart only). Default: false */ + interactive?: boolean +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts new file mode 100644 index 000000000..7cc053d04 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/colors.ts @@ -0,0 +1,140 @@ +// ============================================================================ +// XY Chart — shared color palette +// +// Generates monochromatic shades from the theme accent color. +// Series 0 = accent (or blue fallback). Series 1+ are darker/lighter +// shades of the same hue with subtle hue drift to stay in the same +// color family (like navy ↔ cyan from blue). +// +// Used by both the SVG and ASCII renderers. +// ============================================================================ + +/** Default accent for charts when the theme doesn't provide one. */ +export const CHART_ACCENT_FALLBACK = '#3b82f6' // blue-500 + +// --------------------------------------------------------------------------- +// HSL ↔ Hex conversion +// --------------------------------------------------------------------------- + +function hexToHsl(hex: string): [number, number, number] { + const h = hex.replace('#', '') + const ri = parseInt(h.substring(0, 2), 16) / 255 + const gi = parseInt(h.substring(2, 4), 16) / 255 + const bi = parseInt(h.substring(4, 6), 16) / 255 + + const max = Math.max(ri, gi, bi) + const min = Math.min(ri, gi, bi) + const l = (max + min) / 2 + + if (max === min) return [0, 0, l * 100] + + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + let hue: number + if (max === ri) hue = ((gi - bi) / d + (gi < bi ? 6 : 0)) / 6 + else if (max === gi) hue = ((bi - ri) / d + 2) / 6 + else hue = ((ri - gi) / d + 4) / 6 + + return [hue * 360, s * 100, l * 100] +} + +function hslToHex(h: number, s: number, l: number): string { + const si = s / 100 + const li = l / 100 + + const c = (1 - Math.abs(2 * li - 1)) * si + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = li - c / 2 + + let r: number, g: number, b: number + if (h < 60) { r = c; g = x; b = 0 } + else if (h < 120) { r = x; g = c; b = 0 } + else if (h < 180) { r = 0; g = c; b = x } + else if (h < 240) { r = 0; g = x; b = c } + else if (h < 300) { r = x; g = 0; b = c } + else { r = c; g = 0; b = x } + + const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +// --------------------------------------------------------------------------- +// Hex ↔ RGB conversion +// --------------------------------------------------------------------------- + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', '') + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + ] +} + +function rgbToHex(r: number, g: number, b: number): string { + const toHex = (v: number) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Check whether a string is a valid 6-digit hex color (e.g. "#3b82f6"). */ +export function isValidHex(color: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(color) +} + +/** + * Detect whether a background color is dark (lightness < 50%). + */ +export function isDarkBackground(bgHex: string): boolean { + return hexToHsl(bgHex)[2] < 50 +} + +/** + * Mix two hex colors in RGB space. + * `ratio` controls how much of `fgHex` shows: 0 = pure bg, 1 = pure fg. + * Equivalent to alpha-compositing fg over bg at the given opacity. + */ +export function mixHexColors(bgHex: string, fgHex: string, ratio: number): string { + const [br, bg, bb] = hexToRgb(bgHex) + const [fr, fg, fb] = hexToRgb(fgHex) + const inv = 1 - ratio + return rgbToHex(br * inv + fr * ratio, bg * inv + fg * ratio, bb * inv + fb * ratio) +} + +/** + * Get the hex color for a series index. + * Index 0 returns the accent color as-is. + * Index 1+ alternate between darker and lighter shades of the same hue + * with subtle hue drift (±8-12° per tier) to stay in the same family. + * + * When `bgColor` is provided, shade direction adapts to the background: + * - Light bg: odd = darker, even = lighter (default) + * - Dark bg: odd = lighter, even = darker (so shades stay visible) + */ +export function getSeriesColor(index: number, accentColor: string, bgColor?: string): string { + if (index === 0) return accentColor + // Fall back to defaults when inputs aren't valid hex (e.g. CSS variable refs like "var(--accent)") + const safeAccent = isValidHex(accentColor) ? accentColor : CHART_ACCENT_FALLBACK + const safeBg = bgColor && isValidHex(bgColor) ? bgColor : undefined + const [h, s] = hexToHsl(safeAccent) + const chartS = Math.max(55, Math.min(85, s)) + + const tier = Math.ceil(index / 2) + const oddIndex = index % 2 === 1 + + // On dark backgrounds, flip: odd = lighter, even = darker + const dark = safeBg && isDarkBackground(safeBg) ? !oddIndex : oddIndex + const l = dark + ? Math.max(25, 48 - tier * 13) + : Math.min(78, 55 + tier * 11) + + // Subtle hue drift: darker shades shift slightly negative, lighter shift positive + const hShift = (dark ? -8 : 12) * tier + const newH = ((h + hShift) % 360 + 360) % 360 + + return hslToHex(newH, chartS, l) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts new file mode 100644 index 000000000..29f8595dc --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/parser.ts @@ -0,0 +1,115 @@ +import type { XYChart, XYAxis, XYChartSeries } from './types' + +// ============================================================================ +// XY Chart parser +// +// Parses Mermaid xychart-beta syntax into a typed XYChart structure. +// +// Supported directives: +// xychart-beta [horizontal] +// title "Chart Title" +// x-axis [label1, label2, ...] — categorical +// x-axis min --> max — numeric range +// x-axis "Axis Title" [label1, ...] — with title +// x-axis "Axis Title" min --> max — with title +// y-axis (same patterns) +// bar [val1, val2, ...] +// line [val1, val2, ...] +// ============================================================================ + +/** + * Parse a Mermaid xychart-beta diagram from preprocessed lines. + * Lines should already be trimmed and comment-stripped. + */ +export function parseXYChart(lines: string[]): XYChart { + const xAxis: XYAxis = {} + const yAxis: XYAxis = {} + const series: XYChartSeries[] = [] + let title: string | undefined + let horizontal = false + + for (const line of lines) { + // Header line — detect horizontal + if (/^xychart(-beta)?\b/i.test(line)) { + if (/\bhorizontal\b/i.test(line)) horizontal = true + continue + } + + // Title + const titleMatch = line.match(/^title\s+"([^"]+)"/) + if (titleMatch) { + title = titleMatch[1] + continue + } + + // x-axis with categories: x-axis "Title" [a, b, c] or x-axis [a, b, c] + const xCatMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/) + if (xCatMatch) { + if (xCatMatch[1]) xAxis.title = xCatMatch[1] + xAxis.categories = xCatMatch[2]!.split(',').map(s => s.trim()) + continue + } + + // x-axis with range: x-axis "Title" min --> max or x-axis min --> max + const xRangeMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/) + if (xRangeMatch) { + if (xRangeMatch[1]) xAxis.title = xRangeMatch[1] + xAxis.range = { min: parseFloat(xRangeMatch[2]!), max: parseFloat(xRangeMatch[3]!) } + continue + } + + // y-axis with range: y-axis "Title" min --> max or y-axis min --> max + const yRangeMatch = line.match(/^y-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/) + if (yRangeMatch) { + if (yRangeMatch[1]) yAxis.title = yRangeMatch[1] + yAxis.range = { min: parseFloat(yRangeMatch[2]!), max: parseFloat(yRangeMatch[3]!) } + continue + } + + // y-axis with just title (no range) + const yTitleOnly = line.match(/^y-axis\s+"([^"]+)"\s*$/) + if (yTitleOnly) { + yAxis.title = yTitleOnly[1] + continue + } + + // bar [...] + const barMatch = line.match(/^bar\s+\[([^\]]+)\]/) + if (barMatch) { + series.push({ type: 'bar', data: parseNumericArray(barMatch[1]!) }) + continue + } + + // line [...] + const lineMatch = line.match(/^line\s+\[([^\]]+)\]/) + if (lineMatch) { + series.push({ type: 'line', data: parseNumericArray(lineMatch[1]!) }) + continue + } + } + + // Auto-derive y-axis range from data if not specified + if (!yAxis.range && series.length > 0) { + const allValues = series.flatMap(s => s.data) + let min = Math.min(...allValues) + let max = Math.max(...allValues) + const span = max - min || 1 + // Add 10% padding + min = min - span * 0.1 + max = max + span * 0.1 + // Floor to 0 if all values are positive and min is close to 0 + if (min > 0 && min < span * 0.5) min = 0 + yAxis.range = { min, max } + } + + // Fallback y-axis range + if (!yAxis.range) { + yAxis.range = { min: 0, max: 100 } + } + + return { title, horizontal, xAxis, yAxis, series } +} + +function parseNumericArray(str: string): number[] { + return str.split(',').map(s => parseFloat(s.trim())) +} diff --git a/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts b/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts new file mode 100644 index 000000000..920c98132 --- /dev/null +++ b/packages/utils/src/vendor/mermaid-ascii/xychart/types.ts @@ -0,0 +1,150 @@ +// ============================================================================ +// XY Chart types +// +// Models the parsed and positioned representations of a Mermaid xychart-beta +// diagram. Supports bar charts, line charts, and combinations with categorical +// or numeric x-axes. +// ============================================================================ + +/** Parsed XY chart — logical structure from mermaid text */ +export interface XYChart { + /** Optional chart title */ + title?: string + /** Chart orientation: vertical (default) or horizontal */ + horizontal: boolean + /** X-axis configuration */ + xAxis: XYAxis + /** Y-axis configuration */ + yAxis: XYAxis + /** Data series (bar and/or line) */ + series: XYChartSeries[] +} + +/** Axis configuration — categorical (labels) or numeric (range) */ +export interface XYAxis { + /** Optional axis title/label */ + title?: string + /** Categorical labels (e.g., ["jan", "feb", "mar"]) — mutually exclusive with range */ + categories?: string[] + /** Numeric range — mutually exclusive with categories */ + range?: { min: number; max: number } +} + +/** A single data series (bar or line) */ +export interface XYChartSeries { + /** Series type */ + type: 'bar' | 'line' + /** Data values — one per category, or evenly spaced across numeric range */ + data: number[] +} + +// ============================================================================ +// Positioned XY chart — ready for SVG rendering +// ============================================================================ + +export interface PositionedXYChart { + width: number + height: number + /** Whether this is a horizontal (rotated) chart */ + horizontal?: boolean + /** Title text and position (if present) */ + title?: PositionedTitle + /** Positioned x-axis with tick marks and labels */ + xAxis: PositionedAxis + /** Positioned y-axis with tick marks and labels */ + yAxis: PositionedAxis + /** The plot area bounds (inside axes) */ + plotArea: PlotArea + /** Positioned bar groups */ + bars: PositionedBar[] + /** Positioned line polylines */ + lines: PositionedLine[] + /** Horizontal grid lines for readability */ + gridLines: GridLine[] + /** Legend items (shown when multiple series) */ + legend: LegendItem[] +} + +export interface LegendItem { + /** Display label */ + label: string + /** Position of the swatch/icon */ + x: number + y: number + /** Series type determines swatch shape (rect for bar, line+dot for line) */ + type: 'bar' | 'line' + /** Series index within its type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series (for unified color assignment) */ + colorIndex: number +} + +export interface PositionedTitle { + text: string + x: number + y: number +} + +export interface PositionedAxis { + /** Optional axis title text and position */ + title?: { text: string; x: number; y: number; rotate?: number } + /** Tick positions along the axis */ + ticks: AxisTick[] + /** Axis line: start and end coordinates */ + line: { x1: number; y1: number; x2: number; y2: number } +} + +export interface AxisTick { + /** Label text for this tick */ + label: string + /** Position of the tick mark on the axis */ + x: number + y: number + /** End of the tick mark (short perpendicular line) */ + tx: number + ty: number + /** Label anchor position */ + labelX: number + labelY: number + /** Text anchor for label */ + textAnchor: 'start' | 'middle' | 'end' +} + +export interface PlotArea { + x: number + y: number + width: number + height: number +} + +export interface PositionedBar { + /** Bar rectangle in SVG coordinates */ + x: number + y: number + width: number + height: number + /** Original data value */ + value: number + /** Category label for this bar (e.g. "Jan") */ + label?: string + /** Series index within bar type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface PositionedLine { + /** Polyline points */ + points: Array<{ x: number; y: number; value: number; label?: string }> + /** Series index within line type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface GridLine { + x1: number + y1: number + x2: number + y2: number +} diff --git a/packages/utils/test/mermaid/class-arrows.test.ts b/packages/utils/test/mermaid/class-arrows.test.ts new file mode 100644 index 000000000..b979b6e5b --- /dev/null +++ b/packages/utils/test/mermaid/class-arrows.test.ts @@ -0,0 +1,413 @@ +/** + * Comprehensive tests for class diagram arrow directions. + * + * Ensures all relationship types have correctly oriented arrows: + * - Inheritance/Realization: hollow triangles point toward parent/interface + * - Association/Dependency: filled arrows point from source to target + * - Composition/Aggregation: diamonds are omnidirectional + */ + +import { describe, expect, test } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("Class Diagram Arrow Directions", () => { + // ============================================================================ + // INHERITANCE (<|--) + // ============================================================================ + + describe("Inheritance (<|--)", () => { + test("parent above child - triangle points UP toward parent", () => { + const diagram = `classDiagram + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + // Should contain upward triangle + expect(result).toContain("△"); + expect(result).not.toContain("▽"); + + // Parent should be above child + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + expect(animalLine).toBeLessThan(dogLine); + }); + + test("multiple inheritance creates separate arrows", () => { + const diagram = `classDiagram + Animal <|-- Dog + Animal <|-- Cat + Dog <|-- Puppy`; + const result = renderMermaidAscii(diagram); + + // Animal should be at top, then Dog/Cat, then Puppy + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + const catLine = lines.findIndex(l => l.includes("Cat")); + const puppyLine = lines.findIndex(l => l.includes("Puppy")); + + expect(animalLine).toBeLessThan(dogLine); + expect(animalLine).toBeLessThan(catLine); + expect(dogLine).toBeLessThan(puppyLine); + }); + + test("multi-level inheritance - all triangles point UP", () => { + const diagram = `classDiagram + Animal <|-- Mammal + Mammal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + // Verify ordering: Animal > Mammal > Dog (top to bottom) + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const mammalLine = lines.findIndex(l => l.includes("Mammal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + + expect(animalLine).toBeLessThan(mammalLine); + expect(mammalLine).toBeLessThan(dogLine); + + // All triangles should point up + expect(result.match(/△/g)?.length).toBe(2); + }); + + test("multiple inheritance from same parent", () => { + const diagram = `classDiagram + Animal <|-- Dog + Animal <|-- Cat`; + const result = renderMermaidAscii(diagram); + + // Animal should be above both children + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + const catLine = lines.findIndex(l => l.includes("Cat")); + + expect(animalLine).toBeLessThan(dogLine); + expect(animalLine).toBeLessThan(catLine); + + // Should have at least one triangle pointing up (may merge visually) + expect(result).toContain("△"); + }); + + test("ASCII mode uses ^ for upward triangle", () => { + const diagram = `classDiagram + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("^"); + expect(result).not.toContain("v"); + }); + }); + + // ============================================================================ + // ASSOCIATION (-->) + // ============================================================================ + + describe("Association (-->)", () => { + test("source above target - arrow points DOWN", () => { + const diagram = `classDiagram + Person --> Address`; + const result = renderMermaidAscii(diagram); + + // Should contain downward arrow + expect(result).toContain("▼"); + expect(result).not.toContain("▲"); + + // Person should be above Address + const lines = result.split("\n"); + const personLine = lines.findIndex(l => l.includes("Person")); + const addressLine = lines.findIndex(l => l.includes("Address")); + expect(personLine).toBeLessThan(addressLine); + }); + + test("multiple associations from same source", () => { + const diagram = `classDiagram + Person --> Address + Person --> Phone`; + const result = renderMermaidAscii(diagram); + + // Person should be above both targets + const lines = result.split("\n"); + const personLine = lines.findIndex(l => l.includes("Person")); + const addressLine = lines.findIndex(l => l.includes("Address")); + const phoneLine = lines.findIndex(l => l.includes("Phone")); + + expect(personLine).toBeLessThan(addressLine); + expect(personLine).toBeLessThan(phoneLine); + }); + + test("chain of associations", () => { + const diagram = `classDiagram + A --> B + B --> C`; + const result = renderMermaidAscii(diagram); + + // A > B > C ordering + const lines = result.split("\n"); + const aLine = lines.findIndex(l => l.includes("│ A │")); + const bLine = lines.findIndex(l => l.includes("│ B │")); + const cLine = lines.findIndex(l => l.includes("│ C │")); + + expect(aLine).toBeLessThan(bLine); + expect(bLine).toBeLessThan(cLine); + + // Both arrows point down + expect(result.match(/▼/g)?.length).toBe(2); + }); + + test("ASCII mode uses v for downward arrow", () => { + const diagram = `classDiagram + Person --> Address`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("v"); + expect(result).not.toContain("^"); + }); + }); + + // ============================================================================ + // DEPENDENCY (..>) + // ============================================================================ + + describe("Dependency (..>)", () => { + test("source above target - arrow points DOWN", () => { + const diagram = `classDiagram + Client ..> Server`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("▼"); + expect(result).not.toContain("▲"); + + const lines = result.split("\n"); + const clientLine = lines.findIndex(l => l.includes("Client")); + const serverLine = lines.findIndex(l => l.includes("Server")); + expect(clientLine).toBeLessThan(serverLine); + }); + + test("multiple dependencies", () => { + const diagram = `classDiagram + Client ..> Server + Client ..> Database`; + const result = renderMermaidAscii(diagram); + + const lines = result.split("\n"); + const clientLine = lines.findIndex(l => l.includes("Client")); + const serverLine = lines.findIndex(l => l.includes("Server")); + const dbLine = lines.findIndex(l => l.includes("Database")); + + expect(clientLine).toBeLessThan(serverLine); + expect(clientLine).toBeLessThan(dbLine); + }); + + test("ASCII mode uses v for downward arrow", () => { + const diagram = `classDiagram + Client ..> Server`; + const result = renderMermaidAscii(diagram, { useAscii: true }); + + expect(result).toContain("v"); + }); + }); + + // ============================================================================ + // REALIZATION (..|>) + // ============================================================================ + + describe("Realization (..|>)", () => { + test("interface above implementation - triangle points UP", () => { + // Circle ..|> Shape means "Circle implements Shape" + // Shape (interface/parent) should be placed ABOVE Circle (implementation/child) + const diagram = `classDiagram + Circle ..|> Shape`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) should be above Circle (implementation) + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + expect(shapeLine).toBeLessThan(circleLine); + expect(result).toContain("△"); + }); + + test("realization with <|.. syntax (marker at from end)", () => { + // Shape <|.. Circle means "Circle implements Shape" (same as Circle ..|> Shape) + const diagram = `classDiagram + Shape <|.. Circle`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) should be above Circle (implementation) + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + expect(shapeLine).toBeLessThan(circleLine); + expect(result).toContain("△"); + }); + + test("multiple implementations", () => { + // Circle and Square both implement Shape + const diagram = `classDiagram + Circle ..|> Shape + Square ..|> Shape`; + const result = renderMermaidAscii(diagram); + + // Shape (interface) above both implementations + const lines = result.split("\n"); + const shapeLine = lines.findIndex(l => l.includes("Shape")); + const circleLine = lines.findIndex(l => l.includes("Circle")); + const squareLine = lines.findIndex(l => l.includes("Square")); + + expect(shapeLine).toBeLessThan(circleLine); + expect(shapeLine).toBeLessThan(squareLine); + // At least one triangle (may merge visually if same connection point) + expect(result).toContain("△"); + }); + }); + + // ============================================================================ + // COMPOSITION & AGGREGATION (omnidirectional diamonds) + // ============================================================================ + + describe("Composition (*--) and Aggregation (o--)", () => { + test("composition - diamond is omnidirectional", () => { + const diagram = `classDiagram + Car *-- Engine`; + const result = renderMermaidAscii(diagram); + + // Should contain filled diamond + expect(result).toContain("◆"); + }); + + test("aggregation - hollow diamond is omnidirectional", () => { + const diagram = `classDiagram + Team o-- Player`; + const result = renderMermaidAscii(diagram); + + // Should contain hollow diamond + expect(result).toContain("◇"); + }); + }); + + // ============================================================================ + // MIXED SCENARIOS + // ============================================================================ + + describe("Mixed Relationship Scenarios", () => { + test("all 6 relationship types together", () => { + const diagram = `classDiagram + A <|-- B : inheritance + C *-- D : composition + E o-- F : aggregation + G --> H : association + I ..> J : dependency + K ..|> L : realization`; + const result = renderMermaidAscii(diagram); + + // Upward triangles for inheritance and realization + expect(result.match(/△/g)?.length).toBe(2); + + // Downward arrows for association and dependency + expect(result.match(/▼/g)?.length).toBe(2); + + // Diamonds for composition and aggregation + expect(result).toContain("◆"); + expect(result).toContain("◇"); + }); + + test("inheritance with association - different arrow directions", () => { + const diagram = `classDiagram + Animal <|-- Dog + Dog --> Food`; + const result = renderMermaidAscii(diagram); + + // Should have both up triangle (inheritance) and down arrow (association) + expect(result).toContain("△"); + expect(result).toContain("▼"); + }); + + test("circular reference creates valid layout", () => { + const diagram = `classDiagram + A --> B + B --> C + C ..> A`; + const result = renderMermaidAscii(diagram); + + // Cycles may create mixed arrow directions (up and down) to avoid overlaps + // Just verify arrows are present and classes are rendered + const hasUpArrow = result.includes("▲"); + const hasDownArrow = result.includes("▼"); + expect(hasUpArrow || hasDownArrow).toBe(true); + expect(result).toContain("│ A │"); + expect(result).toContain("│ B │"); + expect(result).toContain("│ C │"); + }); + }); + + // ============================================================================ + // ASCII vs UNICODE CONSISTENCY + // ============================================================================ + + describe("ASCII and Unicode Mode Consistency", () => { + test("same diagram produces consistent layouts in both modes", () => { + const diagram = `classDiagram + Animal <|-- Dog + Person --> Address`; + + const unicode = renderMermaidAscii(diagram); + const ascii = renderMermaidAscii(diagram, { useAscii: true }); + + // Both should have same node ordering + const unicodeLines = unicode.split("\n"); + const asciiLines = ascii.split("\n"); + + const uAnimal = unicodeLines.findIndex(l => l.includes("Animal")); + const uDog = unicodeLines.findIndex(l => l.includes("Dog")); + const aPerson = asciiLines.findIndex(l => l.includes("Person")); + const aAddress = asciiLines.findIndex(l => l.includes("Address")); + + expect(uAnimal).toBeLessThan(uDog); + expect(aPerson).toBeLessThan(aAddress); + + // Unicode has △ and ▼, ASCII has ^ and v + expect(unicode).toContain("△"); + expect(unicode).toContain("▼"); + expect(ascii).toContain("^"); + expect(ascii).toContain("v"); + }); + }); + + // ============================================================================ + // EDGE CASES + // ============================================================================ + + describe("Edge Cases", () => { + test("single inheritance relationship", () => { + const diagram = `classDiagram + A <|-- B`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("△"); + const lines = result.split("\n"); + const aLine = lines.findIndex(l => l.includes("│ A │")); + const bLine = lines.findIndex(l => l.includes("│ B │")); + expect(aLine).toBeLessThan(bLine); + }); + + test("classes with members maintain arrow directions", () => { + const diagram = `classDiagram + class Animal { + +String name + +eat() void + } + class Dog { + +bark() void + } + Animal <|-- Dog`; + const result = renderMermaidAscii(diagram); + + expect(result).toContain("△"); + const lines = result.split("\n"); + const animalLine = lines.findIndex(l => l.includes("Animal")); + const dogLine = lines.findIndex(l => l.includes("Dog")); + expect(animalLine).toBeLessThan(dogLine); + }); + }); +}); diff --git a/packages/utils/test/mermaid/edge-styles.test.ts b/packages/utils/test/mermaid/edge-styles.test.ts new file mode 100644 index 000000000..d86f9ea0a --- /dev/null +++ b/packages/utils/test/mermaid/edge-styles.test.ts @@ -0,0 +1,149 @@ +// ============================================================================ +// ASCII edge style tests — dotted and thick line rendering +// ============================================================================ + +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("ASCII edge styles", () => { + describe("solid edges (default)", () => { + it("renders solid edges with ─ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A --> B + `); + expect(result).toContain("─"); + expect(result).not.toContain("┄"); + expect(result).not.toContain("━"); + }); + + it("renders solid edges with - in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A --> B + `, + { useAscii: true }, + ); + expect(result).toContain("-"); + }); + }); + + describe("dotted edges (-.->)", () => { + it("renders dotted edges with ┄ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A -.-> B + `); + // Should contain dotted horizontal line character + expect(result).toContain("┄"); + }); + + it("renders dotted edges with . in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A -.-> B + `, + { useAscii: true }, + ); + // Should contain dots for dotted lines + expect(result).toContain("."); + }); + + it("renders dotted vertical edges with ┆ in unicode mode", () => { + const result = renderMermaidAscii(` + graph TD + A -.-> B + `); + // Should contain dotted vertical line character + expect(result).toContain("┆"); + }); + + it("renders dotted vertical edges with : in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph TD + A -.-> B + `, + { useAscii: true }, + ); + // Should contain colons for dotted vertical lines + expect(result).toContain(":"); + }); + + it("renders dotted edges with labels", () => { + const result = renderMermaidAscii(` + graph LR + A -.->|optional| B + `); + expect(result).toContain("┄"); + expect(result).toContain("optional"); + }); + }); + + describe("thick edges (==>)", () => { + it("renders thick edges with ━ in unicode mode", () => { + const result = renderMermaidAscii(` + graph LR + A ==> B + `); + // Should contain thick horizontal line character + expect(result).toContain("━"); + }); + + it("renders thick edges with = in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A ==> B + `, + { useAscii: true }, + ); + // Should contain equals for thick lines + expect(result).toContain("="); + }); + + it("renders thick vertical edges with ┃ in unicode mode", () => { + const result = renderMermaidAscii(` + graph TD + A ==> B + `); + // Should contain thick vertical line character + expect(result).toContain("┃"); + }); + }); + + describe("mixed edge styles", () => { + it("renders different styles in the same diagram", () => { + const result = renderMermaidAscii(` + graph LR + A --> B + B -.-> C + C ==> D + `); + // Should have all three line types + expect(result).toContain("─"); // solid + expect(result).toContain("┄"); // dotted + expect(result).toContain("━"); // thick + }); + + it("renders mixed styles in ascii mode", () => { + const result = renderMermaidAscii( + ` + graph LR + A --> B + B -.-> C + C ==> D + `, + { useAscii: true }, + ); + // Note: ASCII mode uses - for solid, . for dotted, = for thick + // We just check that the diagram renders without error + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("C"); + expect(result).toContain("D"); + }); + }); +}); diff --git a/packages/utils/test/mermaid/formatting.test.ts b/packages/utils/test/mermaid/formatting.test.ts new file mode 100644 index 000000000..e5765a4a9 --- /dev/null +++ b/packages/utils/test/mermaid/formatting.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/mermaid-ascii"; + +// The vendored renderer is ASCII-only, so inline formatting (HTML tags and +// markdown emphasis) is reduced to plain text rather than preserved — otherwise +// the raw tags/markers would print inside the node box. Exercised through the +// public `@oh-my-pi/pi-utils` wrapper so the dependency-removal path stays covered. +describe("mermaid ASCII inline-formatting stripping", () => { + const render = (label: string): string => renderMermaidAscii(`flowchart TD\n A[${label}]`, { colorMode: "none" }); + + it("strips markdown bold/italic/strikethrough markers, keeping the text", () => { + const out = render("**bold** *em* ~~gone~~"); + expect(out).toContain("bold"); + expect(out).toContain("em"); + expect(out).toContain("gone"); + expect(out).not.toContain("**"); + expect(out).not.toContain("~~"); + expect(out).not.toContain("*em*"); + }); + + it("strips inline HTML formatting tags, keeping the text", () => { + const out = render("strong and slanted"); + expect(out).toContain("strong"); + expect(out).toContain("slanted"); + expect(out).not.toContain(""); + expect(out).not.toContain(""); + expect(out).not.toContain(""); + }); +}); diff --git a/packages/utils/test/mermaid/golden.test.ts b/packages/utils/test/mermaid/golden.test.ts new file mode 100644 index 000000000..5729217a1 --- /dev/null +++ b/packages/utils/test/mermaid/golden.test.ts @@ -0,0 +1,253 @@ +/** + * Golden-file tests for the ASCII/Unicode renderer. + * + * Ported from AlexanderGrooff/mermaid-ascii cmd/graph_test.go. + * Each .txt file contains mermaid input above a `---` separator + * and the expected ASCII/Unicode output below it. + * + * Test data: 44 ASCII files + 22 Unicode files = 66 total. + */ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; +import { DIAGONAL_CHARS, hasDiagonalLines } from "../../src/vendor/mermaid-ascii/ascii/validate"; + +// ============================================================================ +// Test case parser — matches Go's testutil.ReadTestCase format +// ============================================================================ + +interface TestCase { + mermaid: string; + expected: string; + paddingX: number; + paddingY: number; +} + +/** + * Parse a golden test file into its components. + * Format: + * [paddingX=N] (optional) + * [paddingY=N] (optional) + * + * --- + * + */ +function parseTestCase(content: string): TestCase { + const tc: TestCase = { mermaid: "", expected: "", paddingX: 5, paddingY: 5 }; + const lines = content.split("\n"); + const paddingRegex = /^(?:padding([xy]))\s*=\s*(\d+)\s*$/i; + + let inMermaid = true; + let mermaidStarted = false; + const mermaidLines: string[] = []; + const expectedLines: string[] = []; + + for (const line of lines) { + if (line === "---") { + inMermaid = false; + continue; + } + + if (inMermaid) { + const trimmed = line.trim(); + + // Before mermaid code starts, parse padding directives and skip blanks + if (!mermaidStarted) { + if (trimmed === "") continue; + const match = trimmed.match(paddingRegex); + if (match) { + const value = parseInt(match[2]!, 10); + if (match[1]!.toLowerCase() === "x") { + tc.paddingX = value; + } else { + tc.paddingY = value; + } + continue; + } + } + + mermaidStarted = true; + mermaidLines.push(line); + } else { + expectedLines.push(line); + } + } + + tc.mermaid = `${mermaidLines.join("\n")}\n`; + + // Strip final trailing newline (matches Go's strings.TrimSuffix(expected, "\n")) + let expected = expectedLines.join("\n"); + if (expected.endsWith("\n")) { + expected = expected.slice(0, -1); + } + tc.expected = expected; + + return tc; +} + +// ============================================================================ +// Whitespace normalization — matches Go's testutil.NormalizeWhitespace +// ============================================================================ + +/** + * Normalize whitespace for comparison: + * - Trim trailing spaces from each line + * - Remove leading/trailing blank lines + */ +function normalizeWhitespace(s: string): string { + const lines = s.split("\n"); + const normalized = lines.map(l => l.trimEnd()); + + // Remove leading blank lines + while (normalized.length > 0 && normalized[0] === "") { + normalized.shift(); + } + // Remove trailing blank lines + while (normalized.length > 0 && normalized[normalized.length - 1] === "") { + normalized.pop(); + } + + return normalized.join("\n"); +} + +/** Replace spaces with middle dots for clearer diff output. */ +function visualizeWhitespace(s: string): string { + return s.replaceAll(" ", "·"); +} + +// ============================================================================ +// Test runner — dynamically loads all golden files from testdata directories +// ============================================================================ + +function runGoldenTests(dir: string, useAscii: boolean): void { + const files = readdirSync(dir) + .filter(f => f.endsWith(".txt")) + .sort(); + + for (const file of files) { + const testName = file.replace(".txt", ""); + + it(testName, () => { + const content = readFileSync(join(dir, file), "utf-8"); + const tc = parseTestCase(content); + + const actual = renderMermaidAscii(tc.mermaid, { + useAscii, + paddingX: tc.paddingX, + paddingY: tc.paddingY, + }); + + const normalizedExpected = normalizeWhitespace(tc.expected); + const normalizedActual = normalizeWhitespace(actual); + + if (normalizedExpected !== normalizedActual) { + const expectedVis = visualizeWhitespace(normalizedExpected); + const actualVis = visualizeWhitespace(normalizedActual); + expect(actualVis).toBe(expectedVis); + } + }); + } +} + +// ============================================================================ +// Test suites +// ============================================================================ + +const testdataDir = join(import.meta.dir, "testdata"); + +describe("ASCII rendering", () => { + runGoldenTests(join(testdataDir, "ascii"), true); +}); + +describe("Unicode rendering", () => { + runGoldenTests(join(testdataDir, "unicode"), false); +}); + +// ============================================================================ +// Config behavior tests — ported from Go's TestGraphUseAsciiConfig +// ============================================================================ + +describe("Config behavior", () => { + const mermaidInput = "graph LR\nA --> B"; + + it("ASCII and Unicode outputs should differ", () => { + const asciiOutput = renderMermaidAscii(mermaidInput, { useAscii: true }); + const unicodeOutput = renderMermaidAscii(mermaidInput, { useAscii: false }); + expect(asciiOutput).not.toBe(unicodeOutput); + }); + + it("ASCII output should not contain Unicode box-drawing characters", () => { + const output = renderMermaidAscii(mermaidInput, { useAscii: true }); + expect(output).not.toContain("┌"); + expect(output).not.toContain("─"); + expect(output).not.toContain("│"); + }); + + it("Unicode output should contain Unicode box-drawing characters", () => { + const output = renderMermaidAscii(mermaidInput, { useAscii: false }); + const hasUnicode = output.includes("┌") || output.includes("─") || output.includes("│"); + expect(hasUnicode).toBe(true); + }); +}); + +// ============================================================================ +// Diagonal validation — ensures all edges use orthogonal Manhattan routing +// ============================================================================ + +describe("Diagonal validation", () => { + const asciiDir = join(testdataDir, "ascii"); + const unicodeDir = join(testdataDir, "unicode"); + + it("ASCII output should never contain diagonal characters", () => { + // Test all ASCII golden files + const files = readdirSync(asciiDir).filter(f => f.endsWith(".txt")); + for (const file of files) { + const content = readFileSync(join(asciiDir, file), "utf-8"); + const { mermaid, paddingX, paddingY } = parseTestCase(content); + const output = renderMermaidAscii(mermaid, { + useAscii: true, + boxBorderPadding: paddingX, + paddingY: paddingY, + }); + + // Check for diagonal characters + for (const char of DIAGONAL_CHARS.ascii) { + expect(output).not.toContain(char); + } + } + }); + + it("Unicode output should never contain diagonal characters", () => { + // Test all Unicode golden files + const files = readdirSync(unicodeDir).filter(f => f.endsWith(".txt")); + for (const file of files) { + const content = readFileSync(join(unicodeDir, file), "utf-8"); + const { mermaid, paddingX, paddingY } = parseTestCase(content); + const output = renderMermaidAscii(mermaid, { + useAscii: false, + boxBorderPadding: paddingX, + paddingY: paddingY, + }); + + // Check for diagonal characters + for (const char of DIAGONAL_CHARS.unicode) { + expect(output).not.toContain(char); + } + } + }); + + it("hasDiagonalLines utility correctly detects diagonal characters", () => { + // Should detect ASCII diagonals + expect(hasDiagonalLines("A / B")).toBe(true); + expect(hasDiagonalLines("A \\ B")).toBe(true); + + // Should detect Unicode diagonals + expect(hasDiagonalLines("A ╱ B")).toBe(true); + expect(hasDiagonalLines("A ╲ B")).toBe(true); + + // Should not flag clean output + expect(hasDiagonalLines("┌───┐\n│ A │\n└───┘")).toBe(false); + expect(hasDiagonalLines("+---+\n| A |\n+---+")).toBe(false); + }); +}); diff --git a/packages/utils/test/mermaid/multiline.test.ts b/packages/utils/test/mermaid/multiline.test.ts new file mode 100644 index 000000000..3c26acf1a --- /dev/null +++ b/packages/utils/test/mermaid/multiline.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test"; +import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index"; + +describe("ASCII multi-line labels", () => { + describe("flowchart nodes", () => { + it("renders multi-line node labels", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1
          Line2]", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + // Lines should be on different rows + const lines = ascii.split("\n"); + const line1Row = lines.findIndex(l => l.includes("Line1")); + const line2Row = lines.findIndex(l => l.includes("Line2")); + expect(line2Row).toBeGreaterThan(line1Row); + }); + + it("handles 3+ line labels", () => { + const ascii = renderMermaidAscii("graph TD\n A[A
          B
          C]", { useAscii: false }); + expect(ascii).toContain("A"); + expect(ascii).toContain("B"); + expect(ascii).toContain("C"); + // Verify vertical ordering + const lines = ascii.split("\n"); + const aRow = lines.findIndex(l => l.includes("A") && !l.includes("─") && !l.includes("-")); + const bRow = lines.findIndex(l => l.includes("B")); + const cRow = lines.findIndex(l => l.includes("C")); + expect(bRow).toBeGreaterThan(aRow); + expect(cRow).toBeGreaterThan(bRow); + }); + + it("renders in ASCII mode (not Unicode)", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1
          Line2]", { useAscii: true }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + // Should use ASCII box characters + expect(ascii).toContain("+"); + expect(ascii).toContain("-"); + }); + }); + + describe("flowchart edge labels", () => { + it("renders multi-line edge labels", () => { + const ascii = renderMermaidAscii("graph TD\n A --> B\n A -->|Line1
          Line2| C", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + }); + }); + + describe("flowchart subgraph labels", () => { + it("renders multi-line subgraph labels", () => { + const ascii = renderMermaidAscii( + `graph TD + subgraph sg [Group
          Header] + A[Node] + end + `, + { useAscii: false }, + ); + expect(ascii).toContain("Group"); + expect(ascii).toContain("Header"); + }); + }); + + describe("sequence diagram", () => { + it("renders multi-line actor labels", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A as Actor
          One + A->>A: msg + `, + { useAscii: false }, + ); + expect(ascii).toContain("Actor"); + expect(ascii).toContain("One"); + }); + + it("renders multi-line message labels", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A + participant B + A->>B: Line1
          Line2 + `, + { useAscii: false }, + ); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line2"); + }); + + it("preserves existing note multi-line support", () => { + const ascii = renderMermaidAscii( + `sequenceDiagram + participant A + A->>A: self + Note over A: Note line 1
          Note line 2 + `, + { useAscii: false }, + ); + expect(ascii).toContain("Note line 1"); + expect(ascii).toContain("Note line 2"); + }); + }); + + describe("class diagram", () => { + it("renders multi-line class names", () => { + const ascii = renderMermaidAscii( + `classDiagram + class MyClass["Long
          Name"] + `, + { useAscii: false }, + ); + expect(ascii).toContain("Long"); + expect(ascii).toContain("Name"); + }); + + it("renders multi-line relationship labels", () => { + const ascii = renderMermaidAscii( + `classDiagram + A --> B : uses
          implements + `, + { useAscii: false }, + ); + expect(ascii).toContain("uses"); + expect(ascii).toContain("implements"); + }); + }); + + describe("ER diagram", () => { + it("renders multi-line entity names", () => { + const ascii = renderMermaidAscii( + `erDiagram + "Entity
          Name" { + string id + } + `, + { useAscii: false }, + ); + expect(ascii).toContain("Entity"); + expect(ascii).toContain("Name"); + }); + + it("renders multi-line relationship labels", () => { + const ascii = renderMermaidAscii( + `erDiagram + A ||--o{ B : "has
          many" + `, + { useAscii: false }, + ); + expect(ascii).toContain("has"); + expect(ascii).toContain("many"); + }); + }); + + describe("edge cases", () => { + it("handles empty lines from consecutive
          ", () => { + const ascii = renderMermaidAscii("graph TD\n A[Line1

          Line3]", { useAscii: false }); + expect(ascii).toContain("Line1"); + expect(ascii).toContain("Line3"); + }); + + it("handles single-line labels (no
          )", () => { + const ascii = renderMermaidAscii("graph TD\n A[SingleLine]", { useAscii: false }); + expect(ascii).toContain("SingleLine"); + }); + + it("handles very long lines", () => { + const long = "A".repeat(30); + const ascii = renderMermaidAscii(`graph TD\n A[${long}
          Short]`, { useAscii: false }); + expect(ascii).toContain(long); + expect(ascii).toContain("Short"); + }); + + it("handles mixed short and long lines", () => { + const ascii = renderMermaidAscii("graph TD\n A[Short
          VeryLongSecondLine
          Med]", { useAscii: false }); + expect(ascii).toContain("Short"); + expect(ascii).toContain("VeryLongSecondLine"); + expect(ascii).toContain("Med"); + }); + }); + + describe("multiline-utils functions", () => { + it("splitLines splits on newlines", () => { + // Test through the rendering pipeline + const ascii = renderMermaidAscii("graph TD\n A[One
          Two
          Three]", { useAscii: false }); + const lines = ascii.split("\n"); + // All three words should appear on separate lines + expect(lines.some(l => l.includes("One"))).toBe(true); + expect(lines.some(l => l.includes("Two"))).toBe(true); + expect(lines.some(l => l.includes("Three"))).toBe(true); + }); + + it("maxLineWidth uses longest line for box sizing", () => { + // Box should be wide enough for the longest line + const ascii = renderMermaidAscii("graph TD\n A[X
          LongLine
          Y]", { useAscii: false }); + // The box should contain LongLine without truncation + expect(ascii).toContain("LongLine"); + }); + }); +}); diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt new file mode 100644 index 000000000..3e42697ae --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs.txt @@ -0,0 +1,18 @@ +graph LR +A --> B & C +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | + | + | + | + | + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt new file mode 100644 index 000000000..b5b3ee281 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_lhs_and_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C & D +--- ++---+ +---+ +| | | | +| A |--+->| C | +| | | | | ++---+ | +---+ + | | + | | + +----+ + | | + | | ++---+ | +---+ +| | | | | +| B |--+->| D | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt new file mode 100644 index 000000000..257488a7f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C +--- ++---+ +---+ +| | | | +| A |---->| C | +| | | | ++---+ +---+ + ^ + | + | + | + | ++---+ | +| | | +| B |-------+ +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt new file mode 100644 index 000000000..3b0da2f96 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanin.txt @@ -0,0 +1,18 @@ +graph TD +A & B --> C +--- ++---+ +---+ +| | | | +| A | | B | +| | | | ++---+ +---+ + | | + | | + +---------+ + | + v ++---+ +| | +| C | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt new file mode 100644 index 000000000..8f2527d36 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_td_fanout.txt @@ -0,0 +1,18 @@ +graph TD +A --> B & C +--- ++---+ +| | +| A | +| | ++---+ + | + | + +---------+ + | | + v v ++---+ +---+ +| | | | +| B | | C | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt b/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt new file mode 100644 index 000000000..1fa800d9e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/ampersand_without_edge.txt @@ -0,0 +1,18 @@ +graph LR +A & B +--- ++---+ +| | +| A | +| | ++---+ + + + + + ++---+ +| | +| B | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt b/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt new file mode 100644 index 000000000..bb2b1443c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/back_reference_from_child.txt @@ -0,0 +1,10 @@ +graph LR +A --> B --> C --> A +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ + ^ | + +-------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt new file mode 100644 index 000000000..38078a9f2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_from_bottom.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +C --> D +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| D | +| | | | | | ++---+ +---+ +---+ + | | ^ + | | | + | | | + | | | + | v | + | +---+ | + | | | | + +------>| C |-------+ + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt new file mode 100644 index 000000000..1ebe82d7f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_from_top.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +D --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |--+->| D | +| | | | | | | ++---+ +---+ | +---+ + | | | + | | | + | +----+ + | | + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt b/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt new file mode 100644 index 000000000..2ae51627d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/backlink_with_short_y_padding.txt @@ -0,0 +1,20 @@ +paddingX=5 +paddingY=3 +graph LR +A --> B & C +B --> C & D +D --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |--+->| D | +| | | | | | | ++---+ +---+ | +---+ + | | | + | +----+ + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt b/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt new file mode 100644 index 000000000..2d2c85bb3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_all_relationships.txt @@ -0,0 +1,17 @@ +classDiagram + A <|-- B : inherits + C *-- D : owns + E o-- F : has + G --> H : uses + I ..> J : depends + K ..|> L : implements +--- ++---+ +---+ +---+ +---+ +---+ +---+ +| A | | C | | E | | G | | I | | L | ++---+ +---+ +---+ +---+ +---+ +---+ + ^ * o | : ^ + inherit owns has uses depend implements + | | | v v : ++---+ +---+ +---+ +---+ +---+ +---+ +| B | | D | | F | | H | | J | | K | ++---+ +---+ +---+ +---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt b/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt new file mode 100644 index 000000000..ce0295b8f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_annotation.txt @@ -0,0 +1,31 @@ +classDiagram + class Shape { + <> + +draw() void + } + class Circle { + +radius int + +draw() void + } + Shape <|-- Circle +--- ++--------------+ +| <> | +| Shape | ++--------------+ +| | ++--------------+ +| +draw: void | ++--------------+ + ^ + | + | ++--------------+ +| Circle | ++--------------+ +| +int: radius | ++--------------+ +| +draw: void | ++--------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_association.txt b/packages/utils/test/mermaid/testdata/ascii/cls_association.txt new file mode 100644 index 000000000..41df0fa34 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_association.txt @@ -0,0 +1,14 @@ +classDiagram + Student --> Course : enrolls +--- ++---------+ +| Student | ++---------+ + | + enrolls + v ++--------+ +| Course | ++--------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt b/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt new file mode 100644 index 000000000..b8267cc55 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_basic.txt @@ -0,0 +1,15 @@ +classDiagram + class Animal { + +String name + +eat() void + } +--- ++---------------+ +| Animal | ++---------------+ +| +name: String | ++---------------+ +| +eat: void | ++---------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt b/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt new file mode 100644 index 000000000..ff3ec1c3d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_dependency.txt @@ -0,0 +1,14 @@ +classDiagram + Client ..> Server : uses +--- ++--------+ +| Client | ++--------+ + : + uses + v ++--------+ +| Server | ++--------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt b/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt new file mode 100644 index 000000000..362f272d1 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_inheritance.txt @@ -0,0 +1,22 @@ +classDiagram + Animal <|-- Dog + Animal : +String name + Dog : +bark() void +--- ++---------------+ +| Animal | ++---------------+ +| +name: String | ++---------------+ + ^ + -- + | ++-------------+ +| Dog | ++-------------+ +| | ++-------------+ +| +bark: void | ++-------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt b/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt new file mode 100644 index 000000000..c6a13ec18 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/cls_methods.txt @@ -0,0 +1,21 @@ +classDiagram + class BankAccount { + +String owner + -int balance + +deposit(int amount) void + +withdraw(int amount) bool + -validate() bool + } +--- ++-----------------+ +| BankAccount | ++-----------------+ +| +owner: String | +| -balance: int | ++-----------------+ +| +deposit: void | +| +withdraw: bool | +| -validate: bool | ++-----------------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/comments.txt b/packages/utils/test/mermaid/testdata/ascii/comments.txt new file mode 100644 index 000000000..8df423e32 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/comments.txt @@ -0,0 +1,23 @@ +graph LR +%% This is a comment +A --> B +%% Another comment +B --> C +A --> C +%% Final comment +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | | + | | + | | + | | + | v + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt b/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt new file mode 100644 index 000000000..254a9c008 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/custom_padding.txt @@ -0,0 +1,10 @@ +paddingX=2 +paddingY=1 +graph LR +A --> B +--- ++---+ +---+ +| | | | +| A |->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt b/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt new file mode 100644 index 000000000..388f0806b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/duplicate_labels.txt @@ -0,0 +1,19 @@ +graph TD +A[Server] --> B[Client] +C[Server] --> D[Client] +--- ++--------+ +--------+ +| | | | +| Server | | Server | +| | | | ++--------+ +--------+ + | | + | | + | | + | | + v v ++--------+ +--------+ +| | | | +| Client | | Client | +| | | | ++--------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt b/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt new file mode 100644 index 000000000..f043b74af --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_attributes.txt @@ -0,0 +1,19 @@ +erDiagram + CUSTOMER { + string name PK + string email UK + int age + } + ORDER { + int id PK + string status + } + CUSTOMER ||--o{ ORDER : places +--- ++-----------------+ +------------------+ +| CUSTOMER | | ORDER | ++-----------------+ +------------------+ +| PK string name ||---o<| PK int id | +| UK string email |places| string status | +| int age | +------------------+ ++-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/er_basic.txt b/packages/utils/test/mermaid/testdata/ascii/er_basic.txt new file mode 100644 index 000000000..de0ef4179 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_basic.txt @@ -0,0 +1,8 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places +--- ++----------+ +-------+ +| CUSTOMER ||---o<| ORDER | ++----------+places+-------+ + + diff --git a/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt b/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt new file mode 100644 index 000000000..4ec1a4768 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/er_identifying.txt @@ -0,0 +1,16 @@ +erDiagram + PERSON ||--o{ ADDRESS : lives_at + PERSON { + string name PK + } + ADDRESS { + string street + string city + } +--- ++----------------+ +------------------+ +| PERSON | | ADDRESS | ++----------------+|---o<+------------------+ +| PK string name |lives_| string street | ++----------------+ | string city | + +------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt b/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt new file mode 100644 index 000000000..3c6c342a4 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/flowchart_tb_simple.txt @@ -0,0 +1,29 @@ +flowchart TB + A --> B + B --> C +--- ++---+ +| | +| A | +| | ++---+ + | + | + | + | + v ++---+ +| | +| B | +| | ++---+ + | + | + | + | + v ++---+ +| | +| C | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt b/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt new file mode 100644 index 000000000..0049fa40b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/graph_bt_direction.txt @@ -0,0 +1,28 @@ +graph BT + A --> B --> C +--- ++---+ +| | +| C | +| | ++---+ + ^ + | + | + | + | ++---+ +| | +| B | +| | ++---+ + ^ + | + | + | + | ++---+ +| | +| A | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt b/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt new file mode 100644 index 000000000..9ced6ab77 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/graph_tb_direction.txt @@ -0,0 +1,26 @@ +graph TB +subgraph one + A --> B +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt b/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt new file mode 100644 index 000000000..36d076b49 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/nested_subgraphs_with_labels.txt @@ -0,0 +1,36 @@ +graph TD +A[Web Server] --> B[API Gateway] +B --> C[Web Server] +subgraph Frontend +A +end +subgraph Backend +B +C +end +--- ++-------------+ +| | +| Web Server | +| | ++-------------+ + | + | + | + | + v ++-------------+ +| | +| API Gateway | +| | ++-------------+ + | + | + | + | + v ++-------------+ +| | +| Web Server | +| | ++-------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt b/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt new file mode 100644 index 000000000..8cf5df8ea --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/preserve_order_of_definition.txt @@ -0,0 +1,23 @@ +graph LR +A +B +B --> A +A --> A +B --> C +C --> A +--- ++---+ +---+ +| | | | +| A |<-+--| C | +| | | | | ++---+ | +---+ + ^ | ^ + | | | + +----+ | + | | + | | ++---+ | +| | | +| B |-------+ +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/self_reference.txt b/packages/utils/test/mermaid/testdata/ascii/self_reference.txt new file mode 100644 index 000000000..1b968c118 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/self_reference.txt @@ -0,0 +1,10 @@ +graph LR +A --> A +--- ++---+ +| | +| A |-+ +| | | ++---+ | + ^ | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt b/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt new file mode 100644 index 000000000..303e7d76e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/self_reference_with_edge.txt @@ -0,0 +1,10 @@ +graph LR +A --> A & B +--- ++---+ +---+ +| | | | +| A |--+->| B | +| | | | | ++---+ | +---+ + ^ | + +----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt b/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt new file mode 100644 index 000000000..d42a724e7 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_basic.txt @@ -0,0 +1,17 @@ +sequenceDiagram + Alice->>Bob: Hello Bob + Bob-->>Alice: Hi Alice +--- + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ + | | + | Hello Bob | + |--------------> + | | + | Hi Alice | + <..............| + | | + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt b/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt new file mode 100644 index 000000000..c2918984b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_multiple_messages.txt @@ -0,0 +1,25 @@ +sequenceDiagram + Alice->>Bob: Hello + Bob->>Charlie: Forward + Charlie-->>Bob: Reply + Bob-->>Alice: Done +--- + +-------+ +-----+ +---------+ + | Alice | | Bob | | Charlie | + +-------+ +-----+ +---------+ + | | | + | Hello | | + |----------> | + | | | + | | Forward | + | |------------> + | | | + | | Reply | + | <............| + | | | + | Done | | + <..........| | + | | | + +-------+ +-----+ +---------+ + | Alice | | Bob | | Charlie | + +-------+ +-----+ +---------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt b/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt new file mode 100644 index 000000000..107807563 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/seq_self_message.txt @@ -0,0 +1,18 @@ +sequenceDiagram + Alice->>Alice: Think + Alice->>Bob: Result +--- + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ + | | + +---+ | + | | Think | + <---+ | + | | + | Result | + |-----------> + | | + +-------+ +-----+ + | Alice | | Bob | + +-------+ +-----+ diff --git a/packages/utils/test/mermaid/testdata/ascii/single_node.txt b/packages/utils/test/mermaid/testdata/ascii/single_node.txt new file mode 100644 index 000000000..fc71ff15a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/single_node.txt @@ -0,0 +1,8 @@ +graph LR +A +--- ++---+ +| | +| A | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt b/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt new file mode 100644 index 000000000..5d8970cf7 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/single_node_longer_name.txt @@ -0,0 +1,8 @@ +graph LR +LongerName +--- ++------------+ +| | +| LongerName | +| | ++------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt new file mode 100644 index 000000000..cd7ecdc96 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_mixed.txt @@ -0,0 +1,38 @@ +graph LR +Start +subgraph Processing + A --> B + B --> C +end +subgraph Storage + D + E +end +Start --> A +C --> D +C --> E +D --> End +E --> End +End +--- + +---------------------------+ +-------+ + | Processing | |Storage| + | | | | + | | | | ++-------+ | +---+ +---+ +---+ | | +---+ | +-----+ +| | | | | | | | | | | | | | | | +| Start |---->| A |---->| B |---->| C |---->| D |---->| End | +| | | | | | | | | | | | | | | | ++-------+ | +---+ +---+ +---+ | | +---+ | +-----+ + | | | | | ^ + +-----------------------|---+ | | | + | | | | + | | | | + | | | | + | | +---+ | | + | | | | | | + +------>| E |--------+ + | | | | + | +---+ | + | | + +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt new file mode 100644 index 000000000..8355e830a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_complex_nested.txt @@ -0,0 +1,49 @@ +graph LR +subgraph outer + A + subgraph inner + B + end + C +end +D +--- ++-----------+ +| outer | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | +| +-------+ | +| | inner | | +| | | | +| | | | +| | +---+ | | +| | | | | | +| | | B | | | +| | | | | | +| | +---+ | | +| | | | +| +-------+ | +| | +| | +| | +| +---+ | +| | | | +| | C | | +| | | | +| +---+ | +| | ++-----------+ + + + + +---+ + | | + | D | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt new file mode 100644 index 000000000..9fd668643 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_direction_override.txt @@ -0,0 +1,37 @@ +graph TD +subgraph one [LR Group] + direction LR + A --> B +end +X --> A +B --> Y +--- + +---+ + | | + | X | + | | + +---+ + | + | + | + | + | ++---|-------------+ +| |LR Group | +| | | +| v | +| +---+ +---+ | +| | | | | | +| | A |---->| B | | +| | | | | | +| +---+ +---+ | +| | | ++-------------|---+ + | + | + | + +---+ | + | | | + | Y |<------+ + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt new file mode 100644 index 000000000..1c0d54e75 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_empty.txt @@ -0,0 +1,10 @@ +graph LR +subgraph one +end +A --> B +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt new file mode 100644 index 000000000..0d288a4f2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes.txt @@ -0,0 +1,20 @@ +graph LR +X +subgraph one + A --> B +end +Y +X --> A +B --> Y +--- + +-----------------+ + | one | + | | + | | ++---+ | +---+ +---+ | +---+ +| | | | | | | | | | +| X |---->| A |---->| B |---->| Y | +| | | | | | | | | | ++---+ | +---+ +---+ | +---+ + | | + +-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt new file mode 100644 index 000000000..19af6be0f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_mixed_nodes_td.txt @@ -0,0 +1,48 @@ +graph TD +X +subgraph one + A --> B +end +Y +X --> A +B --> Y +--- + +---+ + | | + | X | + | | + +---+ + | + | + | + | + | ++---|---+ +| one | +| | | +| v | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | | ++---|---+ + | + | + v + +---+ + | | + | Y | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt new file mode 100644 index 000000000..cebdccc31 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_edges.txt @@ -0,0 +1,32 @@ +graph LR +subgraph one + A --> B + A --> C +end +subgraph two + D --> E +end +B --> D +C --> E +--- ++-----------------+ +-------+ +| one | | two | +| | | | +| | | | +| +---+ +---+ | | +---+ | +| | | | | | | | | | +| | A |---->| B |---->| D | | +| | | | | | | | | | +| +---+ +---+ | | +---+ | +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | v | +| | +---+ | | +---+ | +| | | | | | | | | +| +------>| C |---->| E | | +| | | | | | | | +| +---+ | | +---+ | +| | | | ++-----------------+ +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt new file mode 100644 index 000000000..e018e4469 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_multiple_nodes.txt @@ -0,0 +1,16 @@ +graph LR +subgraph one + A --> B +end +--- ++-----------------+ +| one | +| | +| | +| +---+ +---+ | +| | | | | | +| | A |---->| B | | +| | | | | | +| +---+ +---+ | +| | ++-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt new file mode 100644 index 000000000..9c698fbec --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested.txt @@ -0,0 +1,24 @@ +graph LR +subgraph outer + subgraph inner + A + end +end +--- ++-----------+ +| outer | +| | +| | +| +-------+ | +| | inner | | +| | | | +| | | | +| | +---+ | | +| | | | | | +| | | A | | | +| | | | | | +| | +---+ | | +| | | | +| +-------+ | +| | ++-----------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt new file mode 100644 index 000000000..56de3c97d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_nested_with_external.txt @@ -0,0 +1,30 @@ +graph LR +X +subgraph outer + A + subgraph inner + B --> C + end + A --> B +end +X --> A +C --> Y +Y +--- + +-----------------------------+ + | outer | + | | + | | + | +-----------------+ | + | | inner | | + | | | | + | | | | ++---+ | +---+ | +---+ +---+ | | +---+ +| | | | | | | | | | | | | | +| X |---->| A |---->| B |---->| C |---->| Y | +| | | | | | | | | | | | | | ++---+ | +---+ | +---+ +---+ | | +---+ + | | | | + | +-----------------+ | + | | + +-----------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt new file mode 100644 index 000000000..fc4804684 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_node_outside_lr.txt @@ -0,0 +1,17 @@ +graph LR +X +subgraph one + A --> B +end +--- + +-----------------+ + | one | + | | + | | ++---+ | +---+ +---+ | +| | | | | | | | +| X | | | A |---->| B | | +| | | | | | | | ++---+ | +---+ +---+ | + | | + +-----------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt new file mode 100644 index 000000000..155f96b6e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_single_node.txt @@ -0,0 +1,16 @@ +graph LR +subgraph one + A +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt new file mode 100644 index 000000000..d1b254019 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_direction.txt @@ -0,0 +1,26 @@ +graph TD +subgraph one + A --> B +end +--- ++-------+ +| one | +| | +| | +| +---+ | +| | | | +| | A | | +| | | | +| +---+ | +| | | +| | | +| | | +| | | +| v | +| +---+ | +| | | | +| | B | | +| | | | +| +---+ | +| | ++-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt new file mode 100644 index 000000000..d34dc1e98 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple.txt @@ -0,0 +1,44 @@ +graph TD +subgraph Frontend + UI --> API +end +subgraph Backend + API --> Database + API --> Cache +end +--- ++--------------+ +| Frontend | +| | +| | +| +----------+ | +| | | | +| | UI | | +| | | | +| +----------+ | +| | | +| | | +| | | +| | | +| v | +| +----------+ | +| | | | +| | API |---------+ +| | | | | +| +----------+ | | +| | | | ++-------|------+ | + | | + | | + | | ++-------|--------------|-----+ +| | Backend | | +| | | | +| v v | +| +----------+ +-------+ | +| | | | | | +| | Database | | Cache | | +| | | | | | +| +----------+ +-------+ | +| | ++----------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt new file mode 100644 index 000000000..f45c8ea9b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_td_multiple_paddingy.txt @@ -0,0 +1,42 @@ +paddingX=3 +paddingY=3 +graph TD +subgraph Frontend + UI --> API +end +subgraph Backend + API --> Database + API --> Cache +end +--- ++--------------+ +| Frontend | +| | +| | +| +----------+ | +| | | | +| | UI | | +| | | | +| +----------+ | +| | | +| | | +| v | +| +----------+ | +| | | | +| | API |-------+ +| | | | | +| +----------+ | | +| | | | ++-------|------+ | + | | ++-------|------------|-----+ +| | Backend | | +| | | | +| v v | +| +----------+ +-------+ | +| | | | | | +| | Database | | Cache | | +| | | | | | +| +----------+ +-------+ | +| | ++--------------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt new file mode 100644 index 000000000..fa2a694e0 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_levels_nested.txt @@ -0,0 +1,32 @@ +graph LR +subgraph level1 + subgraph level2 + subgraph level3 + A + end + end +end +--- ++---------------+ +| level1 | +| | +| | +| +-----------+ | +| | level2 | | +| | | | +| | | | +| | +-------+ | | +| | |level3 | | | +| | | | | | +| | | | | | +| | | +---+ | | | +| | | | | | | | +| | | | A | | | | +| | | | | | | | +| | | +---+ | | | +| | | | | | +| | +-------+ | | +| | | | +| +-----------+ | +| | ++---------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt new file mode 100644 index 000000000..ed9eb2a9b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_three_separate.txt @@ -0,0 +1,24 @@ +graph LR +subgraph Frontend + UI +end +subgraph Backend + API +end +subgraph Database + DB +end +UI --> API +API --> DB +--- ++--------+ +---------+ +--------+ +|Frontend| | Backend | |Database| +| | | | | | +| | | | | | +| +----+ | | +-----+ | | +----+ | +| | | | | | | | | | | | +| | UI |---->| API |---->| DB | | +| | | | | | | | | | | | +| +----+ | | +-----+ | | +----+ | +| | | | | | ++--------+ +---------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt new file mode 100644 index 000000000..c58582a0b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_two_separate.txt @@ -0,0 +1,20 @@ +graph LR +subgraph one + A +end +subgraph two + B +end +A --> B +--- ++-------+ +-------+ +| one | | two | +| | | | +| | | | +| +---+ | | +---+ | +| | | | | | | | +| | A |---->| B | | +| | | | | | | | +| +---+ | | +---+ | +| | | | ++-------+ +-------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt b/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt new file mode 100644 index 000000000..3168748f3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/subgraph_with_labels.txt @@ -0,0 +1,20 @@ +graph LR +subgraph one + A -->|sends| B +end +subgraph two + C -->|receives| D +end +B -->|data| C +--- ++-------------------+ +----------------------+ +| one | | two | +| | | | +| | | | +| +---+ +---+ | | +---+ +---+ | +| | | | | | | | | | | | +| | A |-sends>| B |data->| C |receives->| D | | +| | | | | | | | | | | | +| +---+ +---+ | | +---+ +---+ | +| | | | ++-------------------+ +----------------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt new file mode 100644 index 000000000..7ec4a6991 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/three_nodes.txt @@ -0,0 +1,9 @@ +graph LR +A --> B +B --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt b/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt new file mode 100644 index 000000000..cc83b6bc0 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/three_nodes_single_line.txt @@ -0,0 +1,8 @@ +graph LR +A --> B --> C +--- ++---+ +---+ +---+ +| | | | | | +| A |---->| B |---->| C | +| | | | | | ++---+ +---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt new file mode 100644 index 000000000..c9f375a52 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +A --> C +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + | + | + | + | + | + | +---+ + | | | + +------>| C | + | | + +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt new file mode 100644 index 000000000..efe45a812 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_layer_single_graph_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +ABC --> CDEFGHI +--- ++-----+ +---------+ +| | | | +| ABC |---->| BCDEFG | +| | | | ++-----+ +---------+ + | + | + | + | + | + | +---------+ + | | | + +------->| CDEFGHI | + | | + +---------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt b/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt new file mode 100644 index 000000000..0d263eb3c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_nodes_linked.txt @@ -0,0 +1,8 @@ +graph LR +A --> B +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt new file mode 100644 index 000000000..1c8fcb090 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_nodes_longer_names.txt @@ -0,0 +1,8 @@ +graph LR +LongerName1 --> LongerName2 +--- ++-------------+ +-------------+ +| | | | +| LongerName1 |---->| LongerName2 | +| | | | ++-------------+ +-------------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt new file mode 100644 index 000000000..896c5fca4 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +C --> D +--- ++---+ +---+ +| | | | +| A |---->| B | +| | | | ++---+ +---+ + + + + + ++---+ +---+ +| | | | +| C |---->| D | +| | | | ++---+ +---+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt new file mode 100644 index 000000000..9627f644c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_root_nodes_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +CDEFGH --> DEF +--- ++--------+ +--------+ +| | | | +| ABC |---->| BCDEFG | +| | | | ++--------+ +--------+ + + + + + ++--------+ +--------+ +| | | | +| CDEFGH |---->| DEF | +| | | | ++--------+ +--------+ diff --git a/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt b/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt new file mode 100644 index 000000000..8fec1dffa --- /dev/null +++ b/packages/utils/test/mermaid/testdata/ascii/two_single_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A +B +--- ++---+ +| | +| A | +| | ++---+ + + + + + ++---+ +| | +| B | +| | ++---+ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt new file mode 100644 index 000000000..50fefa56c --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs.txt @@ -0,0 +1,18 @@ +graph LR +A --> B & C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └───┘ + │ + │ + │ + │ + │ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt new file mode 100644 index 000000000..f56d03a45 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_lhs_and_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C & D +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├──┬─►│ C │ +│ │ │ │ │ +└─┬─┘ │ └───┘ + │ │ + │ │ + ├────┤ + │ │ + │ │ +┌─┴─┐ │ ┌───┐ +│ │ │ │ │ +│ B ├──┴─►│ D │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt new file mode 100644 index 000000000..b33572596 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_rhs.txt @@ -0,0 +1,18 @@ +graph LR +A & B --> C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ C │ +│ │ │ │ +└───┘ └───┘ + ▲ + │ + │ + │ + │ +┌───┐ │ +│ │ │ +│ B ├───────┘ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt b/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt new file mode 100644 index 000000000..ed5860a5d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/ampersand_without_edge.txt @@ -0,0 +1,18 @@ +graph LR +A & B +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ + + + + + +┌───┐ +│ │ +│ B │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt b/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt new file mode 100644 index 000000000..a40debd42 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/back_reference_from_child.txt @@ -0,0 +1,10 @@ +graph LR +A --> B --> C --> A +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └─┬─┘ + ▲ │ + └───────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt b/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt new file mode 100644 index 000000000..885314ca8 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/backlink_from_bottom.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +C --> D +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ D │ +│ │ │ │ │ │ +└─┬─┘ └─┬─┘ └───┘ + │ │ ▲ + │ │ │ + │ │ │ + │ │ │ + │ ▼ │ + │ ┌───┐ │ + │ │ │ │ + └──────►│ C ├───────┘ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt b/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt new file mode 100644 index 000000000..fa4b90ea9 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/backlink_from_top.txt @@ -0,0 +1,22 @@ +graph LR +A --> B +B --> C +A --> C +B --> D +D --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├──┬─►┤ D │ +│ │ │ │ │ │ │ +└─┬─┘ └─┬─┘ │ └───┘ + │ │ │ + │ │ │ + │ ├────┘ + │ │ + │ ▼ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt b/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt new file mode 100644 index 000000000..208843c6e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_all_relationships.txt @@ -0,0 +1,17 @@ +classDiagram + A <|-- B : inherits + C *-- D : owns + E o-- F : has + G --> H : uses + I ..> J : depends + K ..|> L : implements +--- +┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ +│ A │ │ C │ │ E │ │ G │ │ I │ │ L │ +└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ + △ ◆ ◇ │ ┊ △ + inherit owns has uses depend implements + │ │ │ ▼ ▼ ┊ +┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ +│ B │ │ D │ │ F │ │ H │ │ J │ │ K │ +└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt b/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt new file mode 100644 index 000000000..b9e929ffd --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_annotation.txt @@ -0,0 +1,31 @@ +classDiagram + class Shape { + <> + +draw() void + } + class Circle { + +radius int + +draw() void + } + Shape <|-- Circle +--- +┌──────────────┐ +│ <> │ +│ Shape │ +├──────────────┤ +│ │ +├──────────────┤ +│ +draw: void │ +└──────────────┘ + △ + │ + │ +┌──────────────┐ +│ Circle │ +├──────────────┤ +│ +int: radius │ +├──────────────┤ +│ +draw: void │ +└──────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_association.txt b/packages/utils/test/mermaid/testdata/unicode/cls_association.txt new file mode 100644 index 000000000..9729bf2e3 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_association.txt @@ -0,0 +1,14 @@ +classDiagram + Student --> Course : enrolls +--- +┌─────────┐ +│ Student │ +└─────────┘ + │ + enrolls + ▼ +┌────────┐ +│ Course │ +└────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt b/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt new file mode 100644 index 000000000..fab4b1861 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_basic.txt @@ -0,0 +1,15 @@ +classDiagram + class Animal { + +String name + +eat() void + } +--- +┌───────────────┐ +│ Animal │ +├───────────────┤ +│ +name: String │ +├───────────────┤ +│ +eat: void │ +└───────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt b/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt new file mode 100644 index 000000000..5196a5409 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_dependency.txt @@ -0,0 +1,14 @@ +classDiagram + Client ..> Server : uses +--- +┌────────┐ +│ Client │ +└────────┘ + ┊ + uses + ▼ +┌────────┐ +│ Server │ +└────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt b/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt new file mode 100644 index 000000000..f7a3c320e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_inheritance.txt @@ -0,0 +1,22 @@ +classDiagram + Animal <|-- Dog + Animal : +String name + Dog : +bark() void +--- +┌───────────────┐ +│ Animal │ +├───────────────┤ +│ +name: String │ +└───────────────┘ + △ + ┌┘ + │ +┌─────────────┐ +│ Dog │ +├─────────────┤ +│ │ +├─────────────┤ +│ +bark: void │ +└─────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt b/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt new file mode 100644 index 000000000..a87265a1f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/cls_methods.txt @@ -0,0 +1,21 @@ +classDiagram + class BankAccount { + +String owner + -int balance + +deposit(int amount) void + +withdraw(int amount) bool + -validate() bool + } +--- +┌─────────────────┐ +│ BankAccount │ +├─────────────────┤ +│ +owner: String │ +│ -balance: int │ +├─────────────────┤ +│ +deposit: void │ +│ +withdraw: bool │ +│ -validate: bool │ +└─────────────────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/comments.txt b/packages/utils/test/mermaid/testdata/unicode/comments.txt new file mode 100644 index 000000000..6a7ff3c8e --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/comments.txt @@ -0,0 +1,23 @@ +graph LR +%% This is a comment +A --> B +%% Another comment +B --> C +A --> C +%% Final comment +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └─┬─┘ + │ │ + │ │ + │ │ + │ │ + │ ▼ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt b/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt new file mode 100644 index 000000000..949a72268 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/duplicate_labels.txt @@ -0,0 +1,19 @@ +graph TD +A[Server] --> B[Client] +C[Server] --> D[Client] +--- +┌────────┐ ┌────────┐ +│ │ │ │ +│ Server │ │ Server │ +│ │ │ │ +└────┬───┘ └────┬───┘ + │ │ + │ │ + │ │ + │ │ + ▼ ▼ +┌────────┐ ┌────────┐ +│ │ │ │ +│ Client │ │ Client │ +│ │ │ │ +└────────┘ └────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt b/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt new file mode 100644 index 000000000..860dc18f8 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_attributes.txt @@ -0,0 +1,19 @@ +erDiagram + CUSTOMER { + string name PK + string email UK + int age + } + ORDER { + int id PK + string status + } + CUSTOMER ||--o{ ORDER : places +--- +┌─────────────────┐ ┌──────────────────┐ +│ CUSTOMER │ │ ORDER │ +├─────────────────┤ ├──────────────────┤ +│ PK string name ││───○╟│ PK int id │ +│ UK string email │places│ string status │ +│ int age │ └──────────────────┘ +└─────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/er_basic.txt b/packages/utils/test/mermaid/testdata/unicode/er_basic.txt new file mode 100644 index 000000000..9c9f3a8a5 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_basic.txt @@ -0,0 +1,8 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places +--- +┌──────────┐ ┌───────┐ +│ CUSTOMER ││───○╟│ ORDER │ +└──────────┘places└───────┘ + + diff --git a/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt b/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt new file mode 100644 index 000000000..9e8aa961f --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/er_identifying.txt @@ -0,0 +1,16 @@ +erDiagram + PERSON ||--o{ ADDRESS : lives_at + PERSON { + string name PK + } + ADDRESS { + string street + string city + } +--- +┌────────────────┐ ┌──────────────────┐ +│ PERSON │ │ ADDRESS │ +├────────────────┤│───○╟├──────────────────┤ +│ PK string name │lives_│ string street │ +└────────────────┘ │ string city │ + └──────────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt b/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt new file mode 100644 index 000000000..54aa19ae9 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/graph_bt_direction.txt @@ -0,0 +1,28 @@ +graph BT + A --> B --> C +--- +┌───┐ +│ │ +│ C │ +│ │ +└───┘ + ▲ + │ + │ + │ + │ +┌─┴─┐ +│ │ +│ B │ +│ │ +└───┘ + ▲ + │ + │ + │ + │ +┌─┴─┐ +│ │ +│ A │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt b/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt new file mode 100644 index 000000000..6ed6857ce --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/preserve_order_of_definition.txt @@ -0,0 +1,23 @@ +graph LR +A +B +B --> A +A --> A +B --> C +C --> A +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├◄─┬──┤ C │ +│ │ │ │ │ +└───┘ │ └───┘ + ▲ │ ▲ + │ │ │ + ├────┘ │ + │ │ + │ │ +┌─┴─┐ │ +│ │ │ +│ B ├───────┘ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/self_reference.txt b/packages/utils/test/mermaid/testdata/unicode/self_reference.txt new file mode 100644 index 000000000..c11269d34 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/self_reference.txt @@ -0,0 +1,10 @@ +graph LR +A --> A +--- +┌───┐ +│ │ +│ A ├─┐ +│ │ │ +└───┘ │ + ▲ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt b/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt new file mode 100644 index 000000000..8eb0f6a9d --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/self_reference_with_edge.txt @@ -0,0 +1,10 @@ +graph LR +A --> A & B +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├──┬─►│ B │ +│ │ │ │ │ +└───┘ │ └───┘ + ▲ │ + └────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt b/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt new file mode 100644 index 000000000..6106fd620 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_basic.txt @@ -0,0 +1,17 @@ +sequenceDiagram + Alice->>Bob: Hello Bob + Bob-->>Alice: Hi Alice +--- + ┌───────┐ ┌─────┐ + │ Alice │ │ Bob │ + └───┬───┘ └──┬──┘ + │ │ + │ Hello Bob │ + │──────────────▶ + │ │ + │ Hi Alice │ + ◀╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ + │ │ + ┌───┴───┐ ┌──┴──┐ + │ Alice │ │ Bob │ + └───────┘ └─────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt b/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt new file mode 100644 index 000000000..6f2a99e62 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_multiple_messages.txt @@ -0,0 +1,25 @@ +sequenceDiagram + Alice->>Bob: Hello + Bob->>Charlie: Forward + Charlie-->>Bob: Reply + Bob-->>Alice: Done +--- + ┌───────┐ ┌─────┐ ┌─────────┐ + │ Alice │ │ Bob │ │ Charlie │ + └───┬───┘ └──┬──┘ └────┬────┘ + │ │ │ + │ Hello │ │ + │──────────▶ │ + │ │ │ + │ │ Forward │ + │ │────────────▶ + │ │ │ + │ │ Reply │ + │ ◀╌╌╌╌╌╌╌╌╌╌╌╌│ + │ │ │ + │ Done │ │ + ◀╌╌╌╌╌╌╌╌╌╌│ │ + │ │ │ + ┌───┴───┐ ┌──┴──┐ ┌────┴────┐ + │ Alice │ │ Bob │ │ Charlie │ + └───────┘ └─────┘ └─────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt b/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt new file mode 100644 index 000000000..f3652dab2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/seq_self_message.txt @@ -0,0 +1,18 @@ +sequenceDiagram + Alice->>Alice: Think + Alice->>Bob: Result +--- + ┌───────┐ ┌─────┐ + │ Alice │ │ Bob │ + └───┬───┘ └──┬──┘ + │ │ + ├───┐ │ + │ │ Think │ + ◀───┘ │ + │ │ + │ Result │ + │───────────▶ + │ │ + ┌───┴───┐ ┌──┴──┐ + │ Alice │ │ Bob │ + └───────┘ └─────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/single_node.txt b/packages/utils/test/mermaid/testdata/unicode/single_node.txt new file mode 100644 index 000000000..d6824d9b2 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/single_node.txt @@ -0,0 +1,8 @@ +graph LR +A +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt b/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt new file mode 100644 index 000000000..85be6a5ea --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/single_node_longer_name.txt @@ -0,0 +1,8 @@ +graph LR +LongerName +--- +┌────────────┐ +│ │ +│ LongerName │ +│ │ +└────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt new file mode 100644 index 000000000..10e17e9fb --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/three_nodes.txt @@ -0,0 +1,9 @@ +graph LR +A --> B +B --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt b/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt new file mode 100644 index 000000000..b4d9f2a8b --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/three_nodes_single_line.txt @@ -0,0 +1,8 @@ +graph LR +A --> B --> C +--- +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ A ├────►│ B ├────►│ C │ +│ │ │ │ │ │ +└───┘ └───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt new file mode 100644 index 000000000..72e27fdfa --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +A --> C +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└─┬─┘ └───┘ + │ + │ + │ + │ + │ + │ ┌───┐ + │ │ │ + └──────►│ C │ + │ │ + └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt new file mode 100644 index 000000000..5d112d528 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_layer_single_graph_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +ABC --> CDEFGHI +--- +┌─────┐ ┌─────────┐ +│ │ │ │ +│ ABC ├────►│ BCDEFG │ +│ │ │ │ +└──┬──┘ └─────────┘ + │ + │ + │ + │ + │ + │ ┌─────────┐ + │ │ │ + └───────►│ CDEFGHI │ + │ │ + └─────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt b/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt new file mode 100644 index 000000000..84560f209 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_nodes_linked.txt @@ -0,0 +1,8 @@ +graph LR +A --> B +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt new file mode 100644 index 000000000..f53e3bbda --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_nodes_longer_names.txt @@ -0,0 +1,8 @@ +graph LR +LongerName1 --> LongerName2 +--- +┌─────────────┐ ┌─────────────┐ +│ │ │ │ +│ LongerName1 ├────►│ LongerName2 │ +│ │ │ │ +└─────────────┘ └─────────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt new file mode 100644 index 000000000..cd89e1d8a --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A --> B +C --> D +--- +┌───┐ ┌───┐ +│ │ │ │ +│ A ├────►│ B │ +│ │ │ │ +└───┘ └───┘ + + + + + +┌───┐ ┌───┐ +│ │ │ │ +│ C ├────►│ D │ +│ │ │ │ +└───┘ └───┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt new file mode 100644 index 000000000..1c2ea32c1 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_root_nodes_longer_names.txt @@ -0,0 +1,19 @@ +graph LR +ABC --> BCDEFG +CDEFGH --> DEF +--- +┌────────┐ ┌────────┐ +│ │ │ │ +│ ABC ├────►│ BCDEFG │ +│ │ │ │ +└────────┘ └────────┘ + + + + + +┌────────┐ ┌────────┐ +│ │ │ │ +│ CDEFGH ├────►│ DEF │ +│ │ │ │ +└────────┘ └────────┘ diff --git a/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt b/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt new file mode 100644 index 000000000..25dab9054 --- /dev/null +++ b/packages/utils/test/mermaid/testdata/unicode/two_single_root_nodes.txt @@ -0,0 +1,19 @@ +graph LR +A +B +--- +┌───┐ +│ │ +│ A │ +│ │ +└───┘ + + + + + +┌───┐ +│ │ +│ B │ +│ │ +└───┘ diff --git a/packages/utils/test/mermaid/xychart.test.ts b/packages/utils/test/mermaid/xychart.test.ts new file mode 100644 index 000000000..ca705e116 --- /dev/null +++ b/packages/utils/test/mermaid/xychart.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for xychart-beta ASCII rendering. + * + * Tests bar charts, line charts, mixed charts, horizontal orientation, + * multi-series support, staircase line routing, and edge cases. + */ +import { describe, expect, it } from "bun:test"; +import { renderMermaidASCII } from "../../src/vendor/mermaid-ascii/ascii/index"; + +// ============================================================================ +// Helper — render with no colors for easy string matching +// ============================================================================ + +function render(text: string, useAscii = false): string { + return renderMermaidASCII(text, { colorMode: "none", useAscii }); +} + +// ============================================================================ +// Bar charts +// ============================================================================ + +describe("xychart ASCII – bar charts", () => { + it("renders a basic bar chart with block characters", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [10, 20, 30]`); + expect(result).toContain("█"); + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("C"); + }); + + it("renders bars with # in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B, C] + bar [10, 20, 30]`, + true, + ); + expect(result).toContain("#"); + expect(result).not.toContain("█"); + }); + + it("renders a bar chart with title", () => { + const result = render(`xychart-beta + title "Sales Report" + x-axis [Q1, Q2, Q3, Q4] + bar [100, 200, 150, 250]`); + expect(result).toContain("Sales Report"); + expect(result).toContain("Q1"); + expect(result).toContain("Q4"); + }); + + it("renders y-axis tick labels", () => { + const result = render(`xychart-beta + x-axis [A, B] + y-axis 0 --> 100 + bar [25, 75]`); + // Should have at least some tick values visible + expect(result).toContain("┤"); + expect(result).toContain("┼"); + }); + + it("renders multi-series bars", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [10, 20, 30] + bar [15, 25, 35]`); + // Should contain legend for multi-series + expect(result).toContain("Bar 1"); + expect(result).toContain("Bar 2"); + }); +}); + +// ============================================================================ +// Line charts +// ============================================================================ + +describe("xychart ASCII – line charts", () => { + it("renders a line chart with staircase routing", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + line [10, 30, 20, 40]`); + // Should use box-drawing corners for staircase + expect(result).toContain("╭"); + expect(result).toContain("─"); + }); + + it("does NOT use dot markers on line charts", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + line [10, 30, 20, 40]`); + expect(result).not.toContain("●"); + expect(result).not.toContain("*"); + }); + + it("uses vertical segments for row transitions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + line [10, 50, 20]`); + // Vertical segments connect rows + expect(result).toContain("│"); + }); + + it("renders ascending line with ╯ and ╭ corners", () => { + const result = render(`xychart-beta + x-axis [A, B] + line [10, 50]`); + // Ascending: ╯ at bottom (left+up), ╭ at top (bottom+right) + expect(result).toContain("╯"); + expect(result).toContain("╭"); + }); + + it("renders descending line with ╮ and ╰ corners", () => { + const result = render(`xychart-beta + x-axis [A, B] + line [50, 10]`); + // Descending: ╮ at top (left+down), ╰ at bottom (top+right) + expect(result).toContain("╮"); + expect(result).toContain("╰"); + }); + + it("renders flat line with only ─", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + line [50, 50, 50]`); + expect(result).toContain("─"); + // No corners needed for flat lines + expect(result).not.toContain("╭"); + expect(result).not.toContain("╮"); + }); + + it("uses + for corners in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B] + line [10, 50]`, + true, + ); + expect(result).toContain("+"); + expect(result).not.toContain("╭"); + expect(result).not.toContain("╰"); + }); +}); + +// ============================================================================ +// Mixed bar + line +// ============================================================================ + +describe("xychart ASCII – mixed charts", () => { + it("renders bars and lines together", () => { + const result = render(`xychart-beta + x-axis [A, B, C, D] + bar [20, 40, 30, 50] + line [25, 35, 40, 45]`); + // Should have both bar blocks and line corners + expect(result).toContain("█"); + expect(result).toContain("─"); + // Legend for multi-series + expect(result).toContain("Bar 1"); + expect(result).toContain("Line 1"); + }); +}); + +// ============================================================================ +// Horizontal orientation +// ============================================================================ + +describe("xychart ASCII – horizontal", () => { + it("renders horizontal bar chart", () => { + const result = render(`xychart-beta horizontal + x-axis [Python, JavaScript, Go] + bar [30, 25, 12]`); + // Category labels on left side + expect(result).toContain("Python"); + expect(result).toContain("JavaScript"); + expect(result).toContain("Go"); + expect(result).toContain("█"); + }); + + it("renders horizontal line chart with staircase", () => { + const result = render(`xychart-beta horizontal + x-axis [A, B, C] + line [10, 30, 20]`); + // Should have horizontal staircase routing + expect(result).toContain("─"); + expect(result).toContain("│"); + }); +}); + +// ============================================================================ +// Titles and axis labels +// ============================================================================ + +describe("xychart ASCII – titles and axes", () => { + it("renders chart title centered", () => { + const result = render(`xychart-beta + title "My Chart" + x-axis [A, B] + bar [10, 20]`); + const titleLine = result.split("\n").find(l => l.includes("My Chart")); + expect(titleLine).toBeDefined(); + }); + + it("renders x-axis title", () => { + const result = render(`xychart-beta + x-axis "Category" [A, B, C] + bar [10, 20, 30]`); + expect(result).toContain("Category"); + }); + + it("renders y-axis with explicit range", () => { + const result = render(`xychart-beta + x-axis [A, B] + y-axis "Score" 0 --> 100 + bar [25, 75]`); + // Y-axis ticks should appear + expect(result).toContain("0"); + expect(result).toContain("100"); + }); + + it("renders without title when not specified", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + // First non-empty line should be chart content, not a title + const lines = result.split("\n").filter(l => l.trim().length > 0); + expect(lines.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================ +// Edge cases +// ============================================================================ + +describe("xychart ASCII – edge cases", () => { + it("handles single data point", () => { + const result = render(`xychart-beta + x-axis [Only] + bar [42]`); + expect(result).toContain("Only"); + expect(result).toContain("█"); + }); + + it("handles two data points", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + + it("handles all zeros", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [0, 0, 0]`); + expect(result).toContain("A"); + // Should still render axes + expect(result).toContain("┼"); + }); + + it("handles large values", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [1000000, 2000000]`); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + + it("renders line chart with single data point", () => { + const result = render(`xychart-beta + x-axis [A] + line [50]`); + expect(result).toContain("A"); + expect(result).toContain("─"); + }); +}); + +// ============================================================================ +// Axis structure +// ============================================================================ + +describe("xychart ASCII – axis structure", () => { + it("has y-axis ticks (┤) at value positions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + y-axis 0 --> 100 + bar [25, 50, 75]`); + expect(result).toContain("┤"); + }); + + it("has x-axis ticks (┬) at category positions", () => { + const result = render(`xychart-beta + x-axis [A, B, C] + bar [25, 50, 75]`); + expect(result).toContain("┬"); + }); + + it("has origin marker (┼)", () => { + const result = render(`xychart-beta + x-axis [A, B] + bar [10, 20]`); + expect(result).toContain("┼"); + }); + + it("uses + for axis characters in ASCII mode", () => { + const result = render( + `xychart-beta + x-axis [A, B, C] + y-axis 0 --> 100 + bar [25, 50, 75]`, + true, + ); + expect(result).toContain("+"); + expect(result).not.toContain("┤"); + expect(result).not.toContain("┬"); + expect(result).not.toContain("┼"); + }); +}); diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts index 45726c80e..077e8d522 100644 --- a/packages/utils/test/spacing.test.ts +++ b/packages/utils/test/spacing.test.ts @@ -4,96 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils/dirs"; import { Snowflake } from "@oh-my-pi/pi-utils/snowflake"; -import { getEditorConfigFormatting, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-utils/tab-spacing"; - -describe("spacing", () => { - let tempDir = ""; - let previousProjectDir = ""; - - beforeEach(async () => { - previousProjectDir = getProjectDir(); - tempDir = path.join(os.tmpdir(), "pi-utils-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - setProjectDir(tempDir); - setDefaultTabWidth(3); - }); - - afterEach(async () => { - setDefaultTabWidth(3); - setProjectDir(previousProjectDir); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it("resolves editorconfig rules for file path and falls back to default", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "README.md")))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "missing.txt")))).toBe(" "); - }); - - it("merges nested editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - }); - - it("does not throw when the path's segment exceeds NAME_MAX (#1871)", () => { - // A garbage path segment (e.g. 2KiB of garbage Unicode produced by a - // hallucinating model) makes `fs.readFileSync` reject with - // ENAMETOOLONG. The editorconfig probe MUST swallow it and fall back to - // the default tab width — anything else crashes the TUI mid-render. - const huge = "a".repeat(2048); - const phonyPath = path.join(tempDir, huge, "leaf.ts"); - expect(() => getIndentation(phonyPath)).not.toThrow(); - expect(getIndentation(phonyPath)).toBe(3); - }); - - it("returns the default tab width for paths with an overlong component (no syscall)", () => { - // Repro of #1872: a malformed edit tool call lands a long gibberish - // string in `file_path`, the renderer routes it through `replaceTabs -> - // getIndentation`, and `readFileSync` of `/.editorconfig` would - // throw `ENAMETOOLONG`. The path gate must short-circuit before any - // syscall so renderers never see the exception. - const longSegment = "amálpthgadasJennzier".repeat(40); - const overlong = `${longSegment}/inner.ts`; - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(() => getIndentation(overlong)).not.toThrow(); - expect(getIndentation(overlong)).toBe(3); - }); - - it("normalizes paths before rejecting overlong components", async () => { - const longSegment = "amálpthgadasJennzier".repeat(40); - const noisyPath = path.join(tempDir, longSegment, "..", "src", "feature.ts"); - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(" ".repeat(getIndentation(noisyPath))).toBe(" "); - }); - - it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { - // Defense in depth: when a non-directory sits where a directory is - // expected, `parseCachedEditorConfig` previously caught only `ENOENT` - // and let `ENOTDIR` (and `ENAMETOOLONG`, `EACCES`, `ELOOP`, …) - // escape. Editorconfig discovery is best-effort and must absorb any - // `FsError`. - const notADir = path.join(tempDir, "not-a-dir"); - await fs.writeFile(notADir, ""); - const fakeChild = path.join(notADir, "inner.ts"); - expect(() => getIndentation(fakeChild)).not.toThrow(); - expect(getIndentation(fakeChild)).toBe(3); - }); -}); +import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils/tab-spacing"; describe("getEditorConfigFormatting", () => { let tempDir = ""; @@ -176,4 +87,12 @@ describe("getEditorConfigFormatting", () => { expect(() => getEditorConfigFormatting(overlong)).not.toThrow(); expect(getEditorConfigFormatting(overlong)).toEqual({}); }); + + it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { + const notADir = path.join(tempDir, "not-a-dir"); + await fs.writeFile(notADir, ""); + const fakeChild = path.join(notADir, "inner.ts"); + expect(() => getEditorConfigFormatting(fakeChild)).not.toThrow(); + expect(getEditorConfigFormatting(fakeChild)).toEqual({}); + }); }); diff --git a/packages/utils/test/stream.test.ts b/packages/utils/test/stream.test.ts index ec36218e8..618330030 100644 --- a/packages/utils/test/stream.test.ts +++ b/packages/utils/test/stream.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { createAbortableStream } from "@oh-my-pi/pi-utils"; import { sanitizeText } from "@oh-my-pi/pi-utils/sanitize-text"; import { parseJsonlLenient, @@ -64,27 +63,60 @@ describe("readLines", () => { }); }); -describe("createAbortableStream", () => { - it("cancels the source stream when the abort signal fires", async () => { +describe("abortableSource (via readLines)", () => { + it("cancels the source and stops yielding when aborted mid-stream", async () => { let cancelReason: unknown; + let cancelled = false; const controller = new AbortController(); const readable = new ReadableStream({ start(streamController) { + // One complete line, then leave the stream open so the next read blocks. streamController.enqueue(encoder.encode("alpha\n")); }, cancel(reason) { + cancelled = true; cancelReason = reason; }, }); - const reader = createAbortableStream(readable, controller.signal).getReader(); - const first = await reader.read(); + const dec = new TextDecoder(); + const iter = readLines(readable, controller.signal)[Symbol.asyncIterator](); + + const first = await iter.next(); expect(first.done).toBe(false); - controller.abort("timeout"); - await reader.read().catch(() => undefined); + expect(dec.decode(first.value as Uint8Array)).toBe("alpha"); + controller.abort("timeout"); + const next = await iter.next(); + + expect(next.done).toBe(true); + expect(cancelled).toBe(true); expect(cancelReason).toBe("timeout"); }); + + it("cancels the source when the consumer breaks early", async () => { + let cancelled = false; + const readable = new ReadableStream({ + start(streamController) { + streamController.enqueue(encoder.encode("alpha\n")); + streamController.enqueue(encoder.encode("beta\n")); + // Stays open: only a `break` (not EOF) should trigger cancel. + }, + cancel() { + cancelled = true; + }, + }); + + const dec = new TextDecoder(); + const lines: string[] = []; + for await (const line of readLines(readable)) { + lines.push(dec.decode(line)); + break; + } + + expect(lines).toEqual(["alpha"]); + expect(cancelled).toBe(true); + }); }); describe("readJsonl", () => { diff --git a/packages/wire/package.json b/packages/wire/package.json index 9a5ef78e4..59b6312c0 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "16.0.7", + "version": "16.1.2", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index c4822838c..e6ce733a9 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -345,6 +345,9 @@ export type WireFrame = GuestFrame | HostFrame; /** Wire protocol version carried in `hello`; the host rejects mismatches. */ export const COLLAB_PROTO = 1; +/** Parameter key used for intent tracing (e.g. prompt explanation/reasoning) */ +export const INTENT_FIELD = "_i"; + // ═══════════════════════════════════════════════════════════════════════════ // Envelope & link constants // ═══════════════════════════════════════════════════════════════════════════ diff --git a/patches/beautiful-mermaid@1.1.3.patch b/patches/beautiful-mermaid@1.1.3.patch deleted file mode 100644 index c41d4e55e..000000000 --- a/patches/beautiful-mermaid@1.1.3.patch +++ /dev/null @@ -1,1682 +0,0 @@ -diff --git a/dist/index.d.ts b/dist/index.d.ts -index 6239e30019b1bc36d0722921db4ecc84f9b277a6..8ef7097c0a90912fa301efe44d49dc02c70263df 100644 ---- a/dist/index.d.ts -+++ b/dist/index.d.ts -@@ -224,6 +224,14 @@ interface AsciiRenderOptions { - paddingY?: number; - /** Padding inside node boxes. Default: 1 */ - boxBorderPadding?: number; -+ /** -+ * Force the layout direction, overriding the direction parsed from the -+ * diagram source. Applies to the flowchart + state-diagram grid pipeline -+ * (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a -+ * wide `LR` graph into a narrow viewport by laying it out top-down. -+ * Default: undefined (use the source's own direction). -+ */ -+ direction?: Direction; - /** - * Color mode for output. - * - 'none': No colors (plain text) -diff --git a/dist/index.js b/dist/index.js -index e7e087bcb6f29d0d8f36cd558e0183bc0f162fcb..ef3a86433637287555d889f292ec3ddae63f4fa9 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -219,6 +219,45 @@ var EMOJI_REGEX = /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u; - function isEmoji(char) { - return EMOJI_REGEX.test(char); - } -+var WIDE_PAD = "\0"; -+var EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u; -+var ZWJ = "\u200D"; -+var VS16 = "\uFE0F"; -+var graphemeSegmenter = new Intl.Segmenter(); -+function clusterWidth(cluster) { -+ const first = cluster.codePointAt(0) ?? 0; -+ if (first >= 127462 && first <= 127487) return 2; -+ if (cluster.includes(ZWJ)) return 2; -+ const base = String.fromCodePoint(first); -+ let width = isFullwidth(first) || EMOJI_PRESENTATION.test(base) ? 2 : 1; -+ if (width === 1 && cluster.includes(VS16)) width = 2; -+ return width; -+} -+function displayWidth(text) { -+ let ascii = true; -+ for (let i = 0; i < text.length; i++) { -+ if (text.charCodeAt(i) > 126) { -+ ascii = false; -+ break; -+ } -+ } -+ if (ascii) return text.length; -+ let width = 0; -+ for (const seg of graphemeSegmenter.segment(text)) { -+ width += clusterWidth(seg.segment); -+ } -+ return width; -+} -+function toCells(text) { -+ const cells = []; -+ for (const seg of graphemeSegmenter.segment(text)) { -+ cells.push(seg.segment); -+ if (clusterWidth(seg.segment) === 2) { -+ cells.push(WIDE_PAD); -+ } -+ } -+ return cells; -+} - function getCharWidth(char) { - const code = char.codePointAt(0); - if (code === void 0) return 0; -@@ -1102,8 +1141,17 @@ var JUNCTION_CHARS = /* @__PURE__ */ new Set([ - function isJunctionChar(c) { - return JUNCTION_CHARS.has(c); - } --function isAlphanumeric(c) { -- return /^[a-zA-Z0-9]$/.test(c); -+function isLabelChar(c) { -+ return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c); -+} -+function writeCell(canvas, x, y, c) { -+ const current = canvas[x][y]; -+ if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) { -+ canvas[x - 1][y] = " "; -+ } else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) { -+ canvas[x + 1][y] = " "; -+ } -+ canvas[x][y] = c; - } - var JUNCTION_MAP = { - "\u2500": { "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u2534" }, -@@ -1139,16 +1187,21 @@ function mergeCanvases(base, offset, useAscii, ...overlays) { - for (let x = 0; x < overlay.length; x++) { - for (let y = 0; y < overlay[0].length; y++) { - const c = overlay[x][y]; -- if (c !== " ") { -- const mx = x + offset.x; -- const my = y + offset.y; -- const current = merged[mx][my]; -- if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { -- merged[mx][my] = mergeJunctions(current, c); -- } else if (isAlphanumeric(current) && isAlphanumeric(c)) { -- } else { -- merged[mx][my] = c; -+ if (c === " " || c === WIDE_PAD) continue; -+ const mx = x + offset.x; -+ const my = y + offset.y; -+ const current = merged[mx][my]; -+ const isWide = overlay[x + 1]?.[y] === WIDE_PAD; -+ if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { -+ merged[mx][my] = mergeJunctions(current, c); -+ } else if (isWide) { -+ if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? " ")) { -+ writeCell(merged, mx, my, c); -+ writeCell(merged, mx + 1, my, WIDE_PAD); - } -+ } else if (isLabelChar(current) && isLabelChar(c)) { -+ } else { -+ writeCell(merged, mx, my, c); - } - } - } -@@ -1165,14 +1218,17 @@ function canvasToString(canvas, options) { - if (colorMode === "none" || !roleCanvas) { - let line = ""; - for (let x = 0; x <= maxX; x++) { -- line += canvas[x][y]; -+ const c = canvas[x][y]; -+ if (c !== WIDE_PAD) line += c; - } - lines.push(line); - } else { - const chars = []; - const roles = []; - for (let x = 0; x <= maxX; x++) { -- chars.push(canvas[x][y]); -+ const c = canvas[x][y]; -+ if (c === WIDE_PAD) continue; -+ chars.push(c); - roles.push(roleCanvas[x]?.[y] ?? null); - } - lines.push(colorizeLine(chars, roles, theme, colorMode)); -@@ -1222,12 +1278,20 @@ function flipRoleCanvasVertically(roleCanvas) { - return roleCanvas; - } - function drawText(canvas, start, text, forceOverwrite = false) { -- increaseSize(canvas, start.x + text.length, start.y); -- for (let i = 0; i < text.length; i++) { -+ const cells = toCells(text); -+ increaseSize(canvas, start.x + cells.length, start.y); -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]; -+ if (cell === WIDE_PAD) continue; - const x = start.x + i; -- const current = canvas[x][start.y]; -- if (forceOverwrite || current === " ") { -- canvas[x][start.y] = text[i]; -+ if (cells[i + 1] === WIDE_PAD) { -+ const pairFree = canvas[x][start.y] === " " && canvas[x + 1][start.y] === " "; -+ if (forceOverwrite || pairFree) { -+ writeCell(canvas, x, start.y, cell); -+ writeCell(canvas, x + 1, start.y, WIDE_PAD); -+ } -+ } else if (forceOverwrite || canvas[x][start.y] === " ") { -+ writeCell(canvas, x, start.y, cell); - } - } - } -@@ -1690,7 +1754,7 @@ function determinePath(graph, edge) { - } - function determineLabelLine(graph, edge) { - if (edge.text.length === 0) return; -- const lenLabel = edge.text.length; -+ const lenLabel = displayWidth(edge.text); - const pathLen = edge.path.length; - const isVerticalFlow = graph.config.graphDirection === "TD"; - const segments = []; -@@ -1897,7 +1961,7 @@ function splitLines(label) { - } - function maxLineWidth(label) { - const lines = splitLines(label); -- return Math.max(...lines.map((l) => l.length), 0); -+ return Math.max(...lines.map((l) => displayWidth(l)), 0); - } - function lineCount(label) { - return splitLines(label).length; -@@ -1981,7 +2045,7 @@ function getCorners(shape, useAscii) { - // src/ascii/shapes/rectangle.ts - function getBoxDimensions(label, options) { - const lines = splitLines(label); -- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); -+ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); - const lineCount3 = lines.length; - const innerWidth = 2 * options.padding + maxLineWidth3; - const width = innerWidth + 2; -@@ -2028,12 +2092,13 @@ function renderBox(label, dimensions, corners, useAscii) { - const startY = centerY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; -- const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1; -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line); -+ const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1; -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j; - const y = startY + i; - if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0].length) { -- canvas[x][y] = line[j]; -+ canvas[x][y] = cells[j]; - } - } - } -@@ -2214,7 +2279,7 @@ var roundedRenderer = { - var stadiumRenderer = { - getDimensions(label, options) { - const lines = splitLines(label); -- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); -+ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); - const lineCount3 = lines.length; - const innerWidth = 2 * options.padding + maxLineWidth3; - const width = innerWidth + 4; -@@ -2266,12 +2331,13 @@ var stadiumRenderer = { - const startY = centerY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line); -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j; - const y = startY + i; - if (x > 0 && x < width - 1 && y >= 0 && y < height) { -- canvas[x][y] = line[j]; -+ canvas[x][y] = cells[j]; - } - } - } -@@ -2294,7 +2360,7 @@ var hexagonRenderer = { - var subroutineRenderer = { - getDimensions(label, options) { - const lines = splitLines(label); -- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); -+ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); - const lineCount3 = lines.length; - const innerWidth = 2 * options.padding + maxLineWidth3; - const width = innerWidth + 4; -@@ -2339,12 +2405,13 @@ var subroutineRenderer = { - const startY = centerY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line); -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j; - const y = startY + i; - if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { -- canvas[x][y] = line[j]; -+ canvas[x][y] = cells[j]; - } - } - } -@@ -2363,7 +2430,7 @@ var doublecircleRenderer = { - var cylinderRenderer = { - getDimensions(label, options) { - const lines = splitLines(label); -- const maxLineWidth3 = Math.max(...lines.map((l) => l.length), 0); -+ const maxLineWidth3 = Math.max(...lines.map((l) => displayWidth(l)), 0); - const lineCount3 = lines.length; - const innerWidth = 2 * options.padding + maxLineWidth3; - const width = innerWidth + 2; -@@ -2408,12 +2475,13 @@ var cylinderRenderer = { - const startY = centerY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2); -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line); -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2); -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j; - const y = startY + i; - if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { -- canvas[x][y] = line[j]; -+ canvas[x][y] = cells[j]; - } - } - } -@@ -2517,10 +2585,11 @@ function drawBoxWithGridDimensions(node, graph) { - const startY = textCenterY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; -- const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1; -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line); -+ const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1; -+ for (let j = 0; j < cells.length; j++) { - if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0].length) { -- box[textX + j][startY + i] = line[j]; -+ box[textX + j][startY + i] = cells[j]; - } - } - } -@@ -2533,7 +2602,7 @@ function drawMultiBox(sections, useAscii, padding = 1) { - let maxTextWidth = 0; - for (const section of sections) { - for (const line of section) { -- maxTextWidth = Math.max(maxTextWidth, line.length); -+ maxTextWidth = Math.max(maxTextWidth, displayWidth(line)); - } - } - const innerWidth = maxTextWidth + 2 * padding; -@@ -2569,8 +2638,9 @@ function drawMultiBox(sections, useAscii, padding = 1) { - const lines = section.length > 0 ? section : [""]; - for (const line of lines) { - const startX = 1 + padding; -- for (let i = 0; i < line.length; i++) { -- canvas[startX + i][row] = line[i]; -+ const cells = toCells(line); -+ for (let i = 0; i < cells.length; i++) { -+ canvas[startX + i][row] = cells[i]; - } - row++; - } -@@ -2862,7 +2932,7 @@ function drawTextOnLine(canvas, line, label, isUpwardEdge) { - const startY = middleY - Math.floor((lines.length - 1) / 2); - for (let i = 0; i < lines.length; i++) { - const lineText = lines[i]; -- const startX = middleX - Math.floor(lineText.length / 2); -+ const startX = middleX - Math.floor(displayWidth(lineText) / 2); - drawText(canvas, { x: startX, y: startY + i }, lineText); - } - } -@@ -3162,12 +3232,17 @@ function drawSubgraphLabel(sg, graph) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const labelY = 1 + i; -- let labelX = Math.floor(width / 2) - Math.floor(line.length / 2); -+ let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2); - if (labelX < 1) labelX = 1; -- for (let j = 0; j < line.length; j++) { -- if (labelX + j < width && labelY < height) { -- canvas[labelX + j][labelY] = line[j]; -- } -+ const cells = toCells(line); -+ for (let j = 0; j < cells.length; j++) { -+ const cell = cells[j]; -+ if (cell === WIDE_PAD) continue; -+ const cx = labelX + j; -+ const wide = cells[j + 1] === WIDE_PAD; -+ if (cx + (wide ? 1 : 0) >= width || labelY >= height) continue; -+ canvas[cx][labelY] = cell; -+ if (wide) canvas[cx + 1][labelY] = WIDE_PAD; - } - } - return [canvas, { x: sg.minX, y: sg.minY }]; -@@ -3864,7 +3939,7 @@ function renderSequenceAscii(text, config, colorMode, theme) { - curY += 1; - const note = diagram.notes[n]; - const nLines = splitLines(note.text); -- const nWidth = Math.max(...nLines.map((l) => l.length)) + 4; -+ const nWidth = Math.max(...nLines.map((l) => displayWidth(l))) + 4; - const nHeight = nLines.length + 2; - const aIdx = actorIdx.get(note.actorIds[0]) ?? 0; - let nx; -@@ -3903,7 +3978,7 @@ function renderSequenceAscii(text, config, colorMode, theme) { - const msg = diagram.messages[m]; - if (msg.from === msg.to) { - const fi = actorIdx.get(msg.from); -- const selfRight = llX[fi] + 6 + 2 + msg.label.length; -+ const selfRight = llX[fi] + 6 + 2 + displayWidth(msg.label); - totalW = Math.max(totalW, selfRight + 1); - } - } -@@ -3918,6 +3993,18 @@ function renderSequenceAscii(text, config, colorMode, theme) { - setRole(rc, x, y, role); - } - } -+ function setCells(x0, y, cells, role, minX = 0, maxXExcl = canvas.length) { -+ const limit = Math.min(maxXExcl, canvas.length); -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]; -+ if (cell === WIDE_PAD) continue; -+ const x = x0 + i; -+ const wide = cells[i + 1] === WIDE_PAD; -+ if (x < minX || x + (wide ? 1 : 0) >= limit) continue; -+ setC(x, y, cell, role); -+ if (wide) setC(x + 1, y, WIDE_PAD, role); -+ } -+ } - function drawActorBox(cx, topY, label) { - const lines2 = splitLines(label); - const maxW = maxLineWidth(label); -@@ -3932,10 +4019,9 @@ function renderSequenceAscii(text, config, colorMode, theme) { - setC(left, row, V, "border"); - setC(left + w - 1, row, V, "border"); - const line = lines2[i]; -- const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2); -- for (let j = 0; j < line.length; j++) { -- setC(ls + j, row, line[j], "text"); -- } -+ const cells = toCells(line); -+ const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2); -+ setCells(ls, row, cells, "text"); - } - const bottomY = topY + h - 1; - setC(left, bottomY, BL, "border"); -@@ -3975,9 +4061,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { - setC(fromX + loopW, y0, useAscii ? "+" : "\u2510", "corner"); - setC(fromX + loopW, y0 + 1, V, "line"); - const labelX = fromX + loopW + 2; -- for (let i = 0; i < msg.label.length; i++) { -- if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i], "text"); -- } -+ const selfCells = toCells(msg.label); -+ setCells(labelX, y0 + 1, selfCells, "text", 0, totalW); - const arrowChar = isFilled ? useAscii ? "<" : "\u25C0" : useAscii ? "<" : "\u25C1"; - setC(fromX, y0 + 2, arrowChar, "arrow"); - for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, "line"); -@@ -3989,13 +4074,10 @@ function renderSequenceAscii(text, config, colorMode, theme) { - const midX = Math.floor((fromX + toX) / 2); - const msgLines = splitLines(msg.label); - for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { -- const line = msgLines[lineIdx]; -- const labelStart = midX - Math.floor(line.length / 2); -+ const cells = toCells(msgLines[lineIdx]); -+ const labelStart = midX - Math.floor(cells.length / 2); - const y = labelY + lineIdx; -- for (let i = 0; i < line.length; i++) { -- const lx = labelStart + i; -- if (lx >= 0 && lx < totalW) setC(lx, y, line[i], "text"); -- } -+ setCells(labelStart, y, cells, "text", 0, totalW); - } - if (leftToRight) { - for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, "line"); -@@ -4031,10 +4113,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { - const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type; - const hdrLines = splitLines(hdrLabel); - for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { -- const line = hdrLines[lineIdx]; -- for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) { -- setC(bLeft + 1 + i, topY + lineIdx, line[i], "text"); -- } -+ const cells = toCells(hdrLines[lineIdx]); -+ setCells(bLeft + 1, topY + lineIdx, cells, "text", bLeft + 1, bRight); - } - setC(bLeft, botY, BL, "border"); - for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, "border"); -@@ -4052,10 +4132,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { - setC(bRight, dY, JR, "junction"); - const dLabel = block.dividers[d].label; - if (dLabel) { -- const dStr = `[${dLabel}]`; -- for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) { -- setC(bLeft + 1 + i, dY, dStr[i], "text"); -- } -+ const dCells = toCells(`[${dLabel}]`); -+ setCells(bLeft + 1, dY, dCells, "text", bLeft + 1, bRight); - } - } - } -@@ -4069,9 +4147,8 @@ function renderSequenceAscii(text, config, colorMode, theme) { - const ly = np.y + 1 + l; - setC(np.x, ly, V, "border"); - setC(np.x + np.width - 1, ly, V, "border"); -- for (let i = 0; i < np.lines[l].length; i++) { -- setC(np.x + 2 + i, ly, np.lines[l][i], "text"); -- } -+ const cells = toCells(np.lines[l]); -+ setCells(np.x + 2, ly, cells, "text"); - } - const by = np.y + np.height - 1; - setC(np.x, by, BL, "border"); -@@ -4373,7 +4450,7 @@ function renderClassAscii(text, config, colorMode, theme) { - classSections.set(cls.id, sections); - let maxTextW = 0; - for (const section of sections) { -- for (const line of section) maxTextW = Math.max(maxTextW, line.length); -+ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)); - } - const boxW = maxTextW + 4; - let totalLines = 0; -@@ -4689,7 +4766,7 @@ function renderClassAscii(text, config, colorMode, theme) { - } - if (rel.label) { - const lines2 = splitLines(rel.label); -- const maxLabelWidth = Math.max(...lines2.map((l) => l.length)) + 2; -+ const maxLabelWidth = Math.max(...lines2.map((l) => displayWidth(l))) + 2; - let baseMidY; - let idealMidX; - if (fromBY < toTY) { -@@ -4740,18 +4817,19 @@ function renderClassAscii(text, config, colorMode, theme) { - const startY = labelY - halfHeight; - for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { - const paddedLine = ` ${lines2[lineIdx]} `; -- const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2); -+ const cells = toCells(paddedLine); -+ const idealLabelStart = idealMidX - Math.floor(cells.length / 2); - const labelStart = Math.max(0, idealLabelStart); - const y = startY + lineIdx; -- const labelEnd = labelStart + paddedLine.length; -+ const labelEnd = labelStart + cells.length; - if (labelEnd > 0 && y >= 0) { - increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)); - increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)); - } -- for (let i = 0; i < paddedLine.length; i++) { -+ for (let i = 0; i < cells.length; i++) { - const lx = labelStart + i; - if (lx >= 0 && y >= 0) { -- setC(lx, y, paddedLine[i], "text"); -+ setC(lx, y, cells[i], "text"); - } - } - } -@@ -4950,7 +5028,7 @@ function renderErAscii(text, config, colorMode, theme) { - entitySections.set(ent.id, sections); - let maxTextW = 0; - for (const section of sections) { -- for (const line of section) maxTextW = Math.max(maxTextW, line.length); -+ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)); - } - const boxW = maxTextW + 4; - let totalLines = 0; -@@ -5060,15 +5138,19 @@ function renderErAscii(text, config, colorMode, theme) { - const gapMid = Math.floor((startX + endX) / 2); - for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { - const line = lines2[lineIdx]; -- const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2)); -+ const cells = toCells(line); -+ const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2)); - const labelY = lineY + 1 + lineIdx; -- increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)); -- increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)); -- for (let i = 0; i < line.length; i++) { -+ increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)); -+ increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)); -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]; -+ if (cell === WIDE_PAD) continue; - const lx = labelStart + i; -- if (lx >= startX && lx <= endX) { -- setC(lx, labelY, line[i], "text"); -- } -+ const wide = cells[i + 1] === WIDE_PAD; -+ if (lx < startX || lx + (wide ? 1 : 0) > endX) continue; -+ setC(lx, labelY, cell, "text"); -+ if (wide) setC(lx + 1, labelY, WIDE_PAD, "text"); - } - } - } -@@ -5107,16 +5189,16 @@ function renderErAscii(text, config, colorMode, theme) { - const midY = Math.floor((startY + endY) / 2); - const startLabelY = midY - Math.floor((lines2.length - 1) / 2); - for (let lineIdx = 0; lineIdx < lines2.length; lineIdx++) { -- const line = lines2[lineIdx]; -+ const cells = toCells(lines2[lineIdx]); - const labelX = lineX + 2; - const y = startLabelY + lineIdx; - if (y >= 0) { -- for (let i = 0; i < line.length; i++) { -+ for (let i = 0; i < cells.length; i++) { - const lx = labelX + i; - if (lx >= 0) { - increaseSize(canvas, lx + 1, y + 1); - increaseRoleCanvasSize(rc, lx + 1, y + 1); -- setC(lx, y, line[i], "text"); -+ setC(lx, y, cells[i], "text"); - } - } - } -@@ -5343,7 +5425,7 @@ function renderVertical(chart, ch, colorMode, theme) { - const yRange = chart.yAxis.range; - const yTicks = niceTickValues(yRange.min, yRange.max); - const yLabels = yTicks.map((v) => formatTickValue(v)); -- const yGutter = Math.max(...yLabels.map((l) => l.length)) + 1; -+ const yGutter = Math.max(...yLabels.map((l) => displayWidth(l))) + 1; - const plotW = Math.max(PLOT_WIDTH, dataCount * 6); - const plotH = PLOT_HEIGHT; - const bandW = Math.floor(plotW / dataCount); -@@ -5369,7 +5451,7 @@ function renderVertical(chart, ch, colorMode, theme) { - }; - const bandCenter = (i) => plotLeft + Math.floor(bandW * (i + 0.5)); - if (hasTitle && titleRow >= 0) { -- writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title.length / 2), chart.title, "text"); -+ writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title) / 2), chart.title, "text"); - } - if (hasLegend) { - const legendRow = hasTitle ? 1 : 0; -@@ -5386,7 +5468,7 @@ function renderVertical(chart, ch, colorMode, theme) { - const displayRow = plotTop + (plotH - 1 - row); - const label = formatTickValue(tick); - set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, "border"); -- const labelStart = yGutter - label.length; -+ const labelStart = yGutter - displayWidth(label); - writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, "text"); - } - for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { -@@ -5396,12 +5478,12 @@ function renderVertical(chart, ch, colorMode, theme) { - const cx = bandCenter(i); - set(canvas, roles, xAxisRow, cx, ch.xTick, "border"); - const label = catLabels[i]; -- const labelStart = cx - Math.floor(label.length / 2); -+ const labelStart = cx - Math.floor(displayWidth(label) / 2); - writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, "text"); - } - if (hasXTitle && xTitleRow >= 0) { - const title = chart.xAxis.title; -- writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, "text"); -+ writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, "text"); - } - for (const tick of yTicks) { - const row = valueToRow(tick); -@@ -5459,7 +5541,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { - const yRange = chart.yAxis.range; - const valueTicks = niceTickValues(yRange.min, yRange.max); - const catLabels = getCategoryLabels(chart, dataCount); -- const catGutter = Math.max(...catLabels.map((l) => l.length)) + 1; -+ const catGutter = Math.max(...catLabels.map((l) => displayWidth(l))) + 1; - const plotW = Math.max(PLOT_WIDTH, 40); - const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)); - const plotH = bandH * dataCount; -@@ -5481,7 +5563,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { - }; - const bandMid = (i) => plotTop + Math.floor(bandH * (i + 0.5)); - if (hasTitle) { -- writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title.length / 2), chart.title, "text"); -+ writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title) / 2), chart.title, "text"); - } - if (hasLegend) { - const legendRow = hasTitle ? 1 : 0; -@@ -5494,7 +5576,7 @@ function renderHorizontal(chart, ch, colorMode, theme) { - for (let i = 0; i < dataCount; i++) { - const my = bandMid(i); - const label = catLabels[i]; -- const labelStart = catGutter - label.length; -+ const labelStart = catGutter - displayWidth(label); - writeText(canvas, roles, my, Math.max(0, labelStart), label, "text"); - } - for (let c = plotLeft; c < plotLeft + plotW; c++) { -@@ -5505,11 +5587,11 @@ function renderHorizontal(chart, ch, colorMode, theme) { - if (cx < plotLeft || cx >= plotLeft + plotW) continue; - set(canvas, roles, xAxisRow, cx, ch.xTick, "border"); - const label = formatTickValue(tick); -- writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, "text"); -+ writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, "text"); - } - if (hasYTitle) { - const title = chart.yAxis.title; -- writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, "text"); -+ writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, "text"); - } - for (const tick of valueTicks) { - const cx = valueToCol(tick); -@@ -5685,7 +5767,7 @@ function drawLegend(canvas, roles, hexCanvas, chart, row, totalW, ch, seriesColo - let totalLen = 0; - for (let i = 0; i < items.length; i++) { - if (i > 0) totalLen += 2; -- totalLen += 1 + 1 + items[i].label.length; -+ totalLen += 1 + 1 + displayWidth(items[i].label); - } - const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)); - let col = startCol; -@@ -5696,7 +5778,7 @@ function drawLegend(canvas, roles, hexCanvas, chart, row, totalW, ch, seriesColo - col += 1; - col += 1; - writeText(canvas, roles, row, col, item.label, "text"); -- col += item.label.length; -+ col += displayWidth(item.label); - } - } - function createCanvas(width, height) { -@@ -5722,8 +5804,15 @@ function get(canvas, row, col) { - return " "; - } - function writeText(canvas, roles, row, startCol, text, role) { -- for (let i = 0; i < text.length; i++) { -- set(canvas, roles, row, startCol + i, text[i], role); -+ const cells = toCells(text); -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]; -+ if (cell === WIDE_PAD) continue; -+ const col = startCol + i; -+ const wide = cells[i + 1] === WIDE_PAD; -+ if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue; -+ set(canvas, roles, row, col, cell, role); -+ if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role); - } - } - function canvasToString2(canvas, roles, hexCanvas, colorMode, theme) { -@@ -5736,7 +5825,9 @@ function canvasToString2(canvas, roles, hexCanvas, colorMode, theme) { - const rowRoles = []; - const rowHex = []; - for (let col = 0; col < width; col++) { -- chars.push(canvas[col][row]); -+ const c = canvas[col][row]; -+ if (c === WIDE_PAD) continue; -+ chars.push(c); - rowRoles.push(roles[col][row]); - rowHex.push(hexCanvas[col][row]); - } -@@ -5865,6 +5956,9 @@ function renderMermaidASCII(text, options = {}) { - case "flowchart": - default: { - const parsed = parseMermaid(text); -+ if (options.direction) { -+ parsed.direction = options.direction; -+ } - if (parsed.direction === "LR" || parsed.direction === "RL") { - config.graphDirection = "LR"; - } else { -diff --git a/src/ascii/canvas.ts b/src/ascii/canvas.ts -index e9949feb129161f010483e74ab9c91d918fdab6c..22a003199fe8f07f4697ebf1891051bd03d69beb 100644 ---- a/src/ascii/canvas.ts -+++ b/src/ascii/canvas.ts -@@ -8,6 +8,7 @@ - - import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' - import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts' -+import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' - - /** - * Create a blank canvas filled with spaces. -@@ -178,9 +179,33 @@ export function isJunctionChar(c: string): boolean { - return JUNCTION_CHARS.has(c) - } - --/** Check if a character is alphanumeric (part of a label). */ --function isAlphanumeric(c: string): boolean { -- return /^[a-zA-Z0-9]$/.test(c) -+/** -+ * Check if a cell holds label content for first-label-wins collision -+ * handling during merges: letters/digits in any script, the continuation -+ * cell of a wide glyph, or any 2-column glyph. Wide glyphs are only ever -+ * produced by labels (CJK ideographs, Hangul, emoji) — the renderer's own -+ * structural glyphs (borders, the narrow arrowheads ◀▶) are 1 column — so -+ * width 2 is a sufficient signal for emoji labels (🚀, 🇨🇳, 👍🏽) that the -+ * letter/digit test misses. -+ */ -+function isLabelChar(c: string): boolean { -+ return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) -+} -+ -+/** -+ * Write one cell, dissolving any wide-glyph pair the write would split: -+ * overwriting a WIDE_PAD orphans its lead, and overwriting a lead orphans -+ * its pad — the orphaned half becomes a space so serialized rows keep -+ * exactly one column per cell. -+ */ -+function writeCell(canvas: Canvas, x: number, y: number, c: string): void { -+ const current = canvas[x]![y]! -+ if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) { -+ canvas[x - 1]![y] = ' ' -+ } else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) { -+ canvas[x + 1]![y] = ' ' -+ } -+ canvas[x]![y] = c - } - - /** -@@ -243,18 +268,25 @@ export function mergeCanvases( - for (let x = 0; x < overlay.length; x++) { - for (let y = 0; y < overlay[0]!.length; y++) { - const c = overlay[x]![y]! -- if (c !== ' ') { -- const mx = x + offset.x -- const my = y + offset.y -- const current = merged[mx]![my]! -- if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { -- merged[mx]![my] = mergeJunctions(current, c) -- } else if (isAlphanumeric(current) && isAlphanumeric(c)) { -- // Don't overwrite existing label text with new label text -- // This prevents label collisions (first label wins) -- } else { -- merged[mx]![my] = c -+ // WIDE_PAD cells are written atomically with their lead below -+ if (c === ' ' || c === WIDE_PAD) continue -+ const mx = x + offset.x -+ const my = y + offset.y -+ const current = merged[mx]![my]! -+ const isWide = overlay[x + 1]?.[y] === WIDE_PAD -+ if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { -+ merged[mx]![my] = mergeJunctions(current, c) -+ } else if (isWide) { -+ // Wide glyphs land or yield as a whole pair (first label wins) -+ if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? ' ')) { -+ writeCell(merged, mx, my, c) -+ writeCell(merged, mx + 1, my, WIDE_PAD) - } -+ } else if (isLabelChar(current) && isLabelChar(c)) { -+ // Don't overwrite existing label text with new label text -+ // This prevents label collisions (first label wins) -+ } else { -+ writeCell(merged, mx, my, c) - } - } - } -@@ -294,7 +326,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): - // Plain text output — no colors - let line = '' - for (let x = 0; x <= maxX; x++) { -- line += canvas[x]![y]! -+ const c = canvas[x]![y]! -+ // Skip wide-glyph continuation cells: the glyph itself spans 2 columns -+ if (c !== WIDE_PAD) line += c - } - lines.push(line) - } else { -@@ -302,7 +336,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): - const chars: string[] = [] - const roles: (CharRole | null)[] = [] - for (let x = 0; x <= maxX; x++) { -- chars.push(canvas[x]![y]!) -+ const c = canvas[x]![y]! -+ if (c === WIDE_PAD) continue -+ chars.push(c) - roles.push(roleCanvas[x]?.[y] ?? null) - } - lines.push(colorizeLine(chars, roles, theme, colorMode)) -@@ -387,13 +423,22 @@ export function drawText( - text: string, - forceOverwrite = false - ): void { -- increaseSize(canvas, start.x + text.length, start.y) -- for (let i = 0; i < text.length; i++) { -+ const cells = toCells(text) -+ increaseSize(canvas, start.x + cells.length, start.y) -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]! -+ // WIDE_PAD cells are written atomically with their lead below -+ if (cell === WIDE_PAD) continue - const x = start.x + i -- const current = canvas[x]![start.y]! -- // Only write if target is empty or we're forcing overwrite -- if (forceOverwrite || current === ' ') { -- canvas[x]![start.y] = text[i]! -+ if (cells[i + 1] === WIDE_PAD) { -+ // Wide glyph: needs both its cells free (or forced) to land -+ const pairFree = canvas[x]![start.y] === ' ' && canvas[x + 1]![start.y] === ' ' -+ if (forceOverwrite || pairFree) { -+ writeCell(canvas, x, start.y, cell) -+ writeCell(canvas, x + 1, start.y, WIDE_PAD) -+ } -+ } else if (forceOverwrite || canvas[x]![start.y] === ' ') { -+ writeCell(canvas, x, start.y, cell) - } - } - } -diff --git a/src/ascii/class-diagram.ts b/src/ascii/class-diagram.ts -index 95e2c76a43a4a23f6cf1e7299c753aefed01c197..cdf20cdf012dba6c6aa8645e853e02be2d5e6ef0 100644 ---- a/src/ascii/class-diagram.ts -+++ b/src/ascii/class-diagram.ts -@@ -16,6 +16,7 @@ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } - import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' - import { drawMultiBox } from './draw.ts' - import { splitLines } from './multiline-utils.ts' -+import { displayWidth, toCells } from '../text-metrics.ts' - - /** Classify a character from a box drawing as 'border' or 'text'. */ - function classifyBoxChar(ch: string): CharRole { -@@ -170,7 +171,7 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: - // Compute box dimensions from drawMultiBox logic - let maxTextW = 0 - for (const section of sections) { -- for (const line of section) maxTextW = Math.max(maxTextW, line.length) -+ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) - } - const boxW = maxTextW + 4 // 2 border + 2 padding - -@@ -602,7 +603,7 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: - // Add padding around the label for readability - if (rel.label) { - const lines = splitLines(rel.label) -- const maxLabelWidth = Math.max(...lines.map(l => l.length)) + 2 // +2 for padding -+ const maxLabelWidth = Math.max(...lines.map(l => displayWidth(l))) + 2 // +2 for padding - - // Calculate ideal label position based on routing direction - let baseMidY: number -@@ -672,21 +673,22 @@ export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const paddedLine = ` ${lines[lineIdx]!} ` // Add space padding on both sides -+ const cells = toCells(paddedLine) - // Calculate label start, but ensure it doesn't go negative -- const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2) -+ const idealLabelStart = idealMidX - Math.floor(cells.length / 2) - const labelStart = Math.max(0, idealLabelStart) - const y = startY + lineIdx - // Ensure canvas is wide enough for the label -- const labelEnd = labelStart + paddedLine.length -+ const labelEnd = labelStart + cells.length - if (labelEnd > 0 && y >= 0) { - increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)) - increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)) - } - // Clear the area first (overwrite line characters) then draw the padded label -- for (let i = 0; i < paddedLine.length; i++) { -+ for (let i = 0; i < cells.length; i++) { - const lx = labelStart + i - if (lx >= 0 && y >= 0) { -- setC(lx, y, paddedLine[i]!, 'text') -+ setC(lx, y, cells[i]!, 'text') - } - } - } -diff --git a/src/ascii/draw.ts b/src/ascii/draw.ts -index f8020f594fc9e27cc27eeecad17e2688c3900159..e1e205def44dc1d860b22ca2e872192c4561a958 100644 ---- a/src/ascii/draw.ts -+++ b/src/ascii/draw.ts -@@ -21,6 +21,7 @@ import { gridToDrawingCoord, lineToDrawing } from './grid.ts' - import { splitLines } from './multiline-utils.ts' - import { getCorners } from './shapes/corners.ts' - import { getShapeAttachmentPoint } from './shapes/index.ts' -+import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' - - // ============================================================================ - // Node drawing — renders a node using shape-aware rendering -@@ -104,10 +105,11 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! -- const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line) -+ const textX = from.x + Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 -+ for (let j = 0; j < cells.length; j++) { - if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0]!.length) { -- box[textX + j]![startY + i] = line[j]! -+ box[textX + j]![startY + i] = cells[j]! - } - } - } -@@ -147,7 +149,7 @@ export function drawMultiBox( - let maxTextWidth = 0 - for (const section of sections) { - for (const line of section) { -- maxTextWidth = Math.max(maxTextWidth, line.length) -+ maxTextWidth = Math.max(maxTextWidth, displayWidth(line)) - } - } - const innerWidth = maxTextWidth + 2 * padding -@@ -198,8 +200,9 @@ export function drawMultiBox( - // Draw section text lines - for (const line of lines) { - const startX = 1 + padding -- for (let i = 0; i < line.length; i++) { -- canvas[startX + i]![row] = line[i]! -+ const cells = toCells(line) -+ for (let i = 0; i < cells.length; i++) { -+ canvas[startX + i]![row] = cells[i]! - } - row++ - } -@@ -673,7 +676,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU - - for (let i = 0; i < lines.length; i++) { - const lineText = lines[i]! -- const startX = middleX - Math.floor(lineText.length / 2) -+ const startX = middleX - Math.floor(displayWidth(lineText) / 2) - drawText(canvas, { x: startX, y: startY + i }, lineText) - } - } -@@ -1142,13 +1145,19 @@ export function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - const labelY = 1 + i -- let labelX = Math.floor(width / 2) - Math.floor(line.length / 2) -+ let labelX = Math.floor(width / 2) - Math.floor(displayWidth(line) / 2) - if (labelX < 1) labelX = 1 - -- for (let j = 0; j < line.length; j++) { -- if (labelX + j < width && labelY < height) { -- canvas[labelX + j]![labelY] = line[j]! -- } -+ const cells = toCells(line) -+ for (let j = 0; j < cells.length; j++) { -+ const cell = cells[j]! -+ if (cell === WIDE_PAD) continue // written atomically with its lead -+ const cx = labelX + j -+ const wide = cells[j + 1] === WIDE_PAD -+ // Keep wide-glyph pairs atomic at the subgraph edge -+ if (cx + (wide ? 1 : 0) >= width || labelY >= height) continue -+ canvas[cx]![labelY] = cell -+ if (wide) canvas[cx + 1]![labelY] = WIDE_PAD - } - } - -diff --git a/src/ascii/edge-routing.ts b/src/ascii/edge-routing.ts -index e9a9dd4383973053561f5a05bc71949971c4e13e..e69234e4e5c4f7f83a23eccdba3e63b2e1e44a21 100644 ---- a/src/ascii/edge-routing.ts -+++ b/src/ascii/edge-routing.ts -@@ -13,6 +13,7 @@ import { - } from './types.ts' - import { getPath, mergePath } from './pathfinder.ts' - import { getEffectiveDirection, getNodeSubgraph } from './grid.ts' -+import { displayWidth } from '../text-metrics.ts' - - // ============================================================================ - // Direction utilities -@@ -227,7 +228,7 @@ export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void { - export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void { - if (edge.text.length === 0) return - -- const lenLabel = edge.text.length -+ const lenLabel = displayWidth(edge.text) - const pathLen = edge.path.length - const isVerticalFlow = graph.config.graphDirection === 'TD' - -diff --git a/src/ascii/er-diagram.ts b/src/ascii/er-diagram.ts -index 7ff86b78082c9486f12ac8a70b130bd4b33ee128..66410d25ceaa8d66660c0ddfd48ca8b60c1fa27c 100644 ---- a/src/ascii/er-diagram.ts -+++ b/src/ascii/er-diagram.ts -@@ -15,6 +15,7 @@ import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } - import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' - import { drawMultiBox } from './draw.ts' - import { splitLines } from './multiline-utils.ts' -+import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' - - /** Classify a character from a box drawing as 'border' or 'text'. */ - function classifyBoxChar(ch: string): CharRole { -@@ -180,7 +181,7 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col - - let maxTextW = 0 - for (const section of sections) { -- for (const line of section) maxTextW = Math.max(maxTextW, line.length) -+ for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line)) - } - const boxW = maxTextW + 4 // 2 border + 2 padding - -@@ -344,16 +345,21 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col - // Place lines below the relationship line (lineY + 1, lineY + 2, ...) - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]! -- const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2)) -+ const cells = toCells(line) -+ const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2)) - const labelY = lineY + 1 + lineIdx - // Ensure canvas is tall enough -- increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) -- increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) -- for (let i = 0; i < line.length; i++) { -+ increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) -+ increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1)) -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]! -+ if (cell === WIDE_PAD) continue // written atomically with its lead - const lx = labelStart + i -- if (lx >= startX && lx <= endX) { -- setC(lx, labelY, line[i]!, 'text') -- } -+ const wide = cells[i + 1] === WIDE_PAD -+ // Keep wide-glyph pairs atomic within the gap region -+ if (lx < startX || lx + (wide ? 1 : 0) > endX) continue -+ setC(lx, labelY, cell, 'text') -+ if (wide) setC(lx + 1, labelY, WIDE_PAD, 'text') - } - } - } -@@ -413,16 +419,16 @@ export function renderErAscii(text: string, config: AsciiConfig, colorMode?: Col - const startLabelY = midY - Math.floor((lines.length - 1) / 2) - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { -- const line = lines[lineIdx]! -+ const cells = toCells(lines[lineIdx]!) - const labelX = lineX + 2 - const y = startLabelY + lineIdx - if (y >= 0) { -- for (let i = 0; i < line.length; i++) { -+ for (let i = 0; i < cells.length; i++) { - const lx = labelX + i - if (lx >= 0) { - increaseSize(canvas, lx + 1, y + 1) - increaseRoleCanvasSize(rc, lx + 1, y + 1) -- setC(lx, y, line[i]!, 'text') -+ setC(lx, y, cells[i]!, 'text') - } - } - } -diff --git a/src/ascii/index.ts b/src/ascii/index.ts -index 69161a9d592769abec4466421fe8ce038306d182..8eeca584904f92b8ebc484dccf03501aed51ddf4 100644 ---- a/src/ascii/index.ts -+++ b/src/ascii/index.ts -@@ -27,6 +27,7 @@ import { renderErAscii } from './er-diagram.ts' - import { renderXYChartAscii } from './xychart.ts' - import { detectColorMode, DEFAULT_ASCII_THEME, diagramColorsToAsciiTheme } from './ansi.ts' - import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts' -+import type { Direction } from '../types.ts' - - // Re-export types for external use - export type { AsciiTheme, ColorMode } -@@ -41,6 +42,14 @@ export interface AsciiRenderOptions { - paddingY?: number - /** Padding inside node boxes. Default: 1 */ - boxBorderPadding?: number -+ /** -+ * Force the layout direction, overriding the direction parsed from the -+ * diagram source. Applies to the flowchart + state-diagram grid pipeline -+ * (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a -+ * wide `LR` graph into a narrow viewport by laying it out top-down. -+ * Default: undefined (use the source's own direction). -+ */ -+ direction?: Direction - /** - * Color mode for output. - * - 'none': No colors (plain text) -@@ -138,6 +147,13 @@ export function renderMermaidASCII( - // Flowchart + state diagram pipeline (original) - const parsed = parseMermaid(text) - -+ // Honor an explicit direction override. Applied before normalization so -+ // the LR/TD split and the BT vertical-flip below behave exactly as if the -+ // source had authored this direction. -+ if (options.direction) { -+ parsed.direction = options.direction -+ } -+ - // Normalize direction for grid layout. - // BT is laid out as TD then flipped vertically after drawing. - // RL is treated as LR (full RL support not yet implemented). -diff --git a/src/ascii/multiline-utils.ts b/src/ascii/multiline-utils.ts -index 347d260f5bc50388087ffa45e196892b73a04d77..7519789014c7ebfde5d92e02a16f8331cfb43610 100644 ---- a/src/ascii/multiline-utils.ts -+++ b/src/ascii/multiline-utils.ts -@@ -8,6 +8,7 @@ - - import type { Canvas } from './types.ts' - import { drawText } from './canvas.ts' -+import { displayWidth } from '../text-metrics.ts' - - /** - * Split a label into lines. -@@ -23,7 +24,7 @@ export function splitLines(label: string): string[] { - */ - export function maxLineWidth(label: string): number { - const lines = splitLines(label) -- return Math.max(...lines.map(l => l.length), 0) -+ return Math.max(...lines.map(l => displayWidth(l)), 0) - } - - /** -@@ -53,7 +54,7 @@ export function drawMultilineTextCentered( - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - // Center each line horizontally -- const startX = cx - Math.floor(line.length / 2) -+ const startX = cx - Math.floor(displayWidth(line) / 2) - // Force overwrite for node labels (they take priority) - drawText(canvas, { x: startX, y: startY + i }, line, true) - } -diff --git a/src/ascii/sequence.ts b/src/ascii/sequence.ts -index 5ff5eaa1edc2a54828564ceb303271e436fb0642..93b3eea409b9479ad63204476f00e7675b0334fe 100644 ---- a/src/ascii/sequence.ts -+++ b/src/ascii/sequence.ts -@@ -14,6 +14,7 @@ import type { SequenceDiagram, Block } from '../sequence/types.ts' - import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' - import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' - import { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts' -+import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' - - /** Classify a box-drawing character as 'border' or 'text'. */ - function classifyBoxChar(ch: string): CharRole { -@@ -148,7 +149,7 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - curY += 1 - const note = diagram.notes[n]! - const nLines = splitLines(note.text) -- const nWidth = Math.max(...nLines.map(l => l.length)) + 4 -+ const nWidth = Math.max(...nLines.map(l => displayWidth(l))) + 4 - const nHeight = nLines.length + 2 - - // Determine x position based on note.position -@@ -198,7 +199,7 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - const msg = diagram.messages[m]! - if (msg.from === msg.to) { - const fi = actorIdx.get(msg.from)! -- const selfRight = llX[fi]! + 6 + 2 + msg.label.length -+ const selfRight = llX[fi]! + 6 + 2 + displayWidth(msg.label) - totalW = Math.max(totalW, selfRight + 1) - } - } -@@ -217,6 +218,24 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - } - } - -+ /** -+ * Write label cells starting at x0, keeping wide-glyph pairs atomic: -+ * a glyph and its WIDE_PAD continuation land together or not at all, -+ * clamped to [minX, maxXExcl) and the canvas bounds. -+ */ -+ function setCells(x0: number, y: number, cells: string[], role: CharRole, minX = 0, maxXExcl = canvas.length): void { -+ const limit = Math.min(maxXExcl, canvas.length) -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]! -+ if (cell === WIDE_PAD) continue // written atomically with its lead -+ const x = x0 + i -+ const wide = cells[i + 1] === WIDE_PAD -+ if (x < minX || x + (wide ? 1 : 0) >= limit) continue -+ setC(x, y, cell, role) -+ if (wide) setC(x + 1, y, WIDE_PAD, role) -+ } -+ } -+ - // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ---- - - function drawActorBox(cx: number, topY: number, label: string): void { -@@ -238,10 +257,9 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - setC(left + w - 1, row, V, 'border') - // Center this line within the box - const line = lines[i]! -- const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2) -- for (let j = 0; j < line.length; j++) { -- setC(ls + j, row, line[j]!, 'text') -- } -+ const cells = toCells(line) -+ const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2) -+ setCells(ls, row, cells, 'text') - } - - // Bottom border -@@ -305,9 +323,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - // Row 1: vertical on right side + label - setC(fromX + loopW, y0 + 1, V, 'line') - const labelX = fromX + loopW + 2 -- for (let i = 0; i < msg.label.length; i++) { -- if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i]!, 'text') -- } -+ const selfCells = toCells(msg.label) -+ setCells(labelX, y0 + 1, selfCells, 'text', 0, totalW) - - // Row 2: arrow-back + horizontal + bottom-right corner - const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') -@@ -325,13 +342,10 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - const msgLines = splitLines(msg.label) - - for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { -- const line = msgLines[lineIdx]! -- const labelStart = midX - Math.floor(line.length / 2) -+ const cells = toCells(msgLines[lineIdx]!) -+ const labelStart = midX - Math.floor(cells.length / 2) - const y = labelY + lineIdx -- for (let i = 0; i < line.length; i++) { -- const lx = labelStart + i -- if (lx >= 0 && lx < totalW) setC(lx, y, line[i]!, 'text') -- } -+ setCells(labelStart, y, cells, 'text', 0, totalW) - } - - // Draw arrow line -@@ -380,10 +394,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - const hdrLines = splitLines(hdrLabel) - - for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { -- const line = hdrLines[lineIdx]! -- for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) { -- setC(bLeft + 1 + i, topY + lineIdx, line[i]!, 'text') -- } -+ const cells = toCells(hdrLines[lineIdx]!) -+ setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight) - } - - // Bottom border -@@ -408,10 +420,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - // Divider label - const dLabel = block.dividers[d]!.label - if (dLabel) { -- const dStr = `[${dLabel}]` -- for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) { -- setC(bLeft + 1 + i, dY, dStr[i]!, 'text') -- } -+ const dCells = toCells(`[${dLabel}]`) -+ setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight) - } - } - } -@@ -431,9 +441,8 @@ export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode - const ly = np.y + 1 + l - setC(np.x, ly, V, 'border') - setC(np.x + np.width - 1, ly, V, 'border') -- for (let i = 0; i < np.lines[l]!.length; i++) { -- setC(np.x + 2 + i, ly, np.lines[l]![i]!, 'text') -- } -+ const cells = toCells(np.lines[l]!) -+ setCells(np.x + 2, ly, cells, 'text') - } - // Bottom border - const by = np.y + np.height - 1 -diff --git a/src/ascii/shapes/rectangle.ts b/src/ascii/shapes/rectangle.ts -index 5d35dc314ebe35e907691d92940947c8b1bffd0c..ae2f3d8e77be49ad413c6f6fa5262b24f26df99a 100644 ---- a/src/ascii/shapes/rectangle.ts -+++ b/src/ascii/shapes/rectangle.ts -@@ -13,6 +13,7 @@ import { splitLines } from '../multiline-utils.ts' - import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' - import { dirEquals } from '../edge-routing.ts' - import { type CornerChars, getCorners } from './corners.ts' -+import { displayWidth, toCells } from '../../text-metrics.ts' - - // ============================================================================ - // Shared dimension calculation -@@ -24,7 +25,7 @@ import { type CornerChars, getCorners } from './corners.ts' - */ - export function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { - const lines = splitLines(label) -- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) -+ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) - const lineCount = lines.length - - // Width: 2*padding + maxLineWidth + 2 border chars -@@ -108,12 +109,13 @@ export function renderBox( - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! -- const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line) -+ const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1 -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j - const y = startY + i - if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0]!.length) { -- canvas[x]![y] = line[j]! -+ canvas[x]![y] = cells[j]! - } - } - } -diff --git a/src/ascii/shapes/special.ts b/src/ascii/shapes/special.ts -index e7ce95d83d1c83dcaaea862f5c26316f6beaad56..6a8a5f1b2977a2d6fc98ef9078375cdeeff27696 100644 ---- a/src/ascii/shapes/special.ts -+++ b/src/ascii/shapes/special.ts -@@ -13,6 +13,7 @@ import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types - import { dirEquals } from '../edge-routing.ts' - import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' - import { getCorners } from './corners.ts' -+import { displayWidth, toCells } from '../../text-metrics.ts' - - // ============================================================================ - // Subroutine — keeps custom double-border rendering -@@ -28,7 +29,7 @@ import { getCorners } from './corners.ts' - export const subroutineRenderer: ShapeRenderer = { - getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { - const lines = splitLines(label) -- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) -+ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) - const lineCount = lines.length - - const innerWidth = 2 * options.padding + maxLineWidth -@@ -86,12 +87,13 @@ export const subroutineRenderer: ShapeRenderer = { - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line) -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j - const y = startY + i - if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { -- canvas[x]![y] = line[j]! -+ canvas[x]![y] = cells[j]! - } - } - } -@@ -142,7 +144,7 @@ export const doublecircleRenderer: ShapeRenderer = { - export const cylinderRenderer: ShapeRenderer = { - getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { - const lines = splitLines(label) -- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) -+ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) - const lineCount = lines.length - - const innerWidth = 2 * options.padding + maxLineWidth -@@ -204,12 +206,13 @@ export const cylinderRenderer: ShapeRenderer = { - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line) -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j - const y = startY + i - if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { -- canvas[x]![y] = line[j]! -+ canvas[x]![y] = cells[j]! - } - } - } -diff --git a/src/ascii/shapes/stadium.ts b/src/ascii/shapes/stadium.ts -index b400cad6ba588fa166e260c1bdfcf616645af094..c6c7af5209499b34a3df748138d09fd0bddf38de 100644 ---- a/src/ascii/shapes/stadium.ts -+++ b/src/ascii/shapes/stadium.ts -@@ -11,6 +11,7 @@ import { mkCanvas } from '../canvas.ts' - import { splitLines } from '../multiline-utils.ts' - import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' - import { getBoxAttachmentPoint } from './rectangle.ts' -+import { displayWidth, toCells } from '../../text-metrics.ts' - - /** - * Stadium (pill) shape renderer. -@@ -30,7 +31,7 @@ import { getBoxAttachmentPoint } from './rectangle.ts' - export const stadiumRenderer: ShapeRenderer = { - getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { - const lines = splitLines(label) -- const maxLineWidth = Math.max(...lines.map(l => l.length), 0) -+ const maxLineWidth = Math.max(...lines.map(l => displayWidth(l)), 0) - const lineCount = lines.length - - const innerWidth = 2 * options.padding + maxLineWidth -@@ -95,12 +96,13 @@ export const stadiumRenderer: ShapeRenderer = { - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! -- const textX = Math.floor(width / 2) - Math.floor(line.length / 2) -- for (let j = 0; j < line.length; j++) { -+ const cells = toCells(line) -+ const textX = Math.floor(width / 2) - Math.floor(cells.length / 2) -+ for (let j = 0; j < cells.length; j++) { - const x = textX + j - const y = startY + i - if (x > 0 && x < width - 1 && y >= 0 && y < height) { -- canvas[x]![y] = line[j]! -+ canvas[x]![y] = cells[j]! - } - } - } -diff --git a/src/ascii/xychart.ts b/src/ascii/xychart.ts -index 0dafc16d6a565396ac61460bdd50a07c48492430..30713e6917ee29f4decc7ae1e30846bc23c5a14e 100644 ---- a/src/ascii/xychart.ts -+++ b/src/ascii/xychart.ts -@@ -16,6 +16,7 @@ import type { XYChart } from '../xychart/types.ts' - import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types.ts' - import { colorizeText } from './ansi.ts' - import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors.ts' -+import { displayWidth, toCells, WIDE_PAD } from '../text-metrics.ts' - - // ============================================================================ - // Constants -@@ -117,7 +118,7 @@ function renderVertical( - const yRange = chart.yAxis.range! - const yTicks = niceTickValues(yRange.min, yRange.max) - const yLabels = yTicks.map(v => formatTickValue(v)) -- const yGutter = Math.max(...yLabels.map(l => l.length)) + 1 -+ const yGutter = Math.max(...yLabels.map(l => displayWidth(l))) + 1 - - const plotW = Math.max(PLOT_WIDTH, dataCount * 6) - const plotH = PLOT_HEIGHT -@@ -154,7 +155,7 @@ function renderVertical( - - // 1. Title - if (hasTitle && titleRow >= 0) { -- writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') -+ writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') - } - - // 2. Legend -@@ -179,7 +180,7 @@ function renderVertical( - // Tick mark on axis - set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border') - // Label -- const labelStart = yGutter - label.length -+ const labelStart = yGutter - displayWidth(label) - writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text') - } - -@@ -192,14 +193,14 @@ function renderVertical( - set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') - // Label below - const label = catLabels[i]! -- const labelStart = cx - Math.floor(label.length / 2) -+ const labelStart = cx - Math.floor(displayWidth(label) / 2) - writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text') - } - - // 5. X-axis title - if (hasXTitle && xTitleRow >= 0) { - const title = chart.xAxis.title! -- writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, 'text') -+ writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') - } - - // 6. Grid lines (subtle horizontal dots at y-tick positions) -@@ -279,7 +280,7 @@ function renderHorizontal( - const yRange = chart.yAxis.range! - const valueTicks = niceTickValues(yRange.min, yRange.max) - const catLabels = getCategoryLabels(chart, dataCount) -- const catGutter = Math.max(...catLabels.map(l => l.length)) + 1 -+ const catGutter = Math.max(...catLabels.map(l => displayWidth(l))) + 1 - - const plotW = Math.max(PLOT_WIDTH, 40) - const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)) -@@ -310,7 +311,7 @@ function renderHorizontal( - - // Title - if (hasTitle) { -- writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') -+ writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text') - } - - // Legend -@@ -328,7 +329,7 @@ function renderHorizontal( - for (let i = 0; i < dataCount; i++) { - const my = bandMid(i) - const label = catLabels[i]! -- const labelStart = catGutter - label.length -+ const labelStart = catGutter - displayWidth(label) - writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text') - } - -@@ -341,13 +342,13 @@ function renderHorizontal( - if (cx < plotLeft || cx >= plotLeft + plotW) continue - set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') - const label = formatTickValue(tick) -- writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, 'text') -+ writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, 'text') - } - - // Y-axis title - if (hasYTitle) { - const title = chart.yAxis.title! -- writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, 'text') -+ writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text') - } - - // Grid lines (vertical at value tick positions) -@@ -646,7 +647,7 @@ function drawLegend( - let totalLen = 0 - for (let i = 0; i < items.length; i++) { - if (i > 0) totalLen += 2 // gap between items -- totalLen += 1 + 1 + items[i]!.label.length // symbol + space + label -+ totalLen += 1 + 1 + displayWidth(items[i]!.label) // symbol + space + label - } - - const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)) -@@ -662,7 +663,7 @@ function drawLegend( - col += 1 - // Label text - writeText(canvas, roles, row, col, item.label, 'text') -- col += item.label.length -+ col += displayWidth(item.label) - } - } - -@@ -702,8 +703,16 @@ function get(canvas: Canvas, row: number, col: number): string { - } - - function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void { -- for (let i = 0; i < text.length; i++) { -- set(canvas, roles, row, startCol + i, text[i]!, role) -+ const cells = toCells(text) -+ for (let i = 0; i < cells.length; i++) { -+ const cell = cells[i]! -+ if (cell === WIDE_PAD) continue // written atomically with its lead -+ const col = startCol + i -+ const wide = cells[i + 1] === WIDE_PAD -+ // Keep wide-glyph pairs atomic at canvas bounds -+ if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue -+ set(canvas, roles, row, col, cell, role) -+ if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role) - } - } - -@@ -728,7 +737,10 @@ function canvasToString( - const rowRoles: (CharRole | null)[] = [] - const rowHex: (string | null)[] = [] - for (let col = 0; col < width; col++) { -- chars.push(canvas[col]![row]!) -+ const c = canvas[col]![row]! -+ // Skip wide-glyph continuation cells: the glyph itself spans 2 columns -+ if (c === WIDE_PAD) continue -+ chars.push(c) - rowRoles.push(roles[col]![row]!) - rowHex.push(hexCanvas[col]![row]!) - } -diff --git a/src/text-metrics.ts b/src/text-metrics.ts -index 07766ab65303221352620180c63aa592b5842a10..bcac021da55fb83781d8fd920b40a04ee6ff22cc 100644 ---- a/src/text-metrics.ts -+++ b/src/text-metrics.ts -@@ -110,6 +110,103 @@ function isEmoji(char: string): boolean { - return EMOJI_REGEX.test(char) - } - -+// ============================================================================ -+// Terminal display width — used by the ASCII renderer -+// -+// Terminals render most characters in 1 column, but East Asian wide -+// characters (CJK ideographs, Hangul, kana, fullwidth forms) and -+// emoji-presentation glyphs occupy 2 columns. The ASCII canvas is a -+// cell-per-column grid, so its width math counts display columns rather -+// than code units. -+// -+// Text is measured in grapheme clusters (Intl.Segmenter): a cluster that -+// renders 2 columns is stored as its full string in one canvas cell -+// followed by WIDE_PAD in the continuation cell it covers. The ASCII -+// serializers drop WIDE_PAD cells when joining a row, so the emitted line -+// occupies exactly as many terminal columns as the canvas has cells. -+// -+// Width policy (terminal wcwidth-style, locale-insensitive): -+// - East Asian Wide/Fullwidth code points ............ 2 -+// - Emoji_Presentation scalars (e.g. 🚀, ⌚) .......... 2 -+// - VS16 / ZWJ emoji sequences, regional-indicator flags 2 -+// - combining marks / variation selectors / ZWJ ....... 0 (within cluster) -+// - everything else (incl. text-presentation symbols -+// like ◀ ▶ △ that the renderer uses as 1-cell glyphs) 1 -+// East Asian Ambiguous characters (e.g. ½ ·) are treated as narrow. -+// ============================================================================ -+ -+/** -+ * Placeholder occupying the second cell of a fullwidth glyph on the ASCII -+ * canvas. U+0000 cannot appear in parsed Mermaid labels, is treated as -+ * occupied label content by canvas merging, and is stripped at -+ * serialization time. Invariant: a WIDE_PAD cell always sits immediately -+ * right of its lead cell; canvas writes keep the pair atomic. -+ */ -+export const WIDE_PAD = '\u0000' -+ -+/** Scalars that default to emoji presentation render 2 columns wide. */ -+const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u -+ -+const ZWJ = '\u200d' -+const VS16 = '\ufe0f' -+ -+const graphemeSegmenter = new Intl.Segmenter() -+ -+/** Display width of a single grapheme cluster in terminal columns. */ -+function clusterWidth(cluster: string): number { -+ const first = cluster.codePointAt(0) ?? 0 -+ // Regional-indicator pair renders as one 2-column flag glyph. -+ if (first >= 0x1f1e6 && first <= 0x1f1ff) return 2 -+ // ZWJ emoji sequence renders as a single 2-column glyph. -+ if (cluster.includes(ZWJ)) return 2 -+ // An extended grapheme cluster renders as one glyph at its base scalar's -+ // width; trailing extenders (combining marks, emoji/skin-tone modifiers, -+ // variation selectors) fold into the base and add no columns. -+ const base = String.fromCodePoint(first) -+ let width = isFullwidth(first) || EMOJI_PRESENTATION.test(base) ? 2 : 1 -+ // VS16 upgrades a text-presentation base to emoji presentation (2 cols). -+ if (width === 1 && cluster.includes(VS16)) width = 2 -+ return width -+} -+ -+/** -+ * Display width of a string in terminal columns, measured over grapheme -+ * clusters. ASCII-only strings take a fast path. -+ */ -+export function displayWidth(text: string): number { -+ let ascii = true -+ for (let i = 0; i < text.length; i++) { -+ if (text.charCodeAt(i) > 0x7e) { -+ ascii = false -+ break -+ } -+ } -+ if (ascii) return text.length -+ -+ let width = 0 -+ for (const seg of graphemeSegmenter.segment(text)) { -+ width += clusterWidth(seg.segment) -+ } -+ return width -+} -+ -+/** -+ * Expand a string into ASCII-canvas cells: each 2-column grapheme cluster -+ * is stored whole in one cell and followed by WIDE_PAD, so that -+ * cells.length === displayWidth(text). Per-character placement loops can -+ * iterate the result with plain cell offsets. -+ */ -+export function toCells(text: string): string[] { -+ const cells: string[] = [] -+ for (const seg of graphemeSegmenter.segment(text)) { -+ cells.push(seg.segment) -+ if (clusterWidth(seg.segment) === 2) { -+ cells.push(WIDE_PAD) -+ } -+ } -+ return cells -+} -+ - /** - * Get the relative width of a single character. - * diff --git a/python/robomp/.env.example b/python/robomp/.env.example index 235181162..2cf2ff218 100644 --- a/python/robomp/.env.example +++ b/python/robomp/.env.example @@ -110,6 +110,26 @@ ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS=60 ROBOMP_REQUEST_TIMEOUT_SECONDS=120 +# ============================================================================= +# --- PR review / vouch gate --- +# ============================================================================= +# robomp reviews incoming contributor PRs. When PRs are gated by the vouch +# GitHub Action (.github/workflows/vouch-pr.yml), set the trigger to +# `vouched_label` so robomp reviews ONLY the PRs the gate let through (the +# workflow labels survivors with ROBOMP_VOUCH_REVIEW_LABEL) instead of racing +# the gate on PR open. Set ROBOMP_PR_REVIEW_ENABLED=false to disable robomp PR +# review entirely. +ROBOMP_PR_REVIEW_ENABLED=true +# open | vouched_label +ROBOMP_PR_REVIEW_TRIGGER=open +ROBOMP_VOUCH_REVIEW_LABEL=vouched +# In vouched_label mode only `labeled` events from this actor trigger review, +# so a manual triage/maintainer label cannot bypass the gate. Default matches +# the stock GITHUB_TOKEN actor; set to your App's bot login if the vouch +# workflow labels via a GitHub App token. +ROBOMP_VOUCH_REVIEW_LABELER=github-actions[bot] + + # ============================================================================= # --- Per-submitter rate limiting --- # ============================================================================= diff --git a/python/robomp/docker-compose.yml b/python/robomp/docker-compose.yml index 4a06e418d..3d8aed813 100644 --- a/python/robomp/docker-compose.yml +++ b/python/robomp/docker-compose.yml @@ -44,6 +44,16 @@ services: ROBOMP_MAINTAINER_LOGINS: ${ROBOMP_MAINTAINER_LOGINS:-} ROBOMP_REVIEWER_BOTS: ${ROBOMP_REVIEWER_BOTS:-} + # --- PR review / vouch gate --- + # When PRs are gated by the vouch GitHub Action, set the trigger to + # `vouched_label` so robomp reviews ONLY PRs that survive the gate + # (the workflow labels survivors `ROBOMP_VOUCH_REVIEW_LABEL`), instead + # of racing the gate on PR open. `vouched_label` keeps review ENABLED. + ROBOMP_PR_REVIEW_ENABLED: ${ROBOMP_PR_REVIEW_ENABLED:-true} + ROBOMP_PR_REVIEW_TRIGGER: ${ROBOMP_PR_REVIEW_TRIGGER:-open} + ROBOMP_VOUCH_REVIEW_LABEL: ${ROBOMP_VOUCH_REVIEW_LABEL:-vouched} + ROBOMP_VOUCH_REVIEW_LABELER: ${ROBOMP_VOUCH_REVIEW_LABELER:-github-actions[bot]} + # --- model selection --- ROBOMP_MODEL: ${ROBOMP_MODEL:-anthropic/claude-sonnet-4-6} ROBOMP_PROVIDER: ${ROBOMP_PROVIDER:-} diff --git a/python/robomp/src/config.py b/python/robomp/src/config.py index 2c8f1494e..1498e86af 100644 --- a/python/robomp/src/config.py +++ b/python/robomp/src/config.py @@ -38,6 +38,18 @@ class Settings(BaseSettings): git_author_email: str = Field(..., alias="ROBOMP_GIT_AUTHOR_EMAIL") repo_allowlist_raw: str = Field("", alias="ROBOMP_REPO_ALLOWLIST") pr_review_enabled: bool = Field(True, alias="ROBOMP_PR_REVIEW_ENABLED") + # PR review trigger. "open" (default) reviews incoming PRs on + # opened/reopened/ready_for_review. "vouched_label" DEFERS review until the + # vouch GitHub Action labels the PR `vouch_review_label`, so robomp reviews + # only PRs that survive the vouch gate. `pr_review_enabled` remains the + # master switch (False disables review under either trigger). + pr_review_trigger: Literal["open", "vouched_label"] = Field("open", alias="ROBOMP_PR_REVIEW_TRIGGER") + vouch_review_label: str = Field("vouched", alias="ROBOMP_VOUCH_REVIEW_LABEL") + # In vouched_label mode, only `labeled` events from this actor trigger a + # review, so a manual label by a triage/maintainer cannot bypass the gate. + # Default is the actor for the stock GITHUB_TOKEN; set to your App's bot + # login (e.g. "vouch-bot[bot]") if the vouch workflow labels via an App. + vouch_review_labeler: str = Field("github-actions[bot]", alias="ROBOMP_VOUCH_REVIEW_LABELER") # gh-proxy. Set BOTH to route GitHub through the proxy; leave both empty # to keep PAT-on-orchestrator behavior. Mixing the two (PAT + proxy) is diff --git a/python/robomp/src/github_events.py b/python/robomp/src/github_events.py index 1e0c08ceb..75007fff2 100644 --- a/python/robomp/src/github_events.py +++ b/python/robomp/src/github_events.py @@ -140,6 +140,23 @@ def is_implementation_authorizer( return False +def _pr_review_pr(pr: Mapping[str, Any], repo: str, action: str, bot_login: str) -> RouteDecision: + """Build a `review_pr` decision for an incoming PR, or the matching skip.""" + if str(pr.get("state") or "open") != "open": + return RouteDecision("skip", None, repo, None, "PR not open") + if bool(pr.get("draft")): + return RouteDecision("skip", None, repo, None, "draft PR") + if _is_bot_account(pr.get("user") or {}, bot_login): + return RouteDecision("skip", None, repo, None, "bot-authored PR") + number = pr.get("number") + if not isinstance(number, int): + return RouteDecision("skip", None, repo, None, "PR missing number") + login, assoc = _submitter_info(pr) + return RouteDecision( + "queue", "review_pr", repo, issue_key(repo, number), f"pull_request.{action}", submitter=login, association=assoc + ) + + def route( event_type: str, payload: Mapping[str, Any], @@ -150,6 +167,9 @@ def route( reviewer_bots: frozenset[str] = frozenset(), resolve_issue_from_pr: PrIssueResolver = None, pr_review_enabled: bool = True, + pr_review_trigger: str = "open", + vouch_review_label: str = "vouched", + vouch_review_labeler: str = "github-actions[bot]", ) -> RouteDecision: """Decide whether and how to handle a webhook event. @@ -281,24 +301,27 @@ def route( if not pr_review_enabled: return RouteDecision("skip", None, repo, None, "PR review disabled") pr = payload.get("pull_request") or {} - if bool(pr.get("draft")): - return RouteDecision("skip", None, repo, None, "draft PR") - pr_user = pr.get("user") or {} - if _is_bot_account(pr_user, bot_login): - return RouteDecision("skip", None, repo, None, "bot-authored PR") - number = pr.get("number") - if not isinstance(number, int): - return RouteDecision("skip", None, repo, None, "PR missing number") - login, assoc = _submitter_info(pr) - return RouteDecision( - "queue", - "review_pr", - repo, - issue_key(repo, number), - f"pull_request.{action}", - submitter=login, - association=assoc, - ) + if pr_review_trigger == "vouched_label": + # Defer to the vouch gate. robomp reviews ONLY on the `labeled` + # event the workflow emits AFTER a fresh check (it re-applies the + # vouch label on every opened/reopened/ready_for_review). Never + # trust a persisted label here: a since-denounced author must not + # slip through on reopen. + return RouteDecision("skip", None, repo, None, "deferred to vouch label") + return _pr_review_pr(pr, repo, action, bot_login) + + if event_type == "pull_request" and action == "labeled" and pr_review_trigger == "vouched_label": + if not pr_review_enabled: + return RouteDecision("skip", None, repo, None, "PR review disabled") + label = payload.get("label") + label_name = str(label.get("name") or "") if isinstance(label, Mapping) else "" + if label_name.lower() != vouch_review_label.lower(): + return RouteDecision("skip", None, repo, None, f"label {label_name!r} not vouch label") + sender = payload.get("sender") + labeler = str(sender.get("login") or "") if isinstance(sender, Mapping) else "" + if labeler.lower() != vouch_review_labeler.lower(): + return RouteDecision("skip", None, repo, None, f"vouch label not from trusted labeler ({labeler!r})") + return _pr_review_pr(payload.get("pull_request") or {}, repo, action, bot_login) if event_type == "pull_request_review_comment" and action == "created": comment = payload.get("comment") or {} diff --git a/python/robomp/src/server.py b/python/robomp/src/server.py index 5d64700a6..0d1978679 100644 --- a/python/robomp/src/server.py +++ b/python/robomp/src/server.py @@ -341,6 +341,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: maintainers=cfg.maintainer_logins, reviewer_bots=cfg.reviewer_bots, pr_review_enabled=cfg.pr_review_enabled, + pr_review_trigger=cfg.pr_review_trigger, + vouch_review_label=cfg.vouch_review_label, + vouch_review_labeler=cfg.vouch_review_labeler, resolve_issue_from_pr=_resolve, ) diff --git a/python/robomp/tests/test_github_events.py b/python/robomp/tests/test_github_events.py index 6acffbb09..67d51f2dc 100644 --- a/python/robomp/tests/test_github_events.py +++ b/python/robomp/tests/test_github_events.py @@ -857,3 +857,197 @@ def test_route_non_directive_comment_carries_no_pragmas() -> None: ) assert decision.directive is False assert decision.directive_pragmas == () + + +# ---------- vouched-label deferred PR review ---------- + + +def test_route_vouched_label_defers_pr_open() -> None: + decision = route( + "pull_request", + { + "action": "opened", + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_reviews_on_label() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "author_association": "CONTRIBUTOR", + }, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert decision.should_queue + assert decision.task == "review_pr" + assert decision.issue_key == "octo/widget#9" + assert decision.submitter == "alice" + + +def test_route_vouched_label_ignores_other_labels() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "bug"}, + "pull_request": {"number": 9, "user": {"login": "alice"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + + +def test_route_vouched_label_ready_for_review_defers_even_with_label() -> None: + # Persisted labels are NOT trusted; the workflow re-applies the label (a + # fresh `labeled` event) after re-validating, so ready_for_review defers. + decision = route( + "pull_request", + { + "action": "ready_for_review", + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "labels": [{"name": "vouched"}], + }, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_labeled_skips_draft() -> None: + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "draft": True, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "draft PR" + + +def test_route_default_trigger_ignores_labeled() -> None: + # Backward-compat: the default "open" trigger does not route `labeled`. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "user": {"login": "alice"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + ) + assert not decision.should_queue + + +def test_route_vouched_label_reopened_defers_even_with_label() -> None: + # A since-denounced author must not slip through on reopen via a stale + # label; reopened always defers to a fresh gate check + re-label. + decision = route( + "pull_request", + { + "action": "reopened", + "pull_request": { + "number": 9, + "draft": False, + "user": {"login": "alice", "type": "User"}, + "labels": [{"name": "vouched"}], + }, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_reopened_without_label_defers() -> None: + decision = route( + "pull_request", + { + "action": "reopened", + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "deferred to vouch label" + + +def test_route_vouched_label_rejects_manual_labeler() -> None: + # A triage/maintainer hand-adding the label must NOT trigger review. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "draft": False, "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "evilmaintainer"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert "trusted labeler" in decision.reason + + +def test_route_vouched_label_skips_closed_pr() -> None: + # `labeled` can fire on a closed PR; never review one. + decision = route( + "pull_request", + { + "action": "labeled", + "label": {"name": "vouched"}, + "pull_request": {"number": 9, "state": "closed", "user": {"login": "alice", "type": "User"}}, + "repository": {"full_name": "octo/widget"}, + "sender": {"login": "github-actions[bot]"}, + }, + allowlist=ALLOWLIST, + bot_login=BOT, + pr_review_trigger="vouched_label", + ) + assert not decision.should_queue + assert decision.reason == "PR not open" diff --git a/scripts/ci-release-build-binaries.ts b/scripts/ci-release-build-binaries.ts index b97c59b72..8a6f65e2a 100644 --- a/scripts/ci-release-build-binaries.ts +++ b/scripts/ci-release-build-binaries.ts @@ -110,7 +110,7 @@ async function buildBinary(target: BinaryTarget): Promise { console.log(`Building ${target.outfile}...`); await embedNative(target); if (isDryRun) { - console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --minify-identifiers --keep-names --define process.env.PI_COMPILED="true" --root . --external mupdf --target=${target.target} ${entrypoint} --outfile ${target.outfile}`); + console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --minify-identifiers --keep-names --define process.env.PI_COMPILED="true" --root . --target=${target.target} ${entrypoint} --outfile ${target.outfile}`); return; } @@ -132,8 +132,6 @@ async function buildBinary(target: BinaryTarget): Promise { 'process.env.PI_COMPILED="true"', "--root", ".", - "--external", - "mupdf", "--target", target.target, entrypoint, @@ -153,19 +151,27 @@ async function buildBinary(target: BinaryTarget): Promise { async function generateBundle(): Promise { if (isDryRun) { console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate"); + console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate"); + console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --generate"); return; } await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--generate"], repoRoot); + await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--generate"], repoRoot); + await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--generate"], repoRoot); } async function resetArtifacts(): Promise { if (isDryRun) { console.log("DRY RUN bun --cwd=packages/natives run embed:native --reset"); console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset"); + console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --reset"); + console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --reset"); return; } await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native", "--reset"], repoRoot); await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--reset"], repoRoot); + await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--reset"], repoRoot); + await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--reset"], repoRoot); } async function main(): Promise { @@ -188,8 +194,10 @@ async function main(): Promise { } await fs.mkdir(binariesDir, { recursive: true }); - await generateBundle(); + // Generate inside the try so resetArtifacts() always restores the empty + // checked-in placeholders, even if a generate or build step throws. try { + await generateBundle(); for (const target of selectedTargets) { await buildBinary(target); } diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index 5aecdf6da..f81c7a5c4 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -136,7 +136,6 @@ const codingAgentSingletonContentMarkers = [ "Settings.instance", "resetSettingsForTest", "setAgentDir(", - "setDefaultTabWidth(", "vi.useFakeTimers(", "vi.useRealTimers(", "vi.stubEnv(",