AGENTS.md@packages/grammar-plugin · git:20260826.7ee8ea9 · 2026-08-26 · sha256 534e6cdca0f6b927

AGENTS.md@packages/grammar-plugin git:20260826.7ee8ea9A

Immutable. This exact content is served forever at /api/v1/blob/534e6cdca0f6b927.

# DOX — packages/grammar-plugin

First-party dashboard plugin for composer grammar/spell-check. Fully **plugin-contained**
(change: `make-grammar-fully-plugin-contained`): the grammar CHECK route + backend (server entry),
the composer UI (hook + panel, `composer-panel` slot claim), AND the config (`plugins.grammar.*`
via `configSchema`, migrated off core `config.grammar`) live HERE. Core carries ZERO grammar code.
Auto-discovered under `packages/*`. Original settings-only surface: change
`add-grammar-settings-plugin`. **LLM-only** (change: `grammar-llm-only-with-explore`): the
LanguageTool backend + the `backend`/`languagetool` config were removed; the ONLY backend is
`llm`. The `composer-panel` slot also mounts in the OpenSpec Explore + New Change dialogs
(`packages/client/src/components/openspec/`).

| File | Purpose |
|---|---|
| `README.md` | Package overview. Built-in composer grammar/spell-check; redline vs list view, ⌘G. Carries the bundled-plugin caveat: discovered by the `packages/*` build scan, NOT from `node_modules`. |
| `package.json` | Manifest `pi-dashboard-plugin` (id `grammar`, priority 100; package `@blackbelt-technology/pi-dashboard-grammar-plugin`). `client` = `./src/index.tsx` (claims `settings-section`/`general` → `GrammarSettings` AND `composer-panel` → `GrammarComposerPanel`); `server` = `./src/server/index.ts` (owns `/api/grammar/*`); `configSchema` = `./configSchema.json` (`plugins.grammar.*`). `i18nCatalog: catalog`. `fastify` devDep for route types. `diff` dep powers the word-level correction diff (`grammar-diff.ts`). Published to npm (`publishConfig.access: public`, was `private: true`); listed in `publish.yml` PACKAGES — the `publish-allowlist-complete` contract test requires the pair. `files` excludes `__tests__`/`test-support`/`*.test.*`. Bundled built-in: loader discovers it via `packages/*` scan, NOT from `node_modules`. |
| `configSchema.json` | JSON Schema (draft-07) for `plugins.grammar.*` — validates the config written via `POST /api/config/plugins/grammar` + supplies field defaults. `additionalProperties:false` (Ajv throws on unknown keys). NO `backend`/`languagetool` (LLM-only). Mirrors `GrammarConfig` (incl. `correctionView` enum `redline|list` default `redline`, `capitalizeFirstWord` default `false`, optional `llm.{provider,model}`). See change: grammar-llm-only-with-explore. |
| `src/grammar-config.ts` | `GrammarConfig` type + `DEFAULT_GRAMMAR` + `parseGrammarConfig` (clamp/validate) — moved from core `shared/config.ts`; the validation authority the server route + settings UI share. LLM-only: NO `backend`/`languagetool` fields; `parseGrammarConfig` DROPS any persisted legacy `backend`/`languagetool` key on read (never throws) — the read-time half of the migration. `capitalizeFirstWord` (default `false`) gates sentence-start capitalization corrections. `correctionView` (`redline`|`list`, default `redline`) picks the composer presentation; invalid values clamp to `redline`. See changes: make-grammar-fully-plugin-contained, add-grammar-capitalize-toggle, add-grammar-compact-view, grammar-llm-only-with-explore. |
| `src/index.tsx` | Client entry barrel. Re-exports `GrammarComposerPanel` + `GrammarSettings` + `catalog` (names MUST match the manifest claims for the vite-plugin named-import generator). |
| `src/GrammarComposerPanel.tsx` | `composer-panel` slot component. Receives `{draft, sessionId?, sessionStatus?, onApplyText}`, drives `useGrammarCheck` (onDraftChange=onApplyText), renders the trigger (button + document ⌘G listener, gated on `enabled`) + the corrections panel, branching on `grammar.correctionView`: `GrammarRedlinePanel` when `redline` (default), else `GrammarPanel` (list). The whole composer grammar surface, owned by the plugin. See changes: make-grammar-fully-plugin-contained, add-grammar-compact-view. |
| `src/GrammarPanel.tsx` | The **`list`** corrections presentation (`correctionView: list`). Renders each suggestion as an aligned **before→after** grid row (struck-red `original` → green `replacement`) + a kind-coloured pill + the `message`, with per-row Accept/Dismiss + Apply-all + close. Shares shell/error/close/kind-colour with `grammar-panel-chrome.ts`. States idle→null/checking/error/done. Uses `useT()`. Redesigned from the old stacked word-level-diff rows (`SuggestionDiff` no longer used here; `grammar-diff.ts` kept as a utility). Apply-all uses `ACCENT_BUTTON_BG` (AA-safe); kind pill is `text-[11px]` + `rounded-md` (one radius scale). See changes: grammar-composer-clear-and-diff, add-grammar-compact-view, fix-grammar-settings-theme-tokens. |
| `src/GrammarRedlinePanel.tsx` | The **default `redline`** corrections presentation (`correctionView: redline`). Renders the whole draft on one line via `buildRedlineSegments`, with a **remembered** mode toggle `Redline · Compact · Original · Corrected` (persisted in `localStorage["grammar.correctionMode"]`, default `redline`, invalid→`redline`). redline=dotted kind underline + green `→ ghost` (click/Enter applies one); compact=wavy squiggle + hover/focus Apply/Ignore popover (Enter apply, Delete ignore); original/corrected=read-only before/after previews. Apply-all + close; reuses the hook's offset-safe `accept`/`applyAll`/`dismiss`. Kind→colour + aria-label per change (WCAG 1.4.1). Compact Apply/Ignore use the `@mdi` icon family (`mdiCheck`/`mdiClose`), not text glyphs; mode-toggle font normalized to `text-[11px]`; all radii unified to `rounded-md`; Apply-all + the active mode tab use `ACCENT_BUTTON_BG` (AA-safe white label). See changes: add-grammar-compact-view, fix-grammar-settings-theme-tokens. |
| `src/grammar-panel-chrome.tsx` | Shared chrome for BOTH corrections panels: `PANEL_SHELL` class, `errorMessage(t,code)` mapping, `PanelCloseButton`, and `KIND_COLOR_VAR` (spelling→red, grammar→blue, punctuation→orange, style→purple `--accent-*` vars). Also `ACCENT_BUTTON_BG` = `color-mix(in srgb, var(--accent-primary) 85%, black)` — the solid-accent button bg darkened so a white label clears WCAG-AA 4.5:1 (raw `--accent-primary` is only 3.68:1 vs white in the default theme); applied as an inline `style`, NOT a Tailwind arbitrary class (a malformed arbitrary value emits no CSS). Plugin-scoped; core keeps its own `text-white` convention. See changes: add-grammar-compact-view, fix-grammar-settings-theme-tokens. |
| `src/grammar-redline.ts` | `buildRedlineSegments(draft, suggestions)` → ordered `unchanged`/`change` segments; locates each suggestion offset-first then `indexOf` (same as `accept`), drops unlocatable/overlapping spans, omits empty unchanged runs. Presentation-only; the 4 redline modes all derive from it. Round-trip invariant: `Σ unchanged+original == draft`. See change: add-grammar-compact-view. |
| `src/grammar-diff.ts` | `diffTokens(original, replacement)` → `DiffSegment[]` (`equal`/`delete`/`insert`), whitespace-preserving word diff via jsdiff `diffWordsWithSpace`. Presentation-only (apply logic still keys on whole `original`). A self-contained token-LCS variant was prototyped + dropped: jsdiff splits fused punctuation (`work.`→`works.` highlights only `work`), LCS over-highlighted it. See change: grammar-composer-clear-and-diff. |
| `src/useGrammarCheck.ts` | Composer grammar hook. One-shot `GET /api/grammar/health` then manual (`checkNow`) + debounced-auto `POST /api/grammar/check` (relative fetch, same-origin). Aborts on keystroke/session-switch; skips auto while `streaming`/below `minChars`/`/`·`!` drafts. **Clears the panel + aborts in-flight when the draft goes blank** (fires after Send, which resets the draft to "", so stale corrections don't linger; also covers manual clear; guarded on `status` to avoid a loop). Offset-safe `applyAll`/`accept`/`dismiss`/`dismissPanel` via `onDraftChange`. Surfaces `correctionView` from `GET /api/grammar/health` (default `redline`) so the composer picks the presentation from one fetch. Exports `ActiveSuggestion`, `GrammarStatus`. See changes: grammar-composer-clear-and-diff, add-grammar-compact-view. |
| `src/GrammarSettings.tsx` | Settings-section component (imports `GrammarConfig` from `./grammar-config.js`). LLM-only: NO backend selector, NO LanguageTool URL field, NO health/reachability marker. Fields grouped into `<details>` accordions (**General · Model · Advanced**); every `<summary>` carries `focus-ring`. Persists through the host unified Save Bar via `useSettingsDraftSource({id:"plugin:grammar", isDirty, commit, reset})` — NO own Save/Reload buttons, NO "unsaved" chip; `commit` `POST`s `/api/config/plugins/grammar` and **throws on `!res.ok`** (host keeps it dirty + retryable) then re-GETs to surface clamping; `reset` re-GETs. ONE unconditional model picker via the `ui:model-selector` primitive fed by `GET /api/models`; `provider/id` splits into `llm.{provider,model}`; when `llm` unset shows a `grammar-model-required` prompt. `grammar-model-hint` + `grammar-model-guidance-link` (`docs/grammar-model-guidance.md`) + a collapsed `grammar-recommended-models` `<details>` (curated from the OpenRouter competition: `openai/gpt-4.1-nano` recommended + qwen3-30b-a3b-2507/nova-lite-v1/gpt-4o-mini; avoid reasoning models, ministral-8b, retired gemini-2.0-flash) sit by the picker — the disclosure is a SIBLING of the picker, never nested in a `<label>`. **Theme-token utility classes only** (no inline `style` on plugin-owned elements; the host `ui:model-selector` subtree is exempt). Loads via `GET /api/config` (`data.plugins.grammar`). Adapts across all 4 `data-theme` themes + WCAG-AA. See changes: add-grammar-compact-view, fix-grammar-settings-theme-tokens, grammar-llm-only-with-explore, align-grammar-settings-design. |
| `src/i18n.ts` | `catalog` — unprefixed leaf keys, `hu` locale; English inline as `t(key, vars, English)`. Merged under `plugin.grammar.*`. Adds accordion group titles (`groupGeneral/Model/Advanced`) + recommended-model labels (`recommendedModels`/`recBadge`/`recHaiku`/`recSonnet`/`recOpus`/`recGemini`/`recAvoid`); the `save`/`reload`/`saving`/`loading`/`unsaved` keys were removed with the local Save/Reload (host Save Bar owns them). See change: align-grammar-settings-design. |
| `src/server/index.ts` | **Server entry.** `registerPlugin(ctx)` mounts `/api/grammar/*` via `ctx.fastify` and runs the `llm` backend through `ctx.modelRuntime` (in-process registry + streamSimple; no model-proxy loopback). Reads config per request via `parseGrammarConfig(ctx.getPluginConfig())` (namespace `plugins.grammar`). `migrateLegacyConfig` (two cases): (a) absent `plugins.grammar` + legacy core `config.grammar` → copy in via `parseGrammarConfig`; (b) already-populated `plugins.grammar` still carrying a `backend`/`languagetool` key → re-write through `parseGrammarConfig` with those keys set `undefined` to PRUNE them (schema is `additionalProperties:false`; `updatePluginConfig` shallow-merges so a clean partial alone can't remove a key). Idempotent. See changes: make-grammar-fully-plugin-contained, grammar-llm-only-with-explore. |
| `src/server/routes.ts` | `mountGrammarRoutes(fastify, deps)` — `POST /api/grammar/check` + `GET /api/grammar/health`. Auth-only (global auth hook); NO per-route `networkGuard` (plugin-route convention, matches automation-plugin). `relaxSocketTimeout` opts the check out of Fastify's 10s `connectionTimeout`. Maps `GrammarErrorCode`→HTTP. One `[grammar]` log line/call, NO draft text. `check`/`health`/`getModelRegistry`/`streamSimple` injectable. |
| `src/server/grammar-service.ts` | LLM-only `checkGrammar` (gates enabled/empty, clips maxChars, ALWAYS dispatches to `checkWithLlm`, maps errors→code; never throws; `backend_unconfigured` when no `llm` model) + sync `getGrammarHealth` (returns the non-secret client config with `backend:"llm"`, NO `languagetool` block). Moved from core `server/src/grammar`. See change: grammar-llm-only-with-explore. |
| `src/server/backends/llm.ts` | LLM backend. `checkWithLlm` resolves model+creds via `ctx.modelRuntime`'s OAuth/api_key registry and runs `streamSimple`. `googleToOpenAiCompat` reroutes `google-generative-ai` models to Google's OpenAI-compat endpoint (gaxios/jiti workaround). `userPrompt` `<text>`-wraps + "proofread only". `systemPrompt(language, capitalizeFirstWord)` adds a "don't change sentence-start casing" line when the toggle is off — worded as a NARROW exception that re-asserts "you MUST still correct every … mistake" and forbids preserving a misspelling. The earlier blanket phrasing ("leave lowercase sentence starts exactly as written") made weak models (observed: `google/gemini-flash-lite-latest`) return typo-ridden drafts byte-identical with `suggestions: []`, admitting they preserved the errors "as required by the system instructions"; toggling only `capitalizeFirstWord` flipped 0/0/0 → 4/5/5 suggestions. Keep the substring `Do NOT change the capitalization` verbatim — 3 tests assert it as the toggle's contract. SECOND clause, same bug class: the verbatim rule is scoped to `code, file paths, or URLs` and followed by an explicit counter-rule (an unusual/hyphenated prose WORD is NOT code; never leave a misspelling because it looks like jargon) — models had cited "preserve … code … verbatim" to keep `functional-specificatio`. Measured n=5 on flash-lite: jargon draft 0/5 → **4/5**, plain-English 0/5 → **5/5**. Model capability is separate: same prompt, flash-lite 0/5 vs `gemini-flash-latest` 4/4. Builds the pi-ai single user message inline (no server-internal converter). **`parseLlmResult` whole-text fallback**: when the model changed the text but no itemized suggestion survived (empty array, or every `original` a non-substring → all dropped), synthesizes one whole-text correction so LLM mode never silently reports "no issues" on a clearly-corrected draft. `stripTextTags` removes an echoed `<text>…</text>` wrapper the prompt told the model to omit. Also owns `summarize(suggestions)` (the `"2 spelling · 1 grammar"` count string + `KIND_LABELS`), inlined here when the LanguageTool backend was deleted (was its sole other consumer). See changes: make-grammar-fully-plugin-contained, add-grammar-capitalize-toggle, fix-grammar-llm-preserves-mistakes, grammar-llm-only-with-explore; grammar LLM no-issue bugfix. |
| `src/server/grammar-errors.ts` | `GrammarBackendError` (carries `GrammarErrorCode`). Moved from core. |
| `src/server/abort.ts` | `withTimeoutSignal(timeoutMs, external?)` — compose timeout + abort. Moved from core. |
| `vitest.config.ts` | jsdom + `@vitejs/plugin-react`; `globalSetup` = shared `setup-home.ts` (ephemeral HOME); `setupFiles` = `src/test-support/cleanup.ts`. Runs the server backend tests + the React component/hook tests. |
| `src/test-support/cleanup.ts` | `afterEach(cleanup)` — unmount React trees between tests (Testing Library auto-cleanup needs vitest `globals:true`, unset here). |
| `src/__tests__/GrammarPanel.test.tsx` | GrammarPanel (`list`) render/interaction tests: states, apply-all/accept/dismiss, and the L2 before→after columns + kind pill + message. See change: add-grammar-compact-view. |
| `src/__tests__/GrammarRedlinePanel.test.tsx` | GrammarRedlinePanel tests: default redline mode (ghost + click-apply one), compact Apply/Ignore, original/corrected read-only previews, Apply-all, kind→colour, keyboard (Enter), and mode persistence in `localStorage` (+ invalid→redline fallback). See change: add-grammar-compact-view. |
| `src/__tests__/GrammarComposerPanel.test.tsx` | Presentation switch: mocks `useGrammarCheck` and asserts `GrammarRedlinePanel` renders for `correctionView: redline`, `GrammarPanel` for `list`. See change: add-grammar-compact-view. |
| `src/__tests__/grammar-redline.test.ts` | `buildRedlineSegments` tests: offset-first vs `indexOf` location, unlocatable/overlap drop, adjacent/multi-word/fused-punctuation spans, and the round-trip invariants. See change: add-grammar-compact-view. |
| `src/__tests__/useGrammarCheck.test.tsx` | Hook tests (health fetch, auto/manual check, abort, apply, clear-on-empty-draft). Moved from core client (relative fetch). |
| `src/__tests__/grammar-diff.test.ts` | `diffTokens` tests: round-trip (equal+delete=original, equal+insert=replacement), long-sentence highlights only the delta, fused punctuation splits off the word. See change: grammar-composer-clear-and-diff. |
| `src/__tests__/config-grammar.test.ts` | `parseGrammarConfig` unit tests (defaults, clamping, backend fallback, llm validation, unknown keys). Moved from core `shared` (was a `loadConfig` integration test). |
| `src/__tests__/GrammarSettings.test.tsx` | Settings component tests driving the **host Save Bar** contract: mounts inside `SettingsDraftProvider` with a capturing registry, drives the `plugin:grammar` source's `commit`/`reset`/`isDirty` (E1–E3), commit POSTs the plugin endpoint (E2/E6), model-required prompt (E4), persisted-LT renders LLM-only (E5), accordion grouping + Save/Reload/dirty GONE (E7), recommended-models disclosure collapsed + not in a `<label>` (E8), guidance hint+link target exists in `docs/` (E9), failed-save rejects + stays dirty (X1), no plugin-owned inline styles / no LT marker / summary focus-ring / theme tokens (F1–F4). Mocks `fetch`. See change: align-grammar-settings-design. |
| `src/__tests__/manifest.test.ts` | Manifest/barrel wiring: single settings-section claim; barrel exports; asserts the `server` entry `./src/server/index.ts` (configSchema still absent). |
| `src/__tests__/grammar-llm.test.ts` | `checkWithLlm` / `extractJsonObject` / `parseLlmResult` / `googleToOpenAiCompat` (rerouting unit + integration). Moved from core server tests. |
| `src/__tests__/grammar-service.test.ts` | LLM-only `checkGrammar` gating/truncation/language/`backend_unconfigured` (mocked registry+streamSimple). Moved from core. See change: grammar-llm-only-with-explore. |
| `src/__tests__/grammar-routes.test.ts` | `mountGrammarRoutes` via a real Fastify instance (`inject`): success envelope, code→HTTP mapping, health returns `backend:"llm"` + NO `languagetool`. See change: grammar-llm-only-with-explore. |
| `src/__tests__/grammar-llm-edgecases.test.ts` | LLM backend edge cases: the whole-text fallback regression (corrected-text-differs-but-no-suggestions / non-substring drops), `stripTextTags`, out-of-order/duplicate/blank suggestion mapping, resilient `extractJsonObject`, request shaping (token cap, temp 0, `<text>` wrapping, creds, language), stream draining (string/mixed/no-done/empty), timeout/abort/generic-failure mapping. See: grammar LLM no-issue bugfix. |
| `src/__tests__/config-grammar-edgecases.test.ts` | Exhaustive `parseGrammarConfig` matrix: boolean coercion, every clamp boundary (±1, NaN/Infinity), language handling, legacy `backend`/`languagetool` keys DROPPED, `llm` sub-block validation (partial/wrong-type/extra-key strip), idempotence. See change: grammar-llm-only-with-explore. |
| `src/__tests__/grammar-service-edgecases.test.ts` | LLM-only `checkGrammar` edge cases (mocked registry+streamSimple): gating, truncation boundaries, language precedence (arg > config > auto), `capitalizeFirstWord` reaching the llm prompt, error-code mapping (provider-error/raw-throw → backend_unreachable), whole-text fallback through the service, and sync `getGrammarHealth` (never exposes `languagetool`). See change: grammar-llm-only-with-explore. |
| `src/__tests__/grammar-routes-edgecases.test.ts` | Route edge cases: body text/language plumbing, model-registry resolved for every check (llm is the only backend) + tolerant of a throwing resolver, registry+streamSimple forwarded, thrown-check → 502, full code→HTTP table incl. unknown-code → 500, llm health omits `languagetool`. See change: grammar-llm-only-with-explore. |