Skip to content

Latest commit

 

History

History
385 lines (272 loc) · 27.8 KB

File metadata and controls

385 lines (272 loc) · 27.8 KB

AGENTS.md

Guidance for AI coding assistants (Claude Code, Cursor, Warp, Copilot, etc.) working in this repository.

Rules here are stated as rules. Where a rule exists because something broke, the reason is one clause, not a story — git log has the rest.

Contents

Project overview

Browser extension (Chrome MV3 / Firefox MV2) for chatting with local and remote LLM providers, with local-first RAG over uploaded files and optional provider-backed web search. WXT, React 19, TypeScript 6, Tailwind v4, Biome.

Built-in verified providers: Ollama, LM Studio, llama.cpp. vLLM, LocalAI, KoboldCPP and other compatible servers are added through the OpenAI-compatible custom-provider flow. Anthropic is a custom provider on the native Claude Messages API. openai-compatible.ts is the shared implementation, not a separate built-in tile.

Commands

pnpm install                # Install dependencies
pnpm dev                    # Dev build, Chrome MV3
pnpm dev:firefox            # Dev build, Firefox MV2
pnpm build                  # Production build, Chrome MV3
pnpm build:firefox          # Production build, Firefox MV2
pnpm package                # Zip Chrome build for upload
pnpm package:firefox        # Zip Firefox build for upload

pnpm test                   # Vitest, watch mode
pnpm test:run               # Vitest, one-shot
pnpm test:related           # Only tests affected by working-tree changes
pnpm test:coverage          # Coverage report

pnpm lint:check             # Biome check (no writes)
pnpm lint:fix               # Biome check --write
pnpm format:check           # Biome format check
pnpm format:fix             # Biome format --write
pnpm typecheck              # tsc --noEmit

pnpm docs:dev               # Astro dev for the docs site (docs/)
pnpm docs:build             # Astro build → docs/dist/

pnpm generate:resources     # Validate locales, regenerate derived extension assets

Before opening a PR: pnpm typecheck && pnpm lint:check && pnpm test:run. If you touched docs/ or src/locales/: also pnpm docs:build && pnpm generate:resources.

Architecture

Entrypoints (WXT)

WXT discovers entrypoints from src/entrypoints/. Each is a thin bootstrapper that mounts a shell from elsewhere in src/.

Entrypoint File Mounts
Side panel src/entrypoints/sidepanel/index.tsx src/sidepanel/index.tsx (chat UI)
Options page src/entrypoints/options/index.tsx src/options/index.tsx
Background worker src/entrypoints/background.ts src/background/index.ts
Content script src/entrypoints/content.ts src/contents/index.ts
Selection overlay src/entrypoints/selection-button.content.tsx self-contained content UI
Print page src/entrypoints/print/ print-friendly export

Manifest — permissions, CSP, host permissions, browser_specific_settings — lives in wxt.config.ts only.

Dev-only entrypoints (spike-*, benchmark, persistence-verify) are stripped from store builds by config/wxt-hooks.ts, and their code is dead-code-eliminated via the __SPIKE_OPFS_OWNER__ flags in config/wxt-vite.ts. src/spike/ is therefore fine to leave where it is.

Chat round-trip

  1. UI opens a runtime port keyed by MESSAGE_KEYS.PROVIDER.STREAM_RESPONSE.
  2. src/background/index.ts routes by message key to src/background/handlers/.
  3. ProviderFactory.getProviderForModel(modelId) resolves the provider via registry.ts and the user's saved mapping.
  4. The provider streams tokens back through the port; use-chat.ts updates state and persists.

Providers (src/lib/providers/)

File Role
types.ts LLMProvider, ProviderConfig, ProviderType/ProviderId enums
registry.ts static metadata for built-in providers
factory.ts ProviderFactory.getProviderForModel()
manager.ts config + model mappings via ProviderStorageKey
selected-model.ts active model state
capabilities.ts capability detection and per-flag attribution
ollama.ts, lm-studio.ts, llama-cpp.ts verified built-ins
openai-compatible.ts custom OpenAI-compatible endpoints
anthropic.ts native Claude Messages API

Legacy vLLM/LocalAI/KoboldCPP subclasses are compatibility-only, not UI profiles. Default fallback is Ollama when no explicit model→provider mapping exists.

Capability detection resolves in this order, highest first: user override → empirical probe (capability-probe.ts) → model metadata → provider default. An unknown capability resolves to false; only an override may flip it on. Never enable vision or tool calling on a guess.

Metadata evidence, strongest first:

  • Ollama /api/show capabilities[] tags → high confidence
  • LM Studio capabilities[] (e.g. ["tool_use"]) → high, for the flags it names
  • OpenRouter-style modalities / supported_parameters → high
  • LM Studio type (llm/vlm/embeddings) → medium; a category, not a statement about the model
  • provider default → low

An empty metadata array means "unknown", never a reported no — empty catalog arrays are placeholders often enough that treating them as negatives silently disables working models. When you add a metadata source, update both getModelCapabilities and getModelCapabilityStates; the second drives the capability sheet's "where did this come from" attribution and must not contradict the first.

Model list metadata differs sharply by server, so check before assuming a field exists:

  • Ollama /api/tags omits family, parameter_size, and quantization_level for non-GGUF (safetensors/MLX) models. getModels backfills those from /api/show, only for models whose format is reported, non-GGUF, and sizeless. Capped fan-out; a failed lookup leaves the model as-is.
  • LM Studio reports no size of any kind on any endpoint. parameterSizeFromModelId reads it from the id by convention and refuses when the id is ambiguous. Never put max_context_length in parameter_size — that shipped once and rendered a token window as a model size.
  • llama.cpp reports meta.n_params and already formats to one decimal.

formatParameterSize normalizes whatever arrives so one list cannot mix 8B, 8.2B, and 999.89M.

RPC boundary (src/protocol/)

Every provider, model, and embedding request/response crosses the extension-page/background boundary through the versioned RPC contract.

File Role
rpc.ts protocol version, RpcMethod/RpcErrorCode, envelopes
provider-rpc.ts providers.* schemas, typed RpcMap
model-rpc.ts models.*, embeddings.* schemas
diagnostics-rpc.ts diagnostics.* schemas
rpc-registry.ts per-method schema, sender policy, timeout, operation metadata
extension-client.ts validated extension-page client
src/background/rpc-server.ts authorization, validation, dispatch, safe errors
src/lib/providers/provider-rpc-service.ts background-owned provider-config ops
src/lib/providers/model-rpc-service.ts background-owned model lifecycle and catalog ops

Adding a method:

  • Register it in RpcMethod, RpcMap, and RPC_METHOD_DEFINITIONS; refer to it through the enum, never a duplicated wire string.
  • Validate both ends. Keep credentials out of results and diagnostics. Return i18n message keys plus safe fallback text.
  • allowedSources is ["extension-page"] for every method, and a contract test asserts it. Content scripts never reach the protocol, because page-controlled data influences their messages — widening this is a security decision, not a registry edit.
  • Queries must have no persistence side effects, so a client timeout cannot commit stale state. Persist derived state only after the caller accepts the result.
  • Client timeouts send app-rpc-cancel; the server aborts the matching request and passes the AbortSignal into provider fetches. Preserve that path for anything long-running.
  • Widening capabilityHints means editing the schema and its transform in provider-rpc.ts — the transform whitelists fields, so a schema-only change silently drops the value.

Storage

Chat history is SQLite-only (SQLite-in-WASM). The Dexie chat-history fallback is retired; Dexie remains for vector embeddings and knowledge sets.

Data Where
Chats, sessions, messages, files src/lib/repositories/chat-history.ts — a facade over sqlite-chat-history.ts. Go through the facade.
SQLite internals src/lib/sqlite/ (db.ts, schema.ts, migrations/)
On-install embedding-dimension migration src/lib/migration/, invoked from src/background/index.ts
Vectors / embeddings src/lib/embeddings/ — still IndexedDB via storage.ts, not migrated to SQLite
Settings, config, per-extension state @plasmohq/storage via src/lib/plasmo-global-storage.ts
  • Session metadata — pinned state, per-chat system prompts, user tags — lives on SQLite sessions. Add columns through forward-only migrations.
  • Durability — writes debounce 1s to IndexedDB. Page-unload and explicit reset/export paths force-flush via flushSave().
  • Tool-loop durability — active native and non-native tool loops checkpoint to tool_loop_runs at model/tool/approval boundaries and force-flush before awaiting approval. The sidepanel reconnects with the same request id after an MV3 worker restart. Do not remove that checkpoint/reconnect contract.
  • Reasoning replay — signed Anthropic thinking/redacted blocks and OpenRouter reasoning_details live in the versioned, size-capped ChatMessage.replayArtifact, separate from display-only thinking. Preserve block order and opaque values through SQLite and checkpoints, validate provider/model ownership before replay, and never render or log opaque contents.
  • Sync vs local — sync-safe settings use chrome.storage.sync; device-local keys are routed to chrome.storage.local by the wrapper.

The persistence host (Chromium offscreen document / Firefox MV2 background page) owns the only chat-db worker. It reports worker error and messageerror events with their cause — keep it that way; a bare "worker crashed" hides the actual failure. Note that in dev the worker loads from the Vite dev server, which is why worker-src allows that origin during serve only (config/__tests__/manifest-csp.test.ts guards both halves).

Feature modules (src/features/)

Each feature owns its UI, hooks, and — if needed — its Zustand store.

Feature Contents
chat/ chat UI, use-chat.ts, RAG pipeline (rag/), speech store
sessions/ session list + repository, chat-session-store.ts
model/ model management UI, provider/embedding settings
file-upload/ ingestion for RAG, per-format processors/
prompt/ prompt templates
settings/ six intent tabs, settings registry, i18n-backed search, legacy deep-link redirects
selection-actions/ in-page selection overlay
web-search/, permissions/, privacy/, knowledge/, memory/, context/, tabs/, diagnostics/ auxiliary

Feature-scoped stores live in features/<x>/stores/, never src/stores/. src/stores/ is only for cross-feature concerns (theme, shortcuts, search dialog).

Subsystems

RAG / embeddings

  • Pipeline: src/features/chat/rag/ (rag-pipeline.ts, rag-retriever.ts, rag-prompt-builder.ts, query-classifier.ts).
  • All file, memory, and live-page splitting goes through src/lib/embeddings/chunker.ts. Do not build a parallel text splitter.
  • Plumbing: src/lib/embeddings/ (embedding-strategy.ts, embedder-factory.ts, hnsw-index.ts, keyword-index.ts, storage.ts, chunker.ts, search.ts).
  • Embedding strategy chain: provider-native → shared model → Ollama fallback.
  • Hybrid search: keyword (minisearch) + dense (hnsw), configurable weights.
  • Reranking is a cosine-similarity re-scorer (reranker.ts), on by default — not a cross-encoder. A transformers.js / ONNX Runtime cross-encoder was blocked by MV3 CSP and never shipped; neither library is a dependency. config.ts accepts the legacy transformers-js/onnxruntime-web strings only as a shim collapsing them to cosine.

There is no src/lib/rag/core/ tree. Any doc referencing one is stale.

Web search

  • Runtime: src/lib/tools/web-search/. WebSearchBackend adapters keep provider wire formats behind one web_search tool.
  • Backends: SearXNG (GET /search?q=…&format=json), Brave (GET api.search.brave.com/res/v1/web/search, X-Subscription-Token), Tavily (POST api.tavily.com/search, bearer).
  • Settings UI: src/features/web-search/, mounted in the internal context tab (shown as "Knowledge & web"). Config is device-local via STORAGE_KEYS.WEB_SEARCH.CONFIG. Never log API keys.
  • Result counts: SearXNG has pageno but no count parameter — fetch configured pages, de-dupe, then cap. Brave uses count; Tavily uses max_results.
  • Treat snippets and titles as untrusted. Cap per-result snippets and total tool output, and instruct models to cite returned URLs for current facts.

Internal LLM tools

Model-callable tools live in src/lib/tools/internal/, registered in internal-tool-source.ts. When adding one:

  • Give it a stable displayNameKey and add that key to chat.reasoning.trace in every src/locales/<lang>/translation.json, or the reasoning trace shows raw key paths.
  • Run pnpm generate:resources after locale edits.
  • Keep privacy-sensitive tools on the same permission and scope filters as their indexing/search pipeline. A live tool must not bypass user exclusions.

Browser sessions and capture

  • Read-only helpers: src/lib/browser-sessions.ts. Model tools: src/lib/tools/internal/browser-session-tools.ts.
  • sessions is an optional permission. Always check browser support and the live permission before reading recently-closed or synced-device sessions.
  • Session URLs must pass the same unreadable/never-read filters as other browser tools.
  • Do not expose sessions.restore() to a model until tool execution has a real interactive approval boundary.
  • tabCapture + offscreen is a Chromium 116+ prototype. Any capture flow must start from a user gesture, preserve tab audio, show persistent recording state and a Stop control, stop on permission revoke, and keep data ephemeral until explicitly saved.

Conventions

Messaging keys

Do not add a request/response runtime message — add an RpcMethod. Since 0.12.5 every provider, model, and embedding round trip goes through src/protocol/. MESSAGE_KEYS keeps only streaming port names, one-way events, and PROVIDER.GET_MODELS (the single content-script-reachable read, outside the protocol because the RPC envelope is extension-page-only by policy).

MESSAGE_KEYS.OLLAMA.* is two port names (STREAM_RESPONSE, PULL_MODEL). Do not add to LEGACY_OLLAMA_MESSAGE_KEYS — the legacy request/response twins were deleted because a page old enough to send one already has an invalidated extension context.

STORAGE_KEYS.PROVIDER.* vs LEGACY_STORAGE_KEYS.OLLAMA.* is a different case: storage keys name persisted data, so those legacy strings are real.

Background handlers

src/background/handlers/handle-{action}.ts, registered in src/background/index.ts. Only streaming/port work belongs here (chat, context build, pull, selection actions, embedding download). Request/response provider and model operations live in ProviderRpcService / ModelRpcService. Keep handlers thin — adapt the port protocol to src/lib/ and stream back.

Component layers

Four tiers, and the tier decides the rules:

  1. src/components/ui/ — vendored shadcn primitives, curated. Check whether an existing primitive or a small composition works before adding one.
  2. src/components/{settings,actions,feedback,forms,layout}/ — app-owned composites.
  3. src/features/<x>/components/ — feature UI.
  4. src/sidepanel/, src/options/ — shells.

A component with no importer outside its own layer is speculative. Add the second real caller in the same change, or don't add the component.

The options-page composites do not fit the side panel. SettingsRow is p-3 text-sm with breakpoints, built for ~900px; the side panel is ~400px and dense. Reach for a dense primitive rather than hand-rolling a smaller copy of a page-sized one.

Component name suffixes

The suffix names what a component renders, not how important it feels.

Suffix Renders
*-card.tsx its own bordered surface as the root (Card, SettingsCard)
*-section.tsx a titled group with no surface of its own
*-fields.tsx a bare group of form fields — fragment root, no title, no surface
*-panel.tsx a feature's whole composed surface, arranging its own cards and sections

Match the suffix to the root element when adding or restructuring.

Dense list rows

Side-panel rows shaped leading glyph → label → trailing action go through ListRow / ListRowButton (src/components/layout/list-row.tsx). Do not rebuild the grid — hand-rolled copies are why one sheet had leading edges on 8/16/18/26px.

  • ListRow is a div, for rows whose title and trailing control are separate hit areas.
  • ListRowButton is the same geometry on a <button>, for whole-row targets.
  • inset="nested" inside an already-padded scroll container.
  • trailingKind="control" when the trailing slot ends in a hit-area that pays its own padding.
  • description for a second line of the row's own label; below for a second line owning its own content.

EmptyState needs density="compact" in a dense list.

Icons

Import from lucide-react directly. There is no re-export barrel — @/lib/lucide-icon was retired because tree-shaking already drops unused icons through a re-export (bundle size was identical without it) and nothing enforced it as an allowlist.

LucideIcon is a type export of lucide-react. There is no CheckIcon — use Check as CheckIcon.

src/components/__tests__/design-system-contract.test.ts requires named size tokens (icon-sm, icon-xs, …) on Lucide components, not raw size-4 classes, and bans text-[…] and rounded-md/rounded-lg repo-wide.

React Hook Form fields

Use the Controlled* wrappers in src/components/forms/. Never spread register(...) into a src/components/ui/* primitive — several are controlled Base UI wrappers, and spread-register can leave the DOM looking updated while RHF still holds the old value. src/components/forms/__tests__/react-hook-form-contract.test.ts enforces this for production TSX and does not enumerate wrapper names.

The set is ControlledTextarea, ControlledNumberInput, ControlledSlider — what the one RHF form in the app (model-settings-form.tsx) binds. Others were deleted for having no caller; recover them from git when a form needs one.

Settings search and deep links

src/features/settings/settings-registry.ts is the source of truth. When adding or moving a setting:

  • Add or update the entry with the real tab, section, label key, description key, and visible child strings in searchKeys.
  • Prefer i18n keys over keywords. Use aliases only for technical synonyms, provider names, or common typos that are not visible copy.
  • Every id/focusId must resolve to a mounted element via focusId, id, or data-settings-focus-id. Use the focus props on SettingsCard / SettingsFormField / SettingsSliderField / SettingsSwitch, or add data-settings-focus="true" plus data-settings-focus-id="…".
  • No duplicate focus IDs across tabs — duplicates land highlights on the wrong control after navigation.
  • Update settings-registry.test.ts / settings-search-index.test.ts or the component test.

i18n

src/locales/<lang>/translation.json is the source of truth for both in-app copy and extension package metadata. Nine locales: de en es fr hi it ja ru zh.

  • Loaded through the explicit dynamic-import map in src/i18n/locale-loader.ts, one lazy chunk per language. Do not build an aggregated all-languages resource.
  • Never pass a fallback string to t(). Add the key to every locale instead.
  • Keep the top-level extension block filled in for every locale.
  • public/_locales/**/messages.json and public/assets/selection-locales/ are generated by tools/generate-i18n-resources.ts. Do not hand-edit them. _locales is committed because extension packages need it.
  • Generation runs automatically before dev/build/package. It no longer runs on install — prepare only installs husky — so run pnpm generate:resources manually after any locale edit to validate the catalogs.
  • Before adding a key, check for an orphan that already fits — tabs.select.ready sat fully translated and unused.

Testing

  • Vitest with happy-dom and fake-indexeddb. src/test/setup.ts mocks chrome APIs and IndexedDB.
  • Tests live in src/**/__tests__/*.{test,spec}.{ts,tsx} and config/**/*.test.ts.
  • Single file: pnpm test src/path/to/module.test.ts.
  • Coverage excludes only test files and .d.ts. UI components, type modules, and barrels are included.
  • @testing-library/user-event is not a dependency — use fireEvent.
  • When a change breaks an existing test, work out whether the test or the change is wrong. A broken assertion is sometimes the design telling you something: a fan-out that consumed a queued fetch response failed the Ollama contract test, and the predicate was the bug.

Contract tests worth knowing about, because they enforce conventions no reviewer would catch:

Test Enforces
components/__tests__/design-system-contract.test.ts icon size tokens, typography/radius tokens
components/forms/__tests__/react-hook-form-contract.test.ts no spread-register
lib/providers/__tests__/contract.test.ts provider list/stream parsing
config/__tests__/manifest-csp.test.ts no dev origin in a packaged CSP
lib/__tests__/browser-api-contract.test.ts guarded browser API access

Lint and formatting

Biome, not ESLint/Prettier: 2-space indent, LF, double quotes, no semicolons (except ASI hazards), no trailing commas, bracket-same-line JSX.

__tests__/ may use noExplicitAny. Vendored shadcn a11y suppressions are per-line comments in the offending file — there is no blanket override for src/components/ui/**.

Biome also rewrites some Tailwind arbitrary values to canonical form (row-end-[-1]-row-end-1) and enforces exhaustive hook dependencies, so a deps.join() trick will fail — memoize instead.

Git hooks (.husky)

  • pre-commit: lint-staged (typecheck, format:fix, lint:fix, test:related) → format:checklint:checktypecheck. Does not run the full suite.
  • pre-push: pnpm test:run.

Never bypass with --no-verify. If a hook fails, fix the cause.

Constraints

  • MV3 CSP blocks dynamic eval; WASM is allowed via 'wasm-unsafe-eval'. ONNX Runtime is bundled, never fetched.
  • Firefox lacks Chrome's declarativeNetRequest semantics. Cross-origin provider requests rely on host_permissions: ["<all_urls>"] plus CORS-friendly endpoints.
  • Provider model-name collisions make routing ambiguous. ProviderFactory resolves via the saved mapping first, Ollama fallback last.
  • Token budgeting in lib/embeddings/chunker.ts is approximate (chars / 4).
  • A dev build emits no chunks to disk beyond the reload shim; everything is served from the Vite dev server. chrome.runtime.getURL is not available for dev-only asset paths.

Provider API reference

Current state of known hotspots

What these files are now, so you neither go looking for a god-object that was already split nor assume a large file is fine.

Do not restructure incrementally:

  • src/features/chat/hooks/use-chat-turn-controller.ts — owns turn lifecycle, streaming, abort, thinking, attachment handoff. Being redesigned wholesale in the from-scratch architecture rebuild (FROM_SCRATCH_ARCHITECTURE_AUDIT.md). Expect complexity; keep edits minimal and local. Keep use-chat.ts as wiring only.

Open for incremental work:

  • src/features/file-upload/hooks/use-file-upload.ts — still owns UI state around ingestion; pipeline helpers are in file-upload-pipeline.ts. Keep moving validation, registration, and embedding enqueue out of the hook.

Already restructured — match the existing shape rather than reverting to props or god-objects:

  • src/features/selection-actions/ reads view state from selection-overlay-context.tsx, not props. SelectionOverlayApp alone knows about the reducer, the capture, and the content script's refs; SelectionActionsOverlay, SelectionPanel, SelectionToolbar, PanelHeader, PanelFooter take no props. Add a control to the context value and read it where it renders. PanelMarkdown and PanelThinking stay prop-driven — leaf presentational, own tests.
  • src/features/chat/components/chat-input/context-settings-menu.tsx is the sheet shell and view switch (~205 LOC). Settings in hooks/use-context-settings.ts, tab list and its reconciliation effects in hooks/use-context-tab-options.ts, summary in context-summary.ts, views in context-main-view.tsx / context-sub-view.tsx. New context controls go in the hook and the main view, not the shell.
  • src/features/sessions/stores/chat-session-store.ts is a ~19-LOC barrel over extracted slices; persistence reads via chat-history.ts. The old ~485-LOC store is gone.
  • src/features/model/components/provider-settings.tsx delegates connection details and custom model editing to small components. Keep new slices similarly scoped and covered by component tests.
  • src/contents/index.ts is a ~38-LOC entry; selection-capture, dom-observer, and messaging are siblings.
  • src/types/index.ts is a re-export barrel (~11 LOC) over chat, model, messaging, errors, content-extraction, ui-state. Prefer the per-domain path (@/types/chat).
  • Dexie chat-history paths are retired. Vectors and knowledge sets still use Dexie; chat history is SQLite-only through the facade.