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.
- Project overview
- Commands
- Architecture — entrypoints, chat round-trip, providers, RPC, storage, features
- Subsystems — RAG, web search, tools, browser sessions
- Conventions — messaging, UI, i18n, testing, lint, git hooks
- Constraints
- Provider API reference
- Current state of known hotspots
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.
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 assetsBefore 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.
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.
- UI opens a runtime port keyed by
MESSAGE_KEYS.PROVIDER.STREAM_RESPONSE. src/background/index.tsroutes by message key tosrc/background/handlers/.ProviderFactory.getProviderForModel(modelId)resolves the provider viaregistry.tsand the user's saved mapping.- The provider streams tokens back through the port;
use-chat.tsupdates state and persists.
| 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/showcapabilities[]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/tagsomitsfamily,parameter_size, andquantization_levelfor non-GGUF (safetensors/MLX) models.getModelsbackfills 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.
parameterSizeFromModelIdreads it from the id by convention and refuses when the id is ambiguous. Never putmax_context_lengthinparameter_size— that shipped once and rendered a token window as a model size. - llama.cpp reports
meta.n_paramsand already formats to one decimal.
formatParameterSize normalizes whatever arrives so one list cannot mix 8B, 8.2B, and 999.89M.
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, andRPC_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.
allowedSourcesis["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 theAbortSignalinto provider fetches. Preserve that path for anything long-running. - Widening
capabilityHintsmeans editing the schema and its transform inprovider-rpc.ts— the transform whitelists fields, so a schema-only change silently drops the value.
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_runsat 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_detailslive in the versioned, size-cappedChatMessage.replayArtifact, separate from display-onlythinking. 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 tochrome.storage.localby 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).
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).
- 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.tsaccepts the legacytransformers-js/onnxruntime-webstrings only as a shim collapsing them tocosine.
There is no src/lib/rag/core/ tree. Any doc referencing one is stale.
- Runtime:
src/lib/tools/web-search/.WebSearchBackendadapters keep provider wire formats behind oneweb_searchtool. - 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 internalcontexttab (shown as "Knowledge & web"). Config is device-local viaSTORAGE_KEYS.WEB_SEARCH.CONFIG. Never log API keys. - Result counts: SearXNG has
pagenobut no count parameter — fetch configured pages, de-dupe, then cap. Brave usescount; Tavily usesmax_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.
Model-callable tools live in src/lib/tools/internal/, registered in internal-tool-source.ts. When adding one:
- Give it a stable
displayNameKeyand add that key tochat.reasoning.tracein everysrc/locales/<lang>/translation.json, or the reasoning trace shows raw key paths. - Run
pnpm generate:resourcesafter 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.
- Read-only helpers:
src/lib/browser-sessions.ts. Model tools:src/lib/tools/internal/browser-session-tools.ts. sessionsis 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+offscreenis 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.
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.
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.
Four tiers, and the tier decides the rules:
src/components/ui/— vendored shadcn primitives, curated. Check whether an existing primitive or a small composition works before adding one.src/components/{settings,actions,feedback,forms,layout}/— app-owned composites.src/features/<x>/components/— feature UI.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.
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.
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.
ListRowis adiv, for rows whose title and trailing control are separate hit areas.ListRowButtonis 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.descriptionfor a second line of the row's own label;belowfor a second line owning its own content.
EmptyState needs density="compact" in a dense list.
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.
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.
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
aliasesonly for technical synonyms, provider names, or common typos that are not visible copy. - Every
id/focusIdmust resolve to a mounted element viafocusId,id, ordata-settings-focus-id. Use the focus props onSettingsCard/SettingsFormField/SettingsSliderField/SettingsSwitch, or adddata-settings-focus="true"plusdata-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.tsor the component test.
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
extensionblock filled in for every locale. public/_locales/**/messages.jsonandpublic/assets/selection-locales/are generated bytools/generate-i18n-resources.ts. Do not hand-edit them._localesis committed because extension packages need it.- Generation runs automatically before
dev/build/package. It no longer runs on install —prepareonly installs husky — so runpnpm generate:resourcesmanually after any locale edit to validate the catalogs. - Before adding a key, check for an orphan that already fits —
tabs.select.readysat fully translated and unused.
- Vitest with
happy-domandfake-indexeddb.src/test/setup.tsmocks chrome APIs and IndexedDB. - Tests live in
src/**/__tests__/*.{test,spec}.{ts,tsx}andconfig/**/*.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-eventis not a dependency — usefireEvent.- 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 |
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.
pre-commit: lint-staged (typecheck,format:fix,lint:fix,test:related) →format:check→lint:check→typecheck. Does not run the full suite.pre-push:pnpm test:run.
Never bypass with --no-verify. If a hook fails, fix the cause.
- MV3 CSP blocks dynamic eval; WASM is allowed via
'wasm-unsafe-eval'. ONNX Runtime is bundled, never fetched. - Firefox lacks Chrome's
declarativeNetRequestsemantics. Cross-origin provider requests rely onhost_permissions: ["<all_urls>"]plus CORS-friendly endpoints. - Provider model-name collisions make routing ambiguous.
ProviderFactoryresolves via the saved mapping first, Ollama fallback last. - Token budgeting in
lib/embeddings/chunker.tsis 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.getURLis not available for dev-only asset paths.
- Ollama — https://github.com/ollama/ollama/blob/main/docs/api.md
/api/tags: list. Omits family/parameter_size/quantization for non-GGUF models./api/show: full metadata,capabilities[],model_infoincludinggeneral.parameter_count.
- LM Studio — https://lmstudio.ai/docs/developer/rest/endpoints
/api/v0/models:type,publisher,arch,compatibility_type,quantization,state,max_context_length,capabilities[]. No size of any kind, here or on/api/v0/models/{id}./api/v0/chat/completions: chat. Standard OpenAI-compatible endpoints also work;/v1/modelsreturns onlyid/object/owned_by.
- llama.cpp — https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
/v1/models: includesmetawithsizeandn_params.- macOS model cache:
~/Library/Caches/llama.cpp. Example:llama-server -m ~/Library/Caches/llama.cpp/<model>.gguf --port 8000 --host 0.0.0.0
- OpenAI — https://platform.openai.com/docs/api-reference
- Anthropic — https://platform.claude.com/docs/en/api/messages/create
- vLLM — https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html
- KoboldCPP — https://github.com/LostRuins/koboldcpp/wiki
- LocalAI — https://localai.io/features/openai-functions/
- SearXNG — https://docs.searxng.org/dev/search_api.html
- Brave Search — https://api-dashboard.search.brave.com/app/documentation/web-search/responses
- Tavily — https://docs.tavily.com/documentation/api-reference/endpoint/search
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. Keepuse-chat.tsas wiring only.
Open for incremental work:
src/features/file-upload/hooks/use-file-upload.ts— still owns UI state around ingestion; pipeline helpers are infile-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 fromselection-overlay-context.tsx, not props.SelectionOverlayAppalone knows about the reducer, the capture, and the content script's refs;SelectionActionsOverlay,SelectionPanel,SelectionToolbar,PanelHeader,PanelFootertake no props. Add a control to the context value and read it where it renders.PanelMarkdownandPanelThinkingstay prop-driven — leaf presentational, own tests.src/features/chat/components/chat-input/context-settings-menu.tsxis the sheet shell and view switch (~205 LOC). Settings inhooks/use-context-settings.ts, tab list and its reconciliation effects inhooks/use-context-tab-options.ts, summary incontext-summary.ts, views incontext-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.tsis a ~19-LOC barrel over extracted slices; persistence reads viachat-history.ts. The old ~485-LOC store is gone.src/features/model/components/provider-settings.tsxdelegates connection details and custom model editing to small components. Keep new slices similarly scoped and covered by component tests.src/contents/index.tsis a ~38-LOC entry; selection-capture, dom-observer, and messaging are siblings.src/types/index.tsis a re-export barrel (~11 LOC) overchat,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.