{"owner":"Nagi-ovo","repo":"voyager","hasSkills":true,"totalSkillsCount":25,"totalTokensCount":22882,"categories":["anthropic-skill","plugin-manifest","marketplace"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/issue-review/SKILL.md",".claude/rules/content-scripts.md",".agents/skills/release/SKILL.md",".claude/rules/high-complexity.md",".agents/skills/update-safari-extension/SKILL.md",".agents/skills/voyager-contribute/SKILL.md",".claude/rules/typescript.md",".claude/rules/i18n.md",".agents/skills/voyager-contribute/references/browser-testing.md",".agents/skills/voyager-contribute/references/repo-traps.md","src/features/plugins/README.md",".agents/skills/voyager-contribute/agents/openai.yaml",".agents/skills/update-safari-extension/agents/openai.yaml",".agents/skills/release/references/changelog.md",".agents/skills/release/references/release-body.md",".agents/skills/release/references/safari-dmg.md",".agents/skills/release/agents/openai.yaml",".agents/skills/issue-review/agents/openai.yaml","src/features/plugins/catalog/marketplace.json","src/features/plugins/catalog/plugins/claude-reading-width/plugin.json","src/features/plugins/catalog/plugins/claude-reading-width/README.md","src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json","src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md","src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json","src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md"],"skills":{".agents/skills/issue-review/SKILL.md":"---\nname: issue-review\ndescription: Investigate or fix a referenced Voyager GitHub issue, within the requested delivery scope.\n---\n\n# Issue Review\n\n1. Begin with `gh issue view <number-or-url> --comments` (add `--repo owner/repo` when needed). Read the actual issue before locating the cause in the repository.\n2. Match completion to the request: investigation delivers evidence, root cause or remaining uncertainty, and a recommended fix; implementation delivers the scoped change and applicable verification from `AGENTS.md`.\n3. When the request includes committing or publishing, use `voyager-contribute`. A fix commit or PR includes `Closes #<number>` or `Fixes #<number>`; verify any created commit with `git show --stat --format=fuller HEAD`. An investigation or uncommitted fix needs no commit inspection.\n4. When authorized to post, report the actual status in the reporter's language. Say the fix has landed and is coming in the next version only after it has landed. Close the issue only when the fix has landed or another agreed resolution is satisfied and closing is authorized; a local fix or an open PR alone is not completion of the issue.\n",".claude/rules/content-scripts.md":"---\nglobs: [\"src/pages/content/**\", \"src/styles/contentStyle/**\"]\n---\n\n# Content Script Rules\n\n## CSS\n- All injected CSS classes MUST be prefixed with `gv-` (e.g., `.gv-rtl`, `.gv-pm-trigger`)\n- Shared/static injected CSS goes in the matching part under `src/styles/contentStyle/` by default; the build joins the parts in file-name order into the shipped `contentStyle.css`, so the number prefix is cascade order. Add a numbered part rather than grow one past the commit size limit. Feature-specific dynamic CSS may be injected by content modules or the plugin runtime when values are computed at runtime; keep it `gv-` prefixed and tear it down.\n- Support both light and dark themes through `html[data-gv-scheme='dark']` / `html[data-gv-scheme='light']`, which `platformTheme` stamps from each site's `site.json` theme descriptor. Never target a host's own theme markers (`.theme-host.dark-theme`, `body.dark-theme`, `html.dark`, …); `src/core/utils/__tests__/contentStyleTheme.test.ts` rejects them.\n- RTL layout: use `body.gv-rtl` selector for RTL overrides (see `src/core/utils/rtl.ts`)\n\n## Storage\n- Content scripts use `chrome.storage` / `browser.storage` directly — this is the exception to the \"use StorageService\" rule\n- Outside content scripts, prefer `StorageKeys` plus existing storage helpers/services. Direct `chrome.storage` / `browser.storage` still exists in popup/background/options for established settings patterns, bulk reads/writes, and storage listeners.\n\n## DOM Injection\n- Each content script sub-module in `src/pages/content/` is self-contained\n- Bridge between host sites (Gemini, AI Studio, Claude, ChatGPT, DeepSeek) and the extension — any host DOM may change without notice\n- Safari has platform-specific manifest and native-bridge paths, but cloud sync,\n  watermark removal, and image export are supported. Do not add an `isSafari()`\n  skip from historical assumptions; verify the current Safari implementation\n  and its browser-specific tests first.\n- Extension context can be invalidated after update/reload. Use `isExtensionContextInvalidatedError()`.\n\n## Material Symbols Icons\n- Popup Material Symbols are bundled locally through `public/fonts/material-symbols-outlined.css` and `.ttf`; verify the glyph exists in the bundled font or update the bundled font assets. Do not add a remote Google Fonts URL.\n",".agents/skills/release/SKILL.md":"---\nname: release\ndescription: Prepare, publish, or recover a Voyager release. Use for version bumps and releases, failed store submissions, or Safari release failures.\nmetadata:\n  version: '1.5.0'\n---\n\n# Voyager Release\n\nUse repository scripts and `.github/workflows/release.yml` as the source of truth. Normal releases use CI: a release tag builds the browser artifacts, signs and notarizes Safari, creates the GitHub Release, and submits the stores.\n\n## Choose the requested workflow\n\nRead only the reference needed for the current task:\n\n- New release, version bump, or interrupted release preparation: [normal-release.md](references/normal-release.md).\n- Failed Chrome or Edge submission, or an urgent Firefox-only hotfix: [store-recovery.md](references/store-recovery.md).\n- Safari CI failure or an explicitly requested local recovery artifact: [safari-dmg.md](references/safari-dmg.md).\n\n## Authorization and publication boundaries\n\nThe user's request and existing approval define the scope; loading this skill grants no additional permission. Complete authorized preparation and verification, and reuse approval for the same version, destination, and action. A diagnosis or preparation request does not by itself authorize publication.\n\nBefore a tag push, store submission, or replacement upload, verify that the concrete action is authorized. Ask only when that authorization is missing or the version, scope, or a possible release blocker needs a user decision; prepare the reviewable result first. Explain any newly discovered change to the approved release.\n\n- A tag push triggers the public GitHub Release, Safari signing/notarization and Sparkle feed, Firefox AMO submission, Chrome Web Store publication, and Edge Add-ons submission.\n- Moving a published tag requires explicit authorization for that operation and is allowed only while no release artifacts have been produced. Otherwise recover the affected store or prepare a new version; never silently retag.\n- Preserve unrelated changes and stage release paths explicitly. Never bypass hooks with `--no-verify`.\n- Keep Safari compatibility bundle IDs and signing requirements intact. Do not run a second local Safari release or upload a second DMG while CI is producing the release.\n- Failed required checks block tagging and publication. Fix failures caused by the authorized work and rerun affected checks; ask only when resolving a blocker needs new scope or a risk decision. An accepted unresolved failure remains reported as failed and never bypasses artifact signing, notarization, or privacy requirements.\n\n## Completion\n\nFor preparation, report the version, scoped changes, verification results, and remaining publication step. For diagnosis, report the failing stage, evidence, and the concrete recovery path. Do not advance either request into publication automatically.\n\nFor publication or recovery, finish the applicable workflow and verify its actual result: GitHub assets/body, Safari notarization/privacy evidence and signed Sparkle feed, and the requested stores' signing/submission outcomes. Distinguish submission success from store availability, and report pending review or failures. A successful GitHub Release alone does not prove every store or Safari succeeded.\n",".claude/rules/high-complexity.md":"---\nglobs:\n  [\n    'src/core/services/StorageService.ts',\n    'src/core/services/DataBackupService.ts',\n    'src/core/services/GoogleDrive*.ts',\n    'src/core/services/AccountIsolationService.ts',\n    'src/features/folder/**',\n    'src/features/export/**',\n    'src/pages/content/folder/**',\n    'src/pages/content/export/**',\n    'src/features/plugins/storage/**',\n  ]\n---\n\n# High-Complexity Modules — Edit with Caution\n\n| Module                                     | Risk                                                                                                                                                                | Notes                                               |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |\n| `StorageService`                           | Typed sync/local storage wrapper used where suitable. Persistence is also handled by direct `chrome.storage`/`browser.storage` paths and migration/backup services. | Do not modify lightly.                              |\n| `DataBackupService`                        | Multi-layer backup. Race conditions during unload.                                                                                                                  | Critical for data safety.                           |\n| `GoogleDriveSyncService`                   | OAuth2 cloud sync: folders, prompts, settings, plugins, starred, forks, timeline hierarchy and highlights, scoped per account. Split across `GoogleDrive*.ts`.        | Requires OAuth2 identity.                           |\n| `AccountIsolationService`                  | Hard account isolation for multi-account.                                                                                                                           | Integrates with Drive sync.                         |\n| `features/folder` + `pages/content/folder` | Drag-and-drop + cloud sync UI. DOM manipulation + state sync.                                                                                                       | Watch for infinite loops.                           |\n| `features/export` + `pages/content/export` | JSON/MD/PDF/Image export + Deep Research.                                                                                                                           | Fragile to host UI changes. Multi-browser compat.   |\n| `features/plugins/storage`                | Plugin on/off and settings. The legacy bindings decide which store is authoritative for each plugin, and so what backup and restore keep. | A bound id ignores `gvPluginsState`; keep both backups restorable in either order. |\n\n## Changes to behavior or data\n\nApply these checks when changing logic, serialized formats, account scope, or data handling in these modules. Comments, documentation, and formatting-only changes use the prose/formatting checks in `AGENTS.md`.\n\n1. Read the entire file first — not just the section you plan to change\n2. List all existing features that might be affected\n3. Ensure zero destructiveness to user data\n4. Run the full test suite after changes; reuse that result if a later verification step checks the same unchanged inputs\n",".agents/skills/update-safari-extension/SKILL.md":"---\nname: update-safari-extension\ndescription: Build, reload, and verify Voyager in Safari for web-extension changes, native integration, or signed-app testing while preserving installed apps and extension data.\n---\n\n# Update Safari Extension\n\nUse the least invasive route that can test the change. A successful build and a successful live Safari check are separate evidence.\n\n## Choose the runtime route\n\nCheck `git status --short --branch -uall` and inspect Safari Settings > Developer/Extensions. Record whether Voyager is temporary or app-installed and enabled before changing it.\n\n- **Web extension:** TypeScript, React, CSS, manifest, or bundled assets that stay inside the extension use the temporary route below.\n- **Native or signed app:** Swift, entitlements, Sparkle, signing, packaging, or any call to `browser.runtime.sendNativeMessage` require [native-app.md](references/native-app.md). Temporary extensions cannot launch the containing-app handler, including iCloud sync, Safari Google Drive authorization, and native notifications.\n- **Native behavior:** For Drive authorization, notification delivery/clicks, or account-scoped sync verification, also read [native-behavior.md](references/native-behavior.md).\n\n## Default: temporary extension\n\n1. Run `bun run build:safari`. This builds `dist_safari` and verifies Safari resource wiring.\n2. In Safari Settings > Developer > Temporary Extensions, click Voyager's **Reload**. If absent, choose **Add Temporary Extension…** and select `dist_safari`.\n3. Reload the target Gemini, AI Studio, ChatGPT, or Claude tab once to load the current content script.\n4. Open the Voyager popup and test the changed behavior.\n\nSafari removes temporary extensions after 24 hours or when Safari quits. Re-add `dist_safari`; a routine UI refresh does not require signed installation.\n\n## Safety and recovery\n\n- Preserve `/Applications/Voyager.app` and the legacy `/Applications/Gemini Voyager.app`. Replacing an installed app or changing distribution signing/provisioning requires an explicit signed-app test request. Local development profile refresh is covered by the native route.\n- Preserve Safari extension containers, preferences, storage, permissions, bundle identifiers, and versions; changing them is not a stale-build remedy.\n- Use Safari's **Reload** for normal refreshes. If it looks stale, verify the built asset, reload the target tab, and reopen the popup. For launch/URL routing failures or duplicate entries, use the read-only diagnosis in [native-app.md](references/native-app.md).\n- Registration changes (`pluginkit -r`/`-a` or manual unregister/register) are targeted recovery only, with Safari closed, an exact stale/current path pair, and a recovery plan. Reuse explicit user authorization that covers this recovery; otherwise complete read-only diagnosis and prepare the plan before asking. A generic reload request does not authorize it.\n- Never bulk-reset LaunchServices or unregister/remove an app in `/Applications` without explicit permission. Avoid launching apps from Archives, backups, disk images, or old DerivedData; duplicate bundle identifiers can route Safari or custom URLs to the wrong copy.\n- Keep signing identities, provisioning-profile contents, Keychain data, Apple account details, notarization credentials, and CI secret values out of output and commits. Refer only to repository-documented environment-variable names. Keep generated apps, archives, DerivedData, and signed artifacts out of commits unless explicitly tracked.\n- If a feature disappears, stop the update attempt, preserve app and extension data, return to the previously working extension route, and verify the feature.\n\n## Evidence and completion\n\nReport the highest tier actually proven:\n\n1. **Build:** `bun run build:safari` passes.\n2. **Artifact:** expected JS/CSS/resources exist in `dist_safari` and the Xcode bundle wiring check passes.\n3. **Loaded:** Safari shows the intended temporary or containing-app extension enabled once, without an unexpected duplicate.\n4. **Live behavior:** the popup/page visibly shows the changed UI or behavior after reloading the target tab.\n5. **State safety:** pre-existing cards/settings remain visible. A missing card is not evidence of successful loading.\n\nInspect the reloaded popup for popup changes; reload the website for content-script changes; close and reopen the popup for background/native changes. Claim the latest build is loaded only after checking live behavior or a build-specific visible marker in Safari. A marker alone does not prove the changed behavior; report any unverified behavior or state safety.\n\nA standalone popup tab has no source-tab account scope. For account-scoped sync checks or a toolbar popover dismissed before automation reaches its button, follow the testing boundaries in [native-behavior.md](references/native-behavior.md).\n",".agents/skills/voyager-contribute/SKILL.md":"---\nname: voyager-contribute\ndescription: Prepare Voyager commits, pull requests, and contribution evidence, or claim an issue and check review readiness.\n---\n\n# Voyager Contribution\n\nUse only the stages included in the user's request; a readiness review delivers findings, while an authorized commit or publication continues through verification and handoff. Route issue investigation to `issue-review`, Safari loading or native verification to `update-safari-extension`, and releases to `release` when needed.\n\n## 1. Preflight\n\n1. Inspect `git status --short --branch`, the current branch, and the diff. Preserve unrelated work.\n2. Read the linked Issue and its comments when one exists. A new feature needs explicit maintainer approval of the approach, either in the Issue or as a direct instruction in the current task; assignment or `/claim` only selects an owner.\n   The PR gate (`.github/workflows/pr-gate.yml`) closes any PR from an author outside `.github/MAINTAINERS` and `.github/APPROVED_CONTRIBUTORS` unless it has a `fix:` / `fix(scope):` title and stays within 20 files and 1,000 changed lines, or a maintainer added the `maintainer-approved` label. Docs, refactor and feature PRs from such authors need that alignment first. A maintainer's `/approve @user` both assigns the issue and adds the user to `APPROVED_CONTRIBUTORS`.\n3. Work on one focused topic branch targeting `main`. Keep secrets and generated `dist_*` artifacts out of commits.\n4. Read [repo-traps.md](references/repo-traps.md) — the repository-specific pitfalls that have cost past contributors the most review rounds.\n5. Use `gh` as the source of truth for Issue/PR state. Before any GitHub write, confirm `gh auth status` shows the account you intend to contribute as. If GitHub CLI is unavailable, perform Issue/PR reads and writes through the GitHub web UI instead and note that in the PR.\n\nPreflight is complete when the Issue or rationale, approval state, intended scope, current branch, and clean ownership of every changed file are known.\n\n## 2. Verify\n\n1. Add or update regression tests for behavior changes. If no useful automated test exists, record the reason.\n2. Use the verification matrix in [AGENTS.md](../../../AGENTS.md#verification): code PRs require `bun run verify:pr`; prose-only changes use the applicable document checks. During implementation, run focused checks; reuse passing evidence for unchanged inputs. If formatting or lint fixes are needed, apply them to the intended files with `bun run format <paths>` / `bun run lint <paths>` and inspect the diff before checking the final tree. Read-only reviews use check variants.\n\n   `verify:pr` covers local automation and production browser builds; it does not prove that an extension loaded or that live behavior works. Finish with `git diff --check` and `git status --short`.\n\n3. For runtime, UI, manifest, permission, packaging, native, or plugin changes, read [browser-testing.md](references/browser-testing.md) and collect the required live evidence.\n4. Record every omitted command or browser check with its reason. For required coverage that another person must complete, name the browser and owner and leave the item pending.\n\nVerification is complete when every applicable automated check and browser check has a truthful result, the working tree matches the snapshot being tested, and remaining work is explicitly assigned.\n\n## 3. Commit and publish\n\n1. Review the final diff and stage only intended paths.\n2. Create a Conventional Commit with a lowercase scope and a header no longer than 100 characters. Add `Fixes #<issue>` or `Closes #<issue>` when appropriate. Retain the active agent's project-standard co-author footer; Codex-authored commits use:\n\n   ```text\n   Co-authored-by: Codex <codex@users.noreply.github.com>\n   ```\n\n3. Inspect the commit with `git show --stat --format=fuller HEAD` and `git status --short`. Require no staged or unstaged changes in the contribution's paths; preserve and disclose unrelated pre-existing work. `HEAD` is the tested commit only while the verified paths match it, and any later change invalidates affected evidence.\n4. When publishing is authorized, push only the topic branch and open or update a draft PR targeting `main`. Contributors never push directly to `main`; use force-push only with explicit user approval — this repository squash-merges, so a merged branch will later look unmerged (see repo-traps.md) and must be deleted, not re-pushed. Exception: this rule governs contribution workflows; a maintainer with push access working outside a contribution follows the project-wide default push rules in `CLAUDE.md`/`AGENTS.md`, which take precedence.\n5. In the PR, state the linked Issue or direct authorization/rationale, scope, tested commit, commands run, live browser evidence, screenshots for UI changes, and all pending checks. Re-run affected checks after review changes.\n6. Verify the PR author, base branch, commit set, changed files, and CI state with `gh` before handoff.\n\nPublishing is complete when the focused diff is on a topic branch, the draft PR accurately reports its evidence and gaps, and the user receives the PR URL plus pending review or CI work.\n\nAfter an Issue fix lands, leave a short comment in the reporter's language: the fix has landed, it will be available in the next version, and the Issue may be reopened if the problem remains.\n",".claude/rules/typescript.md":"---\nglobs: [\"src/**/*.ts\", \"src/**/*.tsx\"]\n---\n\n# TypeScript Coding Standards\n\n## DOs\n- Prefer plain objects with interfaces/types for data structures\n- Use `map`, `filter`, `reduce` for immutability\n- Use `private`/`protected` in classes\n- Use `unknown` + narrowing (custom type guards) for type safety\n- Use named exports: `export function X`\n- Functional React: hooks at top level, strictly functional components\n\n## DON'Ts\n- **No `any` type.** Use `unknown` if you must, then narrow it.\n- **No global variables** outside defined Services.\n- **Avoid ad hoc storage in UI components** (`src/components/`, `src/pages/popup/`). Prefer `StorageKeys` plus existing storage helpers/services; direct `chrome.storage` / `browser.storage` is acceptable when matching established popup settings patterns, bulk reads/writes, or storage listeners.\n- **No God Components.** Business logic belongs in `features/*/services/` or custom hooks, not UI files.\n- **No magic strings.** Use constants or enums (StorageKeys, CSS classes).\n- **No `console.log` in production.** Use `LoggerService` for critical info; oxlint only warns, so do not add new ones.\n\n## Testing (Vitest + jsdom)\n- Chrome `chrome` object is globally mocked in `src/tests/setup.ts`\n- Mock specific storage: `vi.mocked(chrome.storage.sync.get).mockResolvedValue({ key: 'val' })`\n- jsdom cannot create trusted events: code that tells a person's input from a script's goes through `isUserEvent` (`src/core/utils/userEvent.ts`), and tests mock it\n- Run: `bun run test`, `bun run test <filename>`, `bun run test:coverage`\n",".claude/rules/i18n.md":"---\nglobs: [\"src/locales/**\", \"src/features/plugins/verbs/*/messages/**\"]\n---\n\n# i18n / Translation Rules\n\n## 10 Locales (ALL must be updated together)\n`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`\n\n## When adding a new translation key\n1. Add the key to ALL 10 `src/locales/*/messages.json` files\n2. English (`en`) is the source — write it first, then translate to all others\n3. Each key is a top-level entry in Chrome's `{ \"key\": { \"message\": \"…\", \"description\": \"…\" } }` format; no nested keys\n4. Run `bun run i18n:check`: it fails when any locale is missing a key from `en` or has an extra one\n\n## When removing a translation key\n- Remove from ALL 10 locale files\n\n## Primitive fragments\n- A string used only inside one primitive folder goes in `src/features/plugins/verbs/<name>/messages/<locale>.json` (same format, all 10 locales), not in `src/locales`; it reaches `getTranslationSync` but not `chrome.i18n`. Type its keys by augmenting `PrimitiveMessages` (see `src/utils/translations.ts`).\n- `bun run i18n:check` also requires every fragment locale to match its `en.json` and forbids keys that collide with `src/locales` or another fragment.\n\n## Quality\n- Translations should be natural, not machine-literal\n- Arabic (`ar`) is RTL — ensure UI handles it (see `src/core/utils/rtl.ts`)\n",".agents/skills/voyager-contribute/references/browser-testing.md":"# Browser verification\n\nRead this reference only when a change affects extension runtime, UI, manifests, permissions, packaging, native integration, or plugins.\n\n## Evidence\n\nReport only the highest level actually completed:\n\n- **Build**: the command succeeds and the expected artifact exists.\n- **Loaded**: that artifact is enabled once in the browser with no manifest or load error.\n- **Live**: after reloading the extension and target tab, the changed workflow works and existing state remains intact. The `dist_chrome_dev` build reloads itself after each dev build, so there only the tab needs refreshing; the [verify-in-browser](../../verify-in-browser/SKILL.md) skill covers confirming the loaded build.\n\nA build, unit test, or code inspection is not `Loaded` or `Live` evidence.\nFor `Loaded` or `Live`, record the browser name, exact version, scenario, result, and redacted evidence.\n\n## Route by risk\n\n| Change                                                                                           | Required evidence before review is complete                                                                                                                                                |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Docs, comments, or test-only work                                                                | Relevant automated checks; explain why browsers are not applicable.                                                                                                                        |\n| Pure logic with no browser API or visible behavior change                                        | `bun run verify:pr`; add focused tests.                                                                                                                                                    |\n| Popup, content script, background, shared CSS, storage, or plugin runtime                        | `verify:pr`, then `Loaded` and `Live` on Chrome and Firefox. Add Edge for Chromium-specific, manifest, permission, or packaging behavior. Add Safari whenever Safari behavior is affected. |\n| Browser-specific code, permissions, native messaging, Swift, signing, packaging, or release work | `Loaded` and `Live` on each affected browser, builds for the others, and the feature-specific regression checks. Use the Safari or release skill where applicable.                         |\n| Bundled official or builtin plugin                                                               | `Live` on Chrome, Edge, Firefox, and Safari before merge unless the manifest explicitly supports fewer browsers, plus the plugin checks below.                                             |\n\nMissing hardware or access may be covered by another contributor. Record `Needs <browser> test; owner: @name`; a draft PR may remain open, but it is not review-ready until required evidence is added.\n\n## Artifacts\n\n| Browser | Command                                        | Load target                                                                                                                              |\n| ------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| Chrome  | `bun run dev:chrome` or `bun run build:chrome` | `dist_chrome_dev` for routine development (load once; it reloads itself after each dev build); `dist_chrome` for production verification |\n| Edge    | `bun run build:edge`                           | `dist_edge`                                                                                                                              |\n| Firefox | `bun run build:firefox`                        | `dist_firefox/manifest.json` via `about:debugging`                                                                                       |\n| Safari  | `bun run build:safari`                         | `dist_safari`; then follow the `update-safari-extension` skill                                                                           |\n\n`bun run build:all` and `bun run verify:pr` exclude Edge (the Edge zip step needs the external `zip` executable, which Windows lacks by default — CI's `Build (edge)` covers it). `bun run build:browsers` includes the Edge package build.\n\n## Baseline smoke\n\nFor each required browser:\n\n- Load exactly one enabled Voyager artifact with no manifest or startup error.\n- Open the popup and exercise the changed workflow on every affected site.\n- Check relevant popup, background/service-worker, and page consoles for new errors.\n- Reload the extension and target page; confirm UI, listeners, styles, and notifications are not duplicated.\n- Confirm existing settings and stored data remain intact.\n- For visual changes, check light and dark themes, alignment, spacing, narrow and wide layouts as applicable.\n- Preserve `/u/<index>/...` account scope when the feature is account-sensitive.\n\n## Plugin checks\n\nFor `src/features/plugins/catalog/`, catalog mappings, or builtin/native plugins:\n\n- Confirm the plugin appears once from the intended bundled source.\n- Match the default enabled state of the nearest sibling plugin unless an approved migration says otherwise.\n- Enable, disable, re-enable, and reload; verify state persistence and complete teardown without duplication.\n- Test every declared target site and confirm unrelated sites are unchanged. Test both start and stop paths for builtin JS plugins.\n- Keep custom-site access as an explicit optional grant; direct `<all_urls>` permission requires prior user approval.\n- Attach redacted screenshots or recordings for visible behavior. Never expose account data, conversations, tokens, signing identities, or native handoff URLs.\n",".agents/skills/voyager-contribute/references/repo-traps.md":"# Repository-Specific Traps\n\nEvery item below cost a real contributor at least one review round (source PR in parentheses). CI catches none of them — read this before writing code, not after review.\n\n## Git and PR mechanics\n\n- **This repository squash-merges.** After your PR merges, your local branch will show as unmerged or conflicting — that is normal. Delete it and branch fresh from `main`; never re-open a PR from the old branch (#867 was closed as a self-duplicate created exactly this way).\n- **The PR title becomes the squash commit header.** Title it `type(scope): imperative summary` — never the branch name or \"Fixes #N\" (#859, #865, #867).\n- **Check `git diff origin/main --stat` (or whichever remote tracks the base repository's `main`) before opening the PR** so unrelated churn (assets, lockfiles) never rides along (#865 accidentally wiped `sponsors.svg`).\n- **`@codex review` triggers the automated review** (bare `@codex` does nothing). Trigger it once per new head, re-trigger after any rebase/force-push, and don't re-trigger while a run is pending (#854). The human reviewer finalizes only after Codex has covered the exact current head SHA.\n\n## Reuse the sibling precedent\n\nGrep for the existing primitive before writing parallel logic — reviewers treat a one-off reimplementation of an existing behavior as a defect:\n\n- Send/keyboard handling: reuse the `sendBehavior` detector — it already handles localized labels (发送/送信/enviar) and icon-only buttons, and respects the `gvCtrlEnterSend` setting. An English-only selector or unconditional Enter handler will be rejected (#854).\n- Text insertion: `chatInput` has multiline-aware insertion; a bare `createTextNode` path flattens multiline prompt bodies (#854).\n- Export CSS: extend a shared style builder (see `buildKatexExportStyles`) instead of pasting CSS into a second export service (#847).\n- Export DOM walking: `DOMContentExtractor` must use ONE shared DOM-order traversal helper — Gemini nests arbitrary `section`/`div`/`response-element` layers, and any second code path will miss one (#847 spent 4+ rounds on this).\n- Body-level popovers, global listeners, overlays: copy an existing `gv-pm-*` integration (close-outside handlers, teardown, theme overrides).\n\n## New-site features go through the plugin system\n\n- Adding support for a new site (ChatGPT, Claude, …) goes through the plugin system: declarative CSS+JSON plugins belong in the bundled catalog (`src/features/plugins/catalog/` via `BundledCatalogPluginSource`); only native-function plugins that genuinely need JS go in `src/features/plugins/builtin/` with optional host permission, dynamic content-script registration, and a `start`/`stop` lifecycle with teardown. Plugins start disabled unless an approved default lists them in `DEFAULT_ENABLED_PLUGIN_IDS` (`storage/pluginDefaults.ts`; today only the bundled catalog plugin `voyager.chatgpt-folders`), and even then the content script runs only on origins the user granted.\n- `builtin/` also holds the packaged Gemini / AI Studio official plugins (`gemini*Manifest.ts`, `aistudio*Manifest.ts`). Those are not new-site plugins: their switch is the old sync setting (`storage/legacySettingBindings.ts`), so their default is that binding's reading of a missing key (usually the native feature's old default), and they run only where the content script admits them. Follow `create-voyager-plugin`'s \"Convert a native Gemini or AI Studio feature\" for them. Never add static `content_scripts` or required `host_permissions` to `manifest*.json` — manifest permission escalation without prior Issue approval is a hard stop (#865).\n- Platform registries are additive: never delete or narrow another platform's adapter or tests to make room for yours (#865).\n- Any storage-toggled feature needs a full lifecycle — working start AND destroy at page load and on runtime toggle. Hiding the UI is not disabling the feature (#854).\n\n## Docs and locale mirrors\n\n- \"The docs\" are 20+ surfaces: the main `README.md`, nine `.github/README_*.md` locale mirrors, ten `docs/<locale>/` trees, and `docs/public/llms*.txt`. The green `i18n` CI check only compares `src/locales/*/messages.json` keys — it covers none of these (#874, #853).\n- Never delete a published docs route — there is no redirect layer, so replace retired pages with a localized notice at the original URL (#874).\n\n## Theme and data correctness\n\n- Host theme markers (`.theme-host.dark-theme`, `body.dark`, `html.dark`, …) belong only in each `site.json` `theme` descriptor; `src/pages/content/platformTheme/scheme.ts` reads it and stamps `html[data-gv-scheme]`. Voyager CSS keys off `html[data-gv-scheme='dark' | 'light']` and JS uses `getScheme()` / `subscribeScheme()`; `contentStyleTheme.test.ts` rejects host markers in CSS. Test theme logic against the descriptor with conflicting markers, not single signals (#859).\n- Theme the whole export artifact consistently — a dark diagram on a forced-white document is not dark-mode support (#847).\n- Gemini conversation IDs are namespaced (`gemini:conv:<id>`); shared code that handles raw IDs silently orphans existing starred/bookmark data (#865). Preserve `/u/<index>/...` account scope in every constructed route.\n- Prompt/folder data has multiple merge entry points (`utils/merge.ts` plus page-level Drive merges); route data-shape changes through one shared merge helper, and never drop or rename existing user records on conflict (#854).\n\n## Scope discipline\n\n- The reviewer diffs your behavior against the Issue's confirmed scope and blocks deviations in either direction. When an edge case tempts you to change a product constraint (e.g. enforce name uniqueness), ask in the Issue first (#854); state non-goals explicitly in the PR description.\n- Trivially verifiable one-line fixes may go straight to PR (#876). Features require explicit maintainer approval of the approach, in the Issue or a direct instruction in the current task; assignment alone is not approval (#865).\n","src/features/plugins/README.md":"# Plugin Ecosystem (`src/features/plugins`)\n\nA self-contained subsystem that lets Voyager run **plugins** — units that inject\nstyles / DOM changes into AI chat sites (Gemini, AI Studio, ChatGPT, Claude,\nDeepSeek, …). Voyager's own features can migrate onto this over time; third parties\ncan ship their own plugins against the same contract.\n\n## Two design constraints that shaped everything\n\n1. **Chrome MV3 forbids remotely-hosted code.** You may download _data_ (JSON/CSS)\n   and run it through engine logic that ships **inside** the package; you may not\n   download and execute JS. (`chrome.userScripts` is the only sanctioned escape\n   hatch, and it's gated behind a per-user \"Allow User Scripts\" toggle and is\n   unavailable on Safari.)\n2. **The core is GPL-3.0 with many copyright holders.** It can't be relicensed or\n   closed. Monetizable / proprietary plugins must therefore be **independent works**\n   — and _data read by a GPL engine is not a derivative work of it._\n\nBoth constraints point at the same answer → **declarative-first** plugins.\n\n## Two tiers\n\n| Tier                    | Ships                       | Store-safe                     | Runs on                   | Status                                          |\n| ----------------------- | --------------------------- | ------------------------------ | ------------------------- | ----------------------------------------------- |\n| `declarative` (default) | CSS + JSON (`domOps`)       | ✅ everywhere, remote-loadable | Chrome / Firefox / Safari | **implemented**                                 |\n| `scripted` (advanced)   | JS via `chrome.userScripts` | gated toggle, no Safari        | Chrome / Firefox          | reserved (gated, runtime is a future milestone) |\n\n## Architecture\n\n```\nPluginSource[]  ──►  manifests        SiteRegistry ──► SiteAdapter (current URL)\n   (native builtin,\n    bundled catalog,\n    remote host catalog,\n    local imports)                         ▼\npluginState (storage) ─► enabled?    DeclarativeEngine (interprets contributions)\nEntitlementProvider  ─► entitled?         │  styles + domOps, reversible, idempotent\n        └──────────►  PluginHost.reconcile() ──► engine.mount/unmount\n```\n\n- **`types.ts`** — the whole contract: `PluginManifest`, `PluginContributions`,\n  `DomOperation` (discriminated union — the main extension point), `SiteAdapter`,\n  and the `PluginSource` / `EntitlementProvider` seams.\n- **`sites/`** — `SiteAdapter` per site + a `SiteRegistry` that resolves the\n  current URL. Site-specific selectors live **only** in an adapter, behind the\n  fixed semantic vocabulary in `sites/semanticKeys.ts` (`userTurn`,\n  `assistantTurn`, `thinkingBlock`, `codeBlock`, `composer`, `sidebar`,\n  `sidePanel`, `headerActions`, `scrollContainer`, `composerRoot`,\n  `sendButton`), so a site redesign is a\n  one-file fix. Every adapter is data: `adapters/gemini.ts`, `adapters/aistudio.ts`,\n  `adapters/claude.ts`, `adapters/chatgpt.ts` and `adapters/deepseek.ts` are\n  one-line shells over their `site.json`: edit the JSON, not the TS. Gemini and\n  AI Studio stay native surfaces (`NATIVE_SITE_IDS`, `sites/hostPolicy.ts`); their\n  `site.json` is packaged with the extension and never published remotely.\n  Gemini's `userTurn`/`assistantTurn` are the joined membership lists from\n  `core/gemini/turnSelectors.ts`, bound by `adapters/gemini.parity.test.ts`;\n  timeline, export and highlight keep reading the ordered lists there.\n  `sites/siteAdapterData.ts` holds\n  `validateSiteAdapterData`, the one validator for the bundled `site.json`, the\n  remote override and `catalog:build`.\n- **`runtime/declarativeEngine.ts`** — applies a manifest's `styles` + `domOps`.\n  Reversible (full teardown), idempotent, and uses a `childList`-only\n  MutationObserver so its own mutations can't loop. Pure DOM → all platforms.\n- **`runtime/PluginHost.ts`** — orchestrator. Loads manifests, checks\n  match-URL + enabled + engine-range + entitlement, mounts/unmounts, reacts to\n  state changes. All dependencies injected → fully unit-testable.\n- **`runtime/officialAdmission.ts`** — when each packaged Gemini/AI Studio\n  official plugin may mount: the content script admits it where its native\n  module used to start, and `close` denies whatever is still pending.\n- **`runtime/resourceClaims.ts`, `resourceArbiter.ts`, `reconcileArbitration.ts`,\n  `releaseLedger.ts`** — exclusive page resources (`timeline-rail`, the composer\n  send mode, …): which plugin holds each, why the others are `blocked`, and\n  waiting until a released runtime has really shut down.\n- **`runtime/stateMachine.ts`** — `startMachine`, the runtime for features with\n  interruptible phases: a pure `transition`, one `PluginScope` per status, late\n  callbacks from a left status ignored. Used by export selection, default\n  model, draft restore, reply notifications and both batch deletes; see\n  [the state machine plan](../../../.github/docs/STATE_MACHINE_PLAN.md).\n- **`manifest/validate.ts`** — turns `unknown` into a typed manifest or a list of\n  issues. Remote catalog plugins are untrusted; bundled catalog plugins use\n  the same validator so official data cannot drift from the runtime contract.\n- **`storage/pluginState.ts`** — per-plugin enable state in `chrome.storage.local`.\n- **`sources/` `entitlement/`** — the swap points for native first-party\n  features, bundled official declarative plugins, the per-host remote catalog,\n  the user's locally imported plugins, and a future paid (Stripe/account) store. `sources/defaultSources.ts` also\n  owns `mergePluginRecords`, the rules that pick which copy of a plugin id wins.\n- **`local/`** — user-imported declarative plugins: the `local.*` id namespace,\n  the import gate, storage, the read-only `LocalPluginSource` and the inspect\n  summary the popup shows. See \"Write your own plugin locally\" below.\n- **`remote/`** — the per-host remote catalog channel: a read-only source over a\n  storage cache, plus a background-only fetcher. See below.\n- **`catalog/`** — the bundled official data: one directory per site, each\n  holding that site's `site.json` and its declarative plugins. This\n  keeps official CSS/JSON changes in the same PR, CI run, and release as engine\n  or popup changes.\n\n```\ncatalog/\n  marketplace.json                        list of public catalog plugins (tests, plugin:new)\n  sites/index.ts                          import.meta.glob discovery\n  sites/<site>/site.json                  the site adapter, as data\n  sites/<site>/plugins/<id>/plugin.json   a declarative plugin\n  sites/<site>/plugins/<id>/style.css     its styles\n  sites/<site>/plugins/<id>/README.md     what it fixes and why\n```\n\n`catalog/sites/index.ts` finds every `site.json` and `plugin.json` with\n`import.meta.glob`, so adding a site or a plugin is adding files: there is no\nmapping table. (`scripts/build-plugin-catalog.ts` reads the same tree from disk,\nbecause `import.meta.glob` does not exist under Bun.) Sites today are\n`aistudio`, `chatgpt`, `claude`, `deepseek` and `gemini`. A plugin's `matches`\nmust stay inside its site's `matches` (plan D18) or the build fails, and\n`bun run catalog:build` validates every site and plugin before publishing.\nGemini and AI Studio are validated like the rest but never published: the host\npolicy (`allowsRemoteCatalog`) keeps their four hosts out of the per-host\ncatalog, and their plugins stay out of `marketplace.json`.\n\n`catalog/marketplace.json` is **not** that mapping table, and no page reads it:\nthe site's plugin table is built from the manifests themselves\n(`docs/.vitepress/pluginsPage.ts`, which lists the Gemini and AI Studio\npackaged plugins too). It stays as the list of public catalog plugins that\n`catalog/sites/index.test.ts` keeps in sync with discovery and `plugin:new`\nappends to, so a new plugin still needs an entry whose `source` is the\ncatalog-relative path (`sites/deepseek/plugins/reading-width/plugin.json`).\nPlugins under `sites/gemini/` and `sites/aistudio/` are not listed.\n\n## Authoring a declarative plugin\n\n```jsonc\n{\n  \"id\": \"vendor.my-plugin\",\n  \"name\": \"My Plugin\",\n  \"version\": \"1.0.0\",\n  \"description\": \"...\",\n  \"author\": \"...\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://claude.ai/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"file\": \"style.css\" }],\n    \"domOps\": [{ \"op\": \"addClass\", \"target\": \"body\", \"className\": \"gv-plugin-readable\" }],\n  },\n}\n```\n\nAn official plugin is authored as files under its platform's directory:\n\n1. Pick the site it belongs to. If that site has no `catalog/sites/<site>/`\n   directory yet, write its `site.json` first (`id` equal to the directory name,\n   `label`, `matches`, `selectors` keyed by the semantic vocabulary, `theme`,\n   `brandColor`, `capabilities`, optional `conversationIdPattern` and\n   `turnKeyAttributes`). Nothing registers it: the registry picks it up from the\n   file. `turnKeyAttributes` names the attributes that keep a user message's\n   identity across remounts; send tracking (`sends/trackUserSends.ts`, behind\n   folder Activity and catalog timeline timestamps) runs only on a site that\n   names them, its `composer` and `userTurn`, and a `conversationIdPattern`.\n2. Create `catalog/sites/<site>/plugins/<id>/` with `plugin.json`, `style.css`\n   and a short `README.md`.\n3. Keep the plugin's `matches` inside the site's `matches` (D18).\n4. Add the entry to `catalog/marketplace.json` (`plugin:new` does it for you).\n5. Run the suite: discovery, D18 and the marketplace index are all asserted in\n   `catalog/sites/index.test.ts`.\n\nManifests may keep tiny CSS inline with `{ \"css\": \"...\" }`, but the preferred\nauthoring shape is `{ \"file\": \"style.css\" }` next to `plugin.json`. The bundled\nsource and the published catalog file both resolve that CSS to inline text,\nreject anything that loads, from the page's own origin too (`@import`,\n`image-set()`, `image()`, `cross-fade()`, `src()`, a `url()` other than a\nraster-image `data:` URL or `#fragment`, or any string that starts with an\nexternal URL), and normalize\nit before the runtime sees it. A `data:` URL in `url()` must declare a raster\ntype (`image/png`, `jpeg`, `gif`, `webp`, `avif`, `bmp` or an icon type);\n`data:image/svg+xml` is refused in every encoding, because an SVG used as a\nfilter, mask or clip-path resource can load images of its own. For\nuser settings, `{{settingKey}}` tokens can be used in CSS text or in\n`setAttribute` / `setStyle` DOM op values; a common pattern is for CSS files to\nuse a normal custom property and for a `setStyle` op to set that variable from a\nsetting.\n\nSelect settings render their declared value/label options in the popup. Localize\neach option under `i18n.<locale>.settings.<key>.options.<value>` (engine 1.6.0+).\nA select needs at least one option, unique string values (`\"\"` included) and a\ndefault among them; a stored choice the manifest no longer offers resolves to\nthat default in both the popup and the host (`runtime/resolvePluginSettings.ts`).\n\nSetting fields may declare `enabledBy: \"<booleanKey>\"` to depend on another\nboolean setting in the same plugin. The popup keeps number, select and boolean\ncontrols visible with their values, but disables them unless the controlling\nsetting's resolved value is `true`; stored values remain intact. The key must\nbe non-empty and name another boolean field without its own `enabledBy`\n(one level only). This affects popup controls; page behavior keeps its own\nboolean gate.\nEngine 1.7.0 ignores `enabledBy` and leaves the control enabled, so this field\nrequires no higher engine floor.\n\n`target` is a CSS selector string, or `{ \"kind\": \"semantic\", \"key\": \"userTurn\" }`\nto use the site adapter's stable selector for one of the eleven semantic keys in\n`sites/semanticKeys.ts`; a `site.json` may not invent a key outside that\nvocabulary. Supported ops: `addClass`,\n`setAttribute`, `setStyle`, `hide`, plus `native` (below). All are reversible.\nInline styles are layered per longhand property: a `setStyle` value or a whole\n`style` attribute is expanded into longhands (a trailing `!important` is kept as\npriority), so turning one plugin off restores only the longhands it wrote and never\nanother live plugin's value. A `var()` shorthand written with `setStyle` stays whole.\nClasses must be `gv-` prefixed (content-script rule). `setAttribute` names are an\nexact-match allowlist: `data-*`, `aria-*`, `title`, `role`, `lang`, `dir`,\n`hidden`, `tabindex`, `draggable`, `spellcheck`, `translate` and `style`; URL,\n`on*`, `class` and `id` attributes are refused because a selector can reach any\npage element. `setStyle` values and `style` attributes follow the CSS rule\nabove, and attribute values may not contain an external URL. All of this is\nchecked again after `{{setting}}` substitution (`manifest/sinkGuards.ts`).\n\n## Write your own plugin locally (`local/`)\n\nA user can run a declarative plugin they wrote without publishing it:\n\n1. Write `plugin.json` exactly as above, plus its `style.css` (or inline the CSS\n   as `{ \"css\": \"...\" }`). Any lowercase reverse-dotted `id` works; it is stored\n   as `local.<id>`.\n2. Open the popup and find **Local plugins**: on Claude, ChatGPT or DeepSeek it\n   sits on the plugin page; on Gemini and AI Studio it is a settings card\n   (collapsed until opened, or until a local plugin targets the page, which\n   then also shows its toggle and settings). By default it follows cloud sync\n   and the eight plugin topic cards and comes before folders through general\n   (`NATIVE_POPUP_SECTION_IDS` in `pages/popup/utils/nativeSiteCapabilities.ts`);\n   users can reorder it. Choose **Import files** (pick\n   the manifest together with its `.css` files) or **Paste JSON** (a manifest\n   with its CSS inlined).\n3. A rejected import lists every problem as `path: message` and leaves the\n   installed version untouched. An accepted one is stored **disabled** and its\n   inspect view shows the sites it matches, the CSS size, every page change,\n   each Voyager primitive it calls with its params, and its settings.\n4. Turn it on from the plugin list like any other plugin; it applies live. To\n   update it, edit the files and import again: a re-import also lands disabled,\n   so the user re-inspects the new version (it may add a `native` op) and turns\n   it back on. A plugin with a `native` op that was running when it was\n   re-imported keeps its mounted version until the page reloads, as official\n   ones do (D7). **Export** downloads the stored manifest with CSS inlined;\n   **Remove** deletes it with its enable state and settings.\n\n**Describe a change in one sentence.** The same panel can write the manifest\nwith any AI the user already uses, while Voyager itself stays offline:\n\n- `local/pluginAuthoringPrompt.ts` turns the sentence and a chosen site (the\n  active tab's when supported) into a self-contained prompt: the request, the\n  plugin contract for that site (see \"One contract, four outputs\" below), and\n  \"reply with exactly one ```json block\".\n- The user copies the prompt and sends it themselves. Voyager never sends it,\n  opens or deep-links an AI page, reads one back, or makes a request.\n- `local/pluginReply.ts` takes the pasted reply (bounded, linear fence scan;\n  one ```json or unlabeled block, or bare JSON; zero or several blocks are\n  refused by name) through `readLocalPluginFiles` and the gate without\n  installing it. The popup closes while the user is in their AI tab, so this\n  step works on its own in a fresh popup.\n- `local/pluginPreview.ts` describes the result in plain terms (sites, each\n  change, warnings for hidden content via `hide`, `hidden` or a hiding inline\n  style, raw selectors, a replaced version, a\n  reply that misses the selected site (the active tab's, or the user's pick;\n  recomputed when the picker changes, no prompt needed), and a `theme` the\n  prompt asked the AI to leave out; a primitive is listed as a change, not\n  flagged, because the prompt offers it where the gate accepts it). CSS is not summarized: its full\n  source is shown with a plain warning that it can change anything, hiding\n  included, since reading its effect would be a guess. It builds on `inspectPlugin`, and\n  the same inspect view opens under it on demand. **Import** hands the exact previewed\n  object to `importLocalPlugin`, so it lands disabled and follows the re-import\n  rules above, but only over the install the preview saw: if the same id was\n  installed, changed or removed meanwhile, nothing is written and the preview\n  is refreshed for a new review.\n\n**One contract, four outputs.** `local/authoring/pluginContract.ts`\n(`buildPluginContract(site, { audience })`) is the single description of what a\nlocal plugin may contain: manifest fields, styles, page changes, settings, the\nlocal rules, the site's anchors and notes, the primitives (read only through\n`primitiveReference.ts`), what is not possible, and examples. Four outputs\nrender it:\n\n- the popup prompt above (`audience: 'chat'`: inline CSS, no `theme`, primitives\n  where the site allows them, plus a pointer to the public skill for an AI that\n  can browse; the pasted contract wins where the two differ);\n- the public agent skill `docs/public/skills/write-voyager-plugin/SKILL.md`\n  (`authoringDocs.ts`), committed and served at\n  `https://voyager.nagi.fun/skills/write-voyager-plugin/SKILL.md`. Regenerate it\n  with `bun run skills:build`; `authoringDocs.test.ts` fails while it is stale,\n  so a new primitive, a changed `site.json` or a changed local rule shows up\n  there;\n- the `AGENTS.md` that `create-voyager-plugin init` writes next to a new plugin;\n- the examples, which `pluginContract.test.ts` runs through\n  `validateLocalManifest` (file examples through `readLocalPluginFiles`) for\n  every site and both audiences, so no output can teach what the gate refuses.\n\n`packages/create-voyager-plugin/` bundles the same gate and the bundled site\nadapters into one dependency-free Node file (`bun run cli:build`): `init` writes\na passing `plugin.json`, `style.css` and `AGENTS.md`, and `check` runs\n`readLocalPluginFiles` plus `validateLocalManifest` offline, exit 0 or 1. Its\ntests hold it to parity with the in-process gate over a corpus. The package\nversion follows the extension (`scripts/bump-version.js`).\n\nThe gate (`local/validateLocalManifest.ts`) is the remote catalog's gate plus\nlocal-only rules, never a weaker one: `validateManifest` with the CSS and\nrendered-sink guards, `tier: \"declarative\"` only, `native` ops only for primitives\nthis build ships with params that match their contract and an `engine` floor\nat or above their `sinceEngine` (`manifest/primitiveChecks.ts`, shared with\n`plugin:check`), and `matches` inside a plugin platform's `matches` or a native\nsurface's (`sites/nativeSurfaces.ts`: Gemini, AI Studio), so enabling a local\nplugin can only request host access an official plugin could, or none at all.\nA plugin for Gemini or AI Studio may not declare `theme` (those pages keep\nVoyager's own accent). Its `native` ops follow a capability policy per (site,\nhandler) in `sites/hostPolicy.ts`, derived from the owner table\n`sites/featureOwners.ts`: only primitives explicitly listed in a plugin-owned\nentry's `handlers` open on that site. Fixed-id builtins can own a feature with\nno public primitive: Quote, Highlight, prevent auto-scroll, completion\nnotifications and NanoBanana’s two controls are such builtins. Their owner flips\nkeep other native ops closed. A multi-site manifest must be legal on each site\nits patterns reach. Import, paste and the CLI enforce this policy; re-reading stored plugins\n(`LocalPluginSource`, the popup list, review-and-enable) passes\n`hostPolicy: 'defer'`, which skips only this site policy, so a build that\ncloses a primitive again keeps the user's plugin and reports it\n`blocked: host-policy`.\n`local/localPluginRules.ts` adds three rules that turn a plugin which would\nimport and then do nothing into an actionable error: `addClass` adds one\n`gv-` prefixed class; every semantic key the plugin relies on (op targets,\n`requires.semantic`, a primitive's keys and the keys its `semanticKey` params\nname) is declared by every site its\n`matches` target, or the popup would only show `needs-semantic`; and settings\nare `boolean`, `number` or `select`, the kinds the popup renders a control for.\nStored records are re-validated on every read by\n`local/LocalPluginSource.ts`, and at runtime `PluginHost` and the brand theme\nrefuse a theme or native op by the page's real host\n(`sites/hostPolicy.ts`), whatever the patterns say. By design, a local\nplugin's own CSS can still restyle anything on Gemini, Voyager's UI and its\n`--gv-*` variables included; only `theme` and the closed native ops are refused.\n\n**Gemini and AI Studio.** The packaged official plugins\n(`catalog/sites/{gemini,aistudio}/` and fixed-id entries in `builtin/`) and local\nplugins target them; neither goes through the remote catalog, which never\nserves these hosts and never publishes their manifests. The official ones replace native modules feature by\nfeature. Stage 7 retires migrated native startup callbacks and registrations; the content script\nadmits each official plugin where its native module used to start, and the\nplugin follows the native setting it replaces (`storage/legacySettingBindings.ts`).\nIts row in a popup topic card writes those legacy-bound keys. The owner table has no `legacy` option: rolling a feature back to its\nnative module means reverting both stage 7 retirement commits (startup wiring 9c2161df and\nnative wrappers 0ae260c1) and the commit that removed that option. Permanent page services remain in `nativeFeatures.ts`; their\naccount bridge, history/message caches, selection host, sent-quote styling,\nprevent-auto-scroll bridge, fork first-send finisher and default-model stale-menu cleanup stay\nindependent of optional plugin switches. The last service only removes old menu UI while the sync\nsetting is false; the plugin alone selects models and creates new stars.\nThe page send-key coordinator is the one service started elsewhere:\n`startPageSendKeys` in `pages/content/inputStartup.ts` creates it before `PluginHost`.\nThey resolve semantic keys through the native\n`geminiAdapter` / `aistudioAdapter`, which expose the keys their packaged\n`site.json` declares (the Gemini `sidebar` key also covers Voyager's folder\npanel, so prefer a precise CSS selector there). The manifest already injects the content\nscript and grants these hosts, so enabling one needs no permission prompt and\n`pluginsToOriginPatterns` leaves their origins out of dynamic registration\n(registering them would inject Voyager twice). `PluginHost` mounts them with\nthe same reversible engine as elsewhere, and turning one off removes its\nclasses and stylesheet. AI Studio's master switch (`GV_AISTUDIO_ENABLED`,\n`runtime/surfaceSwitch.ts`) governs plugins too: while Voyager is off on AI\nStudio ordinary plugins do not mount, and flipping it unmounts or remounts them live. Gemini\nhas no page-wide master switch. Its **Show Timeline** switch controls only the\nrail and navigation shortcut lease; saved stars and hierarchy remain, and the\nmessage timestamp labels plugin follows **Show Message Timestamps** independently;\nhistory and turn identity remain page services.\n\nThe packaged owners in [`sites/featureOwners.ts`](sites/featureOwners.ts) and\nthe builtins in [`builtin/index.ts`](builtin/index.ts) cover:\n\n- Gemini: Chat width, edit input width, font size, line height and paragraph spacing, sidebar width,\n  sidebar auto-hide, sidebar full hide, Gems sidebar count, hide input halo; formula copy, Canvas\n  copy, Deep Research export, user LaTeX, code collapse, Mermaid, WaveDrom, ECharts, Markdown fix,\n  watermark download and preview; Vim input, prompt history, input collapse, Ctrl+Enter send, Safari\n  Enter fix, draft save, quote reply; timeline, message timestamps, highlights, prevent auto-scroll,\n  response notifications, batch delete, research pack, default model, fork, usage status, context\n  sync. The Gems sidebar row is greyed out in the popup: its registry entry `requires` an\n  unmet condition with the `gemsSidebarUnavailableHint` note, because Gemini moved Gems into its\n  More menu. The plugin and its code are kept.\n- AI Studio: Formula copy, Enter send, sidebar width, batch delete.\n- ChatGPT: Formula copy, Vim input, timeline, conversation export, temporary chat handoff, folders,\n  response notifications.\n- Claude: Formula copy, Vim input, timeline.\n- Shared: Visual effects on already-injected pages, including authorized Prompt Manager custom\n  sites.\n\nOther sites' declarative plugins live in `catalog/sites/`. Every packaged Gemini / AI Studio\nofficial plugin has a popup row in the\n[`officialPluginRegistry.ts`](../../pages/popup/officialPlugins/officialPluginRegistry.ts).\nFor the next migration, follow the [plan §7.12 recipe](../../../.github/docs/GEMINI_PLUGIN_PLATFORM_PLAN.md#712-剩余小功能迁移2026-10-16)\nand the [popup design §5](../../../.github/docs/POPUP_PLUGIN_LIST_DESIGN.md#5-以后每项迁移的步骤).\n\n**NanoBanana watermark removal.** Two packaged fixed-ID builtins,\n`voyager.gemini-watermark-download` and `voyager.gemini-watermark-preview`, map\none-to-one to the existing popup switches and are admitted at the former native\nstart point. Both derive enablement through `core/utils/watermarkSettings.ts`:\n`gvWatermarkDownloadEnabled` and `gvWatermarkPreviewEnabled` take precedence if\neither holds a boolean, with an unset sibling retaining the historical `true`\ndefault; otherwise `geminiWatermarkRemoverEnabled` controls both, and no saved\npreference means both are off. The popup writes the same keys; none are copied\ninto `gvPluginsState`. Neither builtin opens a public native primitive or adds\npermissions. The shared image worker and download bridge remain packaged\nservices. Safari keeps its static MAIN-world hooks, asynchronous extension-message\nrouting and authenticated page-fetch fallback; full-size browser downloads keep\nusing that path. There is no dedicated watermark-processing Swift channel.\n\nDisabling preview invalidates pending DOM writes. It restores the original\n`src`/`srcset` pair only while `src` still equals Voyager’s blob URL and `srcset`\nis still absent. If the host changed either attribute, the current pair stays\nintact. A blob URL is revoked only once `src` no longer references it; a host\n`srcset` may still fall back to that blob, which is retained until document unload.\nDisabling download removal stops accepting new removal work immediately;\nalready-accepted downloads finish once with their setting at acceptance, including\nacross an off/on cycle. Static MAIN-world hooks cannot be unregistered: their\nremoval path checks the current setting and stays inert when off. Independent\nread-only watermark health inspection may continue while removal is off.\nThe accepted download is a one-shot promise with one response and the existing\ntimeout fallback, independent of either activation’s lease. It does not gain a\n`startMachine`: no intermediate phase is cancelled by the switch, and aborting\na state scope here would lose the response an accepted request still needs.\n\n**Shared Visual effects.** `voyager.visual-effects` is one packaged builtin on every\nalready-injected page, including authorized Prompt Manager custom sites. It is admitted\nbefore normal initialization and excluded from plugin subframes, as its native predecessor\nwas. It remains independent of AI Studio's master switch. Its `<all_urls>` runtime match\nadds no host permission or content-script registration, and its fixed ID never requests an\norigin grant. `gvVisualEffect` is authoritative across sites: any string takes precedence\n(unknown strings render nothing), otherwise `gvSnowEffect === true` selects snow. The trusted\nenum value binding fills `effect`; nothing is copied into `gvPluginsState`. The popup's\nVisual effects row writes those legacy-bound keys on every site: in the Page layout topic\ncard on Gemini and AI Studio, and in a plugin site's plugin card once Voyager can run there\n(a site enabled for Prompt Manager or a plugin). `legacyEnableWrite` selects snow when enabled and `off` when\ndisabled; both clear the legacy boolean. No previous effect is remembered. PluginScope owns\nthe activation's selection while the page holds renderers through a graceful drain; re-enable\nresumes them, and page teardown destroys canvases, renderer frames and workers immediately.\n\nLocal `turnNavigator` on consumer Gemini requires literal `params: { \"dataBackend\": \"shared\" }`\n(engine >=1.9.0). It reads and edits the same native stars, levels and collapse state, waits for\nboth page-owned timestamp caches, and inherits the official rail’s admission. Its own plugin\nswitch controls the rail independently of **Show Timeline**. Both claim `timeline-rail`: the\nofficial setting wins on a cold page, while a mounted holder keeps the rail until disabled.\nTurn off **Show Timeline** before enabling a local replacement; turn the local plugin off before\nreturning to the official rail. Shared manifests stay within `https://gemini.google.com/*` and\ncannot override turn, route or account identity. Business, Enterprise and custom coverage\ndo not admit this native-data rail. The zero-request promise holds: `isEligibleCatalogHost` refuses every native host,\nso a Gemini page has no catalog host, never asks for a check and never reads a catalog cache, and\nthe background refuses a forced check for them too. A minimal Gemini plugin:\n\n```json\n{\n  \"id\": \"me.gemini-compact-turns\",\n  \"name\": \"Compact Gemini turns\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Tighter spacing between my messages\",\n  \"author\": \"me\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://gemini.google.com/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"css\": \".gv-plugin-compact-turn{margin-block:4px!important}\" }],\n    \"domOps\": [\n      {\n        \"op\": \"addClass\",\n        \"target\": { \"kind\": \"semantic\", \"key\": \"userTurn\" },\n        \"className\": \"gv-plugin-compact-turn\"\n      }\n    ]\n  }\n}\n```\n\nOwnership and precedence:\n\n- **`local.*` is reserved.** Ids are forced into it on import; `plugin:check`\n  rejects an official plugin in it and `mergePluginRecords` drops any builtin,\n  bundled or remote record that claims one. A local plugin can therefore never\n  replace an official one, and its enable state and settings in\n  `storage/pluginState.ts` never share a key with one.\n- **Merged last, outside the kill switch.** The remote catalog says nothing about\n  a user's own plugin, and an enabled local plugin never makes a page eligible\n  for a catalog check (`remote/hostCatalogPolicy.ts`). A kindless or official\n  source cannot serve a `local.*` id either: only `kind: 'local'` does.\n- **Storage.** Manifests live in `chrome.storage.local` under\n  `StorageKeys.PLUGIN_LOCAL_MANIFESTS` (at most 50; per import at most\n  1,000,000 characters read, 32 style entries and 1,000,000 characters of CSS\n  after file expansion, checked before anything is expanded or scanned). An\n  import writes the manifest and its `enabled: false` in one `storage.set`, so\n  no page ever sees the new version enabled. Mutations reject on a failed read\n  (never write back a map rebuilt from nothing), keep entries this build cannot\n  read, and hold the `gv-local-plugins` Web Lock (`storage/pluginStorageLock.ts`),\n  which every plugin-state writer shares, so neither a second popup nor a late\n  setting toggle or Drive restore can write an old `enabled: true` back over a\n  new version. Content scripts cannot share that lock, so they send setting\n  changes to the background (`requestPluginSetting`). They stay on the device:\n  the enable state rides the plugin-state Drive backup, but the manifests\n  themselves do not, and a restore may switch a local plugin off but never on;\n  export them to keep a copy.\n\n## Primitives (`verbs/`) and the `native` op\n\nShipped primitives (`verbs/<name>/contract.ts`): `formulaCopy` (since 1.3.0), `vimInput` (since 1.4.0,\n`composer` param, defaults to the adapter's `composer` selector) and `turnNavigator` (since 1.4.0,\n`turn` / `conversationIdPattern` / `scrollContainer` / `yieldWhen` / `position`, all optional and\ndefaulting to the adapter, plus the optional `conversationIdAttribute` attribute and `turnItem`\nselector since 1.5.0, and `dataBackend: \"shared\"` on consumer Gemini since 1.9.0) and `userLatex` (since 1.7.0, optional `turn` selector defaulting to the\nadapter's `userTurn`, plus an optional `unit` of `leaf` (default) or `turn` since 1.9.0; renders the LaTeX a user typed with Gemini's renderer and restores the text on\nunmount) and the code-block diagram primitives `mermaid`, `echarts` and `wavedrom` (all since\n1.7.0, optional `turn` / `codeBlock` / `code` / `codeLine` / `language` selectors defaulting to\nthe adapter's `assistantTurn` and `codeBlock`). Each is Gemini's renderer core plus the shared\nengine in `verbs/codeBlockDiagram.ts`, which draws a diagram in a shadow-root panel before the\nhost's untouched code block; a label counts as a language only when it is shaped like an id, so a\nhost's localized \"plain text\" label leaves the block to content detection. New diagram kinds follow\nthe PATTERN note at the top of that file, and each site gets one `diagram-rendering` plugin listing\nits diagram ops: ChatGPT's lists `echarts` and `wavedrom`, since ChatGPT draws Mermaid itself, and\n`mermaid` waits for a site that needs it. `codeCollapse` (since 1.8.0, optional\n`code` / `action` selectors, blocks from the adapter's `codeBlock` inside `assistantTurn`\nand outside `thinkingBlock`) is Gemini's long code block collapse on another site: one engine in\n`pages/content/codeBlockCollapse/engine.ts` drives both, Gemini with its blocks expanded until the\nuser collapses them, the primitive with them collapsed plus expand all / collapse all. The formula-copy,\nVim, Claude-timeline and ChatGPT-timeline builtins are now manifests that invoke these primitives;\nthe shared timeline engine lives in `../timeline/TimelineEngine.ts`. Its catalog adapter\n(`../timeline/adapters/catalog/`) accumulates markers across virtualized windows (`turnMerge.ts`)\nand keeps stars and hierarchy in per-site local keys, while stars remain available in Saved Library. Star ids come from the URL at\neach read or write (`conversationId.ts`); a manifest's `conversationIdPattern` wins over the site\nadapter's. A mounted timeline publishes that namespace (`activeStarNamespace.ts`) so ChatGPT\nexport's starred filter reads the same ids. A site with a `conversationIdPattern` cannot star a\nroute that does not match it. Without a site adapter the timeline stays inert: its stars, outline\nand placement are keyed by the site id. A star is written only for a turn proven to be the current conversation's:\nthe host's own id for it where the site names one (`conversationIdAttribute` on an ancestor, as\nClaude's `data-conv-id`, or inside the turn's `turnItem`; a turn without it is unstarrable), else\nthe URL when the turn entered the page (`turnOwnership.ts`).\n\n`tableCopy` (since 1.8.0, no parameters) finds tables inside the adapter's\n`assistantTurn` and puts a shadow-root toolbar before each reply table that copies it as\nMarkdown or TSV. The Markdown is conversation export's own `extractTable`, and TSV reads the same\n`collectTableRows`, so formulas stay LaTeX and copy never drifts from export.\n\n`collapse` (since 1.8.0) limits the visible height of long content and\nplaces one expand/collapse button after each target. Its optional `target` is a semantic key:\n`userTurn` (default), `assistantTurn` or `thinkingBlock`; raw selectors are not accepted.\n`maxPx` defaults to 320 and accepts finite numbers from 80 to 2000; `startCollapsed` defaults\nto `true`. Content at or below `maxPx + 16` pixels gets no toggle. Streaming content gains a\ntoggle when it grows past that threshold, and an expanded element stays expanded while the\nsame host element survives. Labels are localized, buttons work with the keyboard, and styling\nsupports Voyager's light/dark scheme and RTL layout. Unmount restores the host layout.\n\nBecause `target` is configurable, the contract has no fixed semantic requirements. Authors\nmust declare the chosen key in `requires.semantic`, including `userTurn` when omitting\n`target`; a missing adapter selector leaves the primitive inactive. Targeting `assistantTurn`\nclips the complete reply, including any thinking content inside it. Choose `thinkingBlock`\nseparately when only that content should collapse. The primitive has no host-specific checks:\navoid content that already has native collapse, such as DeepSeek's `.ds-collapsible-text`.\nThere is no expand-all control. A plugin can expose `maxPx` and `startCollapsed` as settings\nusing `\"params\": { \"target\": \"userTurn\", \"maxPx\": \"{{maxHeight}}\", \"startCollapsed\": \"{{startCollapsed}}\" }`.\n\n`sendShortcut` claims one composer send mode per page on every site: a second plugin\nreports a conflict while the current holder keeps running. The optional `mode`\n(since 1.9.0) is `ctrlEnterSends` (default) or `enterSends`. Gemini only accepts\nthe default, AI Studio only the latter, via the packaged page coordinator; Gemini\nEnterprise accepts neither. Composition, Process and keyCode 229 are left to the IME.\nLocal send plugins are open on Gemini and AI Studio. Disable the corresponding\nofficial send switch to hand the page's mode to a local plugin; the Safari fix\nremains paired with Gemini's packaged send owner.\n\n`turnNavigator` likewise claims `timeline-rail` on every site, by design: a second rail on\nChatGPT, Claude or DeepSeek is paused as a conflict while the mounted one keeps running.\n\n`draftSave` (since 1.8.0, no parameters; reads `composer` and\n`sendButton`, and stays inert without `conversationIdPattern`) is Gemini's draft auto-save on a\ncatalog site. It shares `pages/content/draftSave/draftStore.ts` (debounce, empty means remove,\nfive newest per prefix) and the composer reader/writer in `core/services/composer/`. Drafts are\nstored in `chrome.storage.local` as `gvDraft:<siteId>:conv:<id>`, or `gvDraft:<siteId>:new` for\nevery page without a conversation id, so they never meet Gemini's `gvDraft_<path>` entries.\nA draft is restored once per conversation entry and only into an empty composer; a restore\nstill loading is abandoned once the person types (`core/utils/userEvent.ts` `isUserEvent`, so a\nhost filling its composer while mounting does not count). A draft is dropped when the composer\nempties within two seconds of Enter, the send button or a form submit, or when that send moves\nthe new-chat page to its conversation; switching from one conversation to another never counts\nas a send. While a `sendShortcut` plugin holds the composer's Enter\n(`verbs/composerEnterOwner.ts`), Enter is never read as a send: only its button click is. ChatGPT temporary chats save\nnothing. These sites have no account index in their routes, so drafts are per site and\nconversation, not per account. Unmount stops everything and keeps stored drafts.\n\nOn Gemini, the same primitive selects the trusted Gemini policy and shares native\n`gvDraft_<full pathname>` records, including `/u/N` and Gem routes. Official and\nlocal draft plugins can run together; writes and five-record pruning are queued\non this page. A confirmed first send clears its source new-chat draft, while\nnavigation without a send keeps it. Restore checks route, account and input edits\nafter asynchronous waits, and never restores into an old-question edit input.\nUnload makes a best-effort final save; asynchronous storage cannot be guaranteed\nafter browser closure. Changing email within one account slot still shares stored\ndrafts. Gemini Enterprise stays inactive. Drafts and their key format survive\nany rollback of the plugin owner.\n\n`quoteReply` (since 1.8.0, no parameters; reads `assistantTurn` and\n`composer`) is Gemini's quote reply on a catalog site. Selecting text wholly inside one\nassistant turn shows a localized Quote button near the selection end; activating it appends the\ntext to the composer as a Markdown quote (every line prefixed `> `, a blank line after an\nexisting draft) and never sends. Selections touching a user turn or the adapter's\n`thinkingBlock` show nothing. Rendered KaTeX is quoted as its LaTeX source through\n`features/formulaCopy/formulaSource.ts`; line prefixing and Gemini math replacement are shared\nwith Gemini's quote reply in `core/utils/quotedText.ts`. A missing composer shows a toast and\nkeeps the selection. Unmount removes the button, its style and every listener synchronously.\n\n`copyButton` (since 1.8.0; required `target` of `userTurn`, `assistantTurn`, `thinkingBlock`\nor `codeBlock`; optional `format`, which accepts only `text` today) places a small copy button above each\nmatched element. It copies the element's visible text without Voyager's own controls and\nconfirms with a toast; the element itself is left untouched.\n\n`counterBadge` (since 1.8.0; `target` is `composer` or `assistantTurn`,\n`metric` is `chars` (default), `words`, `cjkChars` or `lines`) shows a small read-only count.\nFor `composer` it sits above the adapter's `composerRoot` (or the composer's parent), reads the\ndraft with `readComposer` and hides while the draft is empty; for `assistantTurn` each reply\ngets a badge at its end, recounted from `copyableText` once streaming settles. The label is CSS\ngenerated content, so copying, export and the counts themselves never include it. Shipped\nplugins use only the composer target.\n\nSome behaviour cannot be expressed as CSS or as the reversible DOM operations.\nA **primitive** is that behaviour, written once as first-party TypeScript inside\nthe extension and given a name a manifest can call:\n\n```jsonc\n{\n  \"engine\": \">=1.3.0\",\n  \"requires\": { \"handlers\": [\"formulaCopy\"] },\n  \"contributes\": {\n    \"domOps\": [{ \"op\": \"native\", \"handler\": \"formulaCopy\", \"params\": {} }],\n  },\n}\n```\n\nThe manifest picks a primitive and configures it. It never supplies logic: no\nconditions, no ordering, no code. `params` is configuration, checked by that\nprimitive's own hand-written guard before the primitive sees it (plan C1, D17).\nThat is what keeps a remote manifest data rather than remotely-hosted code, and\nit is why `native` is safe on the same channel as CSS.\n\nNative params may use `{{settingKey}}` in string values, recursively inside\nobjects and arrays. Parameter names and object keys stay literal. A whole-value\ntoken preserves the setting's number, boolean or string type; embedded tokens\nproduce a string. For example, `turnNavigator` can receive\n`\"params\": { \"position\": \"{{side}}\" }` from a select setting with `left` and\n`right` options. Expansion runs once, so tokens inside setting values remain\nliteral. Every referenced key must exist in `contributes.settings`, and the\nsetting type must fit the primitive contract wherever statically knowable.\nManifest loading checks substituted defaults. Each activation substitutes\nresolved stored values and runs the primitive's `validateParams`; an invalid\nresult skips the op through the same diagnostic path as invalid static params.\nSettings changes serialize disposal and reactivation for templated primitive\nops; static params keep the existing in-place update path.\n\nParams substitution requires engine 1.8.0, alongside any higher primitive or\nparameter floor. It has shipped, so plugins that rely only on it declare a concrete\n`\">=1.8.0\"`; `\">=next\"` is for a primitive or parameter that is still unreleased.\nThe capability floor remains 1.8.0 in later releases. Engine 1.7.0 passes tokens\nliterally to primitive guards, so a selector token can be accepted yet match\nnothing; enforcing the floor makes an older build report `needs-engine`.\n\nEach primitive is one folder, `verbs/<name>/`, and adding the folder is all\nthe registration there is:\n\n- **`contract.ts`** — data only: `export const contract` with the primitive's\n  `name` (the folder name), `sinceEngine`, the semantic keys it reads from the\n  adapter, and its parameter spec (`type`, `required`, `description`; a\n  param that names a semantic key the primitive reads is `type: 'semanticKey'`,\n  optionally narrowed by `allowedKeys`). It imports only types and data modules\n  (`contractTypes.ts`, `codeBlockDiagramParams.ts`), so Bun scripts and the CLI\n  can load it without DOM or implementations; `primitiveFolders.test.ts`\n  enforces this.\n- **`index.ts`** — the implementation: a primitive object carrying that\n  contract, a `validateParams` guard, and `activate(scope, params, context)`.\n  Every side effect is registered on the `PluginScope`, so unmount pays them\n  all back, and the primitive reports how many elements it acts on through\n  `context.setTargetCounter`.\n- **`paramsBaseline.json`** — the published params (D9, below).\n\n`verbs/contracts.ts` collects the contracts, sorted by name, through\n`import.meta.glob`; Bun has no glob, so Bun scripts and the CLI build feed the\nsame files from disk (`scripts/lib/primitiveContractsSeam.ts`). `verbs/registry.ts`\nfinds the implementations the same way, and `verifyPrimitiveRegistry()` names\nany folder whose contract and implementation disagree. The primitive enums in\n`docs/public/plugin.schema.json` are generated: run `bun run skills:build`\nafter adding a primitive (it also refreshes the public skill).\n\n`PLUGIN_ENGINE_VERSION` records the released engine version and is never\nhand-bumped by a contributor. A new primitive or optional parameter declares\n`sinceEngine: 'next'`; its dependent catalog plugin uses `\"engine\": \">=next\"`.\nThe shared resolver maps `next` to the released engine plus one minor, patch\nzero (1.8.0 → 1.9.0). While any contract or param is `next`, this tree runs that\neffective engine version, so its bundled unreleased plugins are usable.\n\nA manifest that uses a primitive must set an `engine` range whose **minimum** is\nat least its resolved `sinceEngine`, and `bun run catalog:build` fails the build\notherwise (plan §5, §8). Contributor checks, CLI checks and bundled loading\nresolve the same notation. Catalog publishing writes only concrete versions,\nso a released extension filters an update requiring the next engine without\nmistaking it for a remote kill switch. Local imports require concrete versions;\nthe public authoring skill and CLI scaffold document resolved primitive floors.\n\n`bun run bump` stamps all unpublished contracts, param baselines and dependent\ncatalog manifest ranges, and advances the engine only when `next` exists. A\nrelease gate rejects any remaining `next`; extension-version bumps without\nunpublished engine additions leave the engine version unchanged.\n\nThe primitive engine floor makes an older Voyager report `needs-engine`\n(\"update Voyager\") instead of `needs-handler`, which means a configuration\nmistake. `catalog:build` also rejects a handler\nwith no contract, and a semantic key the plugin's own site does not define.\n\nA param added to a shipped primitive carries its own `sinceEngine` (D9: params\nare only ever added). An older engine rejects a param it does not know and skips\nthe whole op, so a manifest that sets the param needs an `engine` floor at least\nthat high; `plugin:check` and `catalog:build` enforce it alongside the primitive\nfloor, and `builtin.test.ts` applies the same check to the builtins.\n\n`requires` states the same needs declaratively:\n\n- `requires.handlers` lists the primitives the plugin invokes. Every `native` op\n  is implied, so listing it is documentation, not duplication.\n- `requires.semantic` lists semantic keys the plugin depends on beyond the ones\n  its ops target. A primitive's own `semantic` keys come from its contract, and\n  the key a `semanticKey` param names (every option, when the param is a select\n  setting's `{{key}}` token) comes from the op's params.\n\n`runtime/pluginStatus.ts` derives both sets (`requiredHandlers`,\n`requiredSemanticKeys`) and turns them into a status, which `PluginHost` reports\nfor every plugin that targets the page instead of quietly filtering it:\n\n| Kind             | Meaning                                 | Popup                                         |\n| ---------------- | --------------------------------------- | --------------------------------------------- |\n| `needs-engine`   | `engine` range above this build         | toggle disabled, \"needs Voyager ≥ x.y\"        |\n| `needs-handler`  | a primitive or runtime it lacks         | toggle disabled, \"update Voyager\"             |\n| `needs-semantic` | the site adapter lacks a key it needs   | toggle disabled, names the site               |\n| `ready`          | compatible, not mounted yet             | normal toggle                                 |\n| `mounted`        | enabled and running on this page        | normal toggle                                 |\n| `no-effect`      | mounted but found nothing (D12)         | toggle stays on, yellow warning               |\n| `blocked`        | compatible, kept off by `blockedReason` | toggle keeps the user's choice, note says why |\n\n`blockedReason` (with `blockedBy` naming the other plugin where there is one):\n\n| Reason            | Meaning                                                                                       | Clears                              |\n| ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------- |\n| `host-policy`     | this build refuses its `theme` or a `native` op on this site                                  | in a build that allows it           |\n| `duplicate-claim` | the manifest starts one page-wide runtime twice                                               | when the manifest is fixed          |\n| `conflict`        | `blockedBy` holds or reserves a page-wide runtime it needs                                    | when the user turns one of them off |\n| `releasing`       | next in line, waiting for `blockedBy` to finish shutting the runtime down                     | by itself                           |\n| `pending`         | `blockedBy",".agents/skills/voyager-contribute/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/voyager-contribute/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/agents/openai.yaml)",".agents/skills/update-safari-extension/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/update-safari-extension/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/agents/openai.yaml)",".agents/skills/release/references/changelog.md":"# References Skill\n\nPath: `.agents/skills/release/references/changelog.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/changelog.md)",".agents/skills/release/references/release-body.md":"# References Skill\n\nPath: `.agents/skills/release/references/release-body.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/release-body.md)",".agents/skills/release/references/safari-dmg.md":"# References Skill\n\nPath: `.agents/skills/release/references/safari-dmg.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/safari-dmg.md)",".agents/skills/release/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/release/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/agents/openai.yaml)",".agents/skills/issue-review/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/issue-review/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/agents/openai.yaml)","src/features/plugins/catalog/marketplace.json":"# Claude Plugin Marketplace Catalog\n\nPath: `src/features/plugins/catalog/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/marketplace.json)","src/features/plugins/catalog/plugins/claude-reading-width/plugin.json":"# claude-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/plugin.json)","src/features/plugins/catalog/plugins/claude-reading-width/README.md":"# claude-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/README.md)","src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json":"# claude-cjk-render-fix Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json)","src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md":"# claude-cjk-render-fix Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md)","src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json":"# chatgpt-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json)","src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md":"# chatgpt-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md)"},"files":{".agents/skills/issue-review/SKILL.md":"---\nname: issue-review\ndescription: Investigate or fix a referenced Voyager GitHub issue, within the requested delivery scope.\n---\n\n# Issue Review\n\n1. Begin with `gh issue view <number-or-url> --comments` (add `--repo owner/repo` when needed). Read the actual issue before locating the cause in the repository.\n2. Match completion to the request: investigation delivers evidence, root cause or remaining uncertainty, and a recommended fix; implementation delivers the scoped change and applicable verification from `AGENTS.md`.\n3. When the request includes committing or publishing, use `voyager-contribute`. A fix commit or PR includes `Closes #<number>` or `Fixes #<number>`; verify any created commit with `git show --stat --format=fuller HEAD`. An investigation or uncommitted fix needs no commit inspection.\n4. When authorized to post, report the actual status in the reporter's language. Say the fix has landed and is coming in the next version only after it has landed. Close the issue only when the fix has landed or another agreed resolution is satisfied and closing is authorized; a local fix or an open PR alone is not completion of the issue.\n",".claude/rules/content-scripts.md":"---\nglobs: [\"src/pages/content/**\", \"src/styles/contentStyle/**\"]\n---\n\n# Content Script Rules\n\n## CSS\n- All injected CSS classes MUST be prefixed with `gv-` (e.g., `.gv-rtl`, `.gv-pm-trigger`)\n- Shared/static injected CSS goes in the matching part under `src/styles/contentStyle/` by default; the build joins the parts in file-name order into the shipped `contentStyle.css`, so the number prefix is cascade order. Add a numbered part rather than grow one past the commit size limit. Feature-specific dynamic CSS may be injected by content modules or the plugin runtime when values are computed at runtime; keep it `gv-` prefixed and tear it down.\n- Support both light and dark themes through `html[data-gv-scheme='dark']` / `html[data-gv-scheme='light']`, which `platformTheme` stamps from each site's `site.json` theme descriptor. Never target a host's own theme markers (`.theme-host.dark-theme`, `body.dark-theme`, `html.dark`, …); `src/core/utils/__tests__/contentStyleTheme.test.ts` rejects them.\n- RTL layout: use `body.gv-rtl` selector for RTL overrides (see `src/core/utils/rtl.ts`)\n\n## Storage\n- Content scripts use `chrome.storage` / `browser.storage` directly — this is the exception to the \"use StorageService\" rule\n- Outside content scripts, prefer `StorageKeys` plus existing storage helpers/services. Direct `chrome.storage` / `browser.storage` still exists in popup/background/options for established settings patterns, bulk reads/writes, and storage listeners.\n\n## DOM Injection\n- Each content script sub-module in `src/pages/content/` is self-contained\n- Bridge between host sites (Gemini, AI Studio, Claude, ChatGPT, DeepSeek) and the extension — any host DOM may change without notice\n- Safari has platform-specific manifest and native-bridge paths, but cloud sync,\n  watermark removal, and image export are supported. Do not add an `isSafari()`\n  skip from historical assumptions; verify the current Safari implementation\n  and its browser-specific tests first.\n- Extension context can be invalidated after update/reload. Use `isExtensionContextInvalidatedError()`.\n\n## Material Symbols Icons\n- Popup Material Symbols are bundled locally through `public/fonts/material-symbols-outlined.css` and `.ttf`; verify the glyph exists in the bundled font or update the bundled font assets. Do not add a remote Google Fonts URL.\n",".agents/skills/release/SKILL.md":"---\nname: release\ndescription: Prepare, publish, or recover a Voyager release. Use for version bumps and releases, failed store submissions, or Safari release failures.\nmetadata:\n  version: '1.5.0'\n---\n\n# Voyager Release\n\nUse repository scripts and `.github/workflows/release.yml` as the source of truth. Normal releases use CI: a release tag builds the browser artifacts, signs and notarizes Safari, creates the GitHub Release, and submits the stores.\n\n## Choose the requested workflow\n\nRead only the reference needed for the current task:\n\n- New release, version bump, or interrupted release preparation: [normal-release.md](references/normal-release.md).\n- Failed Chrome or Edge submission, or an urgent Firefox-only hotfix: [store-recovery.md](references/store-recovery.md).\n- Safari CI failure or an explicitly requested local recovery artifact: [safari-dmg.md](references/safari-dmg.md).\n\n## Authorization and publication boundaries\n\nThe user's request and existing approval define the scope; loading this skill grants no additional permission. Complete authorized preparation and verification, and reuse approval for the same version, destination, and action. A diagnosis or preparation request does not by itself authorize publication.\n\nBefore a tag push, store submission, or replacement upload, verify that the concrete action is authorized. Ask only when that authorization is missing or the version, scope, or a possible release blocker needs a user decision; prepare the reviewable result first. Explain any newly discovered change to the approved release.\n\n- A tag push triggers the public GitHub Release, Safari signing/notarization and Sparkle feed, Firefox AMO submission, Chrome Web Store publication, and Edge Add-ons submission.\n- Moving a published tag requires explicit authorization for that operation and is allowed only while no release artifacts have been produced. Otherwise recover the affected store or prepare a new version; never silently retag.\n- Preserve unrelated changes and stage release paths explicitly. Never bypass hooks with `--no-verify`.\n- Keep Safari compatibility bundle IDs and signing requirements intact. Do not run a second local Safari release or upload a second DMG while CI is producing the release.\n- Failed required checks block tagging and publication. Fix failures caused by the authorized work and rerun affected checks; ask only when resolving a blocker needs new scope or a risk decision. An accepted unresolved failure remains reported as failed and never bypasses artifact signing, notarization, or privacy requirements.\n\n## Completion\n\nFor preparation, report the version, scoped changes, verification results, and remaining publication step. For diagnosis, report the failing stage, evidence, and the concrete recovery path. Do not advance either request into publication automatically.\n\nFor publication or recovery, finish the applicable workflow and verify its actual result: GitHub assets/body, Safari notarization/privacy evidence and signed Sparkle feed, and the requested stores' signing/submission outcomes. Distinguish submission success from store availability, and report pending review or failures. A successful GitHub Release alone does not prove every store or Safari succeeded.\n",".claude/rules/high-complexity.md":"---\nglobs:\n  [\n    'src/core/services/StorageService.ts',\n    'src/core/services/DataBackupService.ts',\n    'src/core/services/GoogleDrive*.ts',\n    'src/core/services/AccountIsolationService.ts',\n    'src/features/folder/**',\n    'src/features/export/**',\n    'src/pages/content/folder/**',\n    'src/pages/content/export/**',\n    'src/features/plugins/storage/**',\n  ]\n---\n\n# High-Complexity Modules — Edit with Caution\n\n| Module                                     | Risk                                                                                                                                                                | Notes                                               |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |\n| `StorageService`                           | Typed sync/local storage wrapper used where suitable. Persistence is also handled by direct `chrome.storage`/`browser.storage` paths and migration/backup services. | Do not modify lightly.                              |\n| `DataBackupService`                        | Multi-layer backup. Race conditions during unload.                                                                                                                  | Critical for data safety.                           |\n| `GoogleDriveSyncService`                   | OAuth2 cloud sync: folders, prompts, settings, plugins, starred, forks, timeline hierarchy and highlights, scoped per account. Split across `GoogleDrive*.ts`.        | Requires OAuth2 identity.                           |\n| `AccountIsolationService`                  | Hard account isolation for multi-account.                                                                                                                           | Integrates with Drive sync.                         |\n| `features/folder` + `pages/content/folder` | Drag-and-drop + cloud sync UI. DOM manipulation + state sync.                                                                                                       | Watch for infinite loops.                           |\n| `features/export` + `pages/content/export` | JSON/MD/PDF/Image export + Deep Research.                                                                                                                           | Fragile to host UI changes. Multi-browser compat.   |\n| `features/plugins/storage`                | Plugin on/off and settings. The legacy bindings decide which store is authoritative for each plugin, and so what backup and restore keep. | A bound id ignores `gvPluginsState`; keep both backups restorable in either order. |\n\n## Changes to behavior or data\n\nApply these checks when changing logic, serialized formats, account scope, or data handling in these modules. Comments, documentation, and formatting-only changes use the prose/formatting checks in `AGENTS.md`.\n\n1. Read the entire file first — not just the section you plan to change\n2. List all existing features that might be affected\n3. Ensure zero destructiveness to user data\n4. Run the full test suite after changes; reuse that result if a later verification step checks the same unchanged inputs\n",".agents/skills/update-safari-extension/SKILL.md":"---\nname: update-safari-extension\ndescription: Build, reload, and verify Voyager in Safari for web-extension changes, native integration, or signed-app testing while preserving installed apps and extension data.\n---\n\n# Update Safari Extension\n\nUse the least invasive route that can test the change. A successful build and a successful live Safari check are separate evidence.\n\n## Choose the runtime route\n\nCheck `git status --short --branch -uall` and inspect Safari Settings > Developer/Extensions. Record whether Voyager is temporary or app-installed and enabled before changing it.\n\n- **Web extension:** TypeScript, React, CSS, manifest, or bundled assets that stay inside the extension use the temporary route below.\n- **Native or signed app:** Swift, entitlements, Sparkle, signing, packaging, or any call to `browser.runtime.sendNativeMessage` require [native-app.md](references/native-app.md). Temporary extensions cannot launch the containing-app handler, including iCloud sync, Safari Google Drive authorization, and native notifications.\n- **Native behavior:** For Drive authorization, notification delivery/clicks, or account-scoped sync verification, also read [native-behavior.md](references/native-behavior.md).\n\n## Default: temporary extension\n\n1. Run `bun run build:safari`. This builds `dist_safari` and verifies Safari resource wiring.\n2. In Safari Settings > Developer > Temporary Extensions, click Voyager's **Reload**. If absent, choose **Add Temporary Extension…** and select `dist_safari`.\n3. Reload the target Gemini, AI Studio, ChatGPT, or Claude tab once to load the current content script.\n4. Open the Voyager popup and test the changed behavior.\n\nSafari removes temporary extensions after 24 hours or when Safari quits. Re-add `dist_safari`; a routine UI refresh does not require signed installation.\n\n## Safety and recovery\n\n- Preserve `/Applications/Voyager.app` and the legacy `/Applications/Gemini Voyager.app`. Replacing an installed app or changing distribution signing/provisioning requires an explicit signed-app test request. Local development profile refresh is covered by the native route.\n- Preserve Safari extension containers, preferences, storage, permissions, bundle identifiers, and versions; changing them is not a stale-build remedy.\n- Use Safari's **Reload** for normal refreshes. If it looks stale, verify the built asset, reload the target tab, and reopen the popup. For launch/URL routing failures or duplicate entries, use the read-only diagnosis in [native-app.md](references/native-app.md).\n- Registration changes (`pluginkit -r`/`-a` or manual unregister/register) are targeted recovery only, with Safari closed, an exact stale/current path pair, and a recovery plan. Reuse explicit user authorization that covers this recovery; otherwise complete read-only diagnosis and prepare the plan before asking. A generic reload request does not authorize it.\n- Never bulk-reset LaunchServices or unregister/remove an app in `/Applications` without explicit permission. Avoid launching apps from Archives, backups, disk images, or old DerivedData; duplicate bundle identifiers can route Safari or custom URLs to the wrong copy.\n- Keep signing identities, provisioning-profile contents, Keychain data, Apple account details, notarization credentials, and CI secret values out of output and commits. Refer only to repository-documented environment-variable names. Keep generated apps, archives, DerivedData, and signed artifacts out of commits unless explicitly tracked.\n- If a feature disappears, stop the update attempt, preserve app and extension data, return to the previously working extension route, and verify the feature.\n\n## Evidence and completion\n\nReport the highest tier actually proven:\n\n1. **Build:** `bun run build:safari` passes.\n2. **Artifact:** expected JS/CSS/resources exist in `dist_safari` and the Xcode bundle wiring check passes.\n3. **Loaded:** Safari shows the intended temporary or containing-app extension enabled once, without an unexpected duplicate.\n4. **Live behavior:** the popup/page visibly shows the changed UI or behavior after reloading the target tab.\n5. **State safety:** pre-existing cards/settings remain visible. A missing card is not evidence of successful loading.\n\nInspect the reloaded popup for popup changes; reload the website for content-script changes; close and reopen the popup for background/native changes. Claim the latest build is loaded only after checking live behavior or a build-specific visible marker in Safari. A marker alone does not prove the changed behavior; report any unverified behavior or state safety.\n\nA standalone popup tab has no source-tab account scope. For account-scoped sync checks or a toolbar popover dismissed before automation reaches its button, follow the testing boundaries in [native-behavior.md](references/native-behavior.md).\n",".agents/skills/voyager-contribute/SKILL.md":"---\nname: voyager-contribute\ndescription: Prepare Voyager commits, pull requests, and contribution evidence, or claim an issue and check review readiness.\n---\n\n# Voyager Contribution\n\nUse only the stages included in the user's request; a readiness review delivers findings, while an authorized commit or publication continues through verification and handoff. Route issue investigation to `issue-review`, Safari loading or native verification to `update-safari-extension`, and releases to `release` when needed.\n\n## 1. Preflight\n\n1. Inspect `git status --short --branch`, the current branch, and the diff. Preserve unrelated work.\n2. Read the linked Issue and its comments when one exists. A new feature needs explicit maintainer approval of the approach, either in the Issue or as a direct instruction in the current task; assignment or `/claim` only selects an owner.\n   The PR gate (`.github/workflows/pr-gate.yml`) closes any PR from an author outside `.github/MAINTAINERS` and `.github/APPROVED_CONTRIBUTORS` unless it has a `fix:` / `fix(scope):` title and stays within 20 files and 1,000 changed lines, or a maintainer added the `maintainer-approved` label. Docs, refactor and feature PRs from such authors need that alignment first. A maintainer's `/approve @user` both assigns the issue and adds the user to `APPROVED_CONTRIBUTORS`.\n3. Work on one focused topic branch targeting `main`. Keep secrets and generated `dist_*` artifacts out of commits.\n4. Read [repo-traps.md](references/repo-traps.md) — the repository-specific pitfalls that have cost past contributors the most review rounds.\n5. Use `gh` as the source of truth for Issue/PR state. Before any GitHub write, confirm `gh auth status` shows the account you intend to contribute as. If GitHub CLI is unavailable, perform Issue/PR reads and writes through the GitHub web UI instead and note that in the PR.\n\nPreflight is complete when the Issue or rationale, approval state, intended scope, current branch, and clean ownership of every changed file are known.\n\n## 2. Verify\n\n1. Add or update regression tests for behavior changes. If no useful automated test exists, record the reason.\n2. Use the verification matrix in [AGENTS.md](../../../AGENTS.md#verification): code PRs require `bun run verify:pr`; prose-only changes use the applicable document checks. During implementation, run focused checks; reuse passing evidence for unchanged inputs. If formatting or lint fixes are needed, apply them to the intended files with `bun run format <paths>` / `bun run lint <paths>` and inspect the diff before checking the final tree. Read-only reviews use check variants.\n\n   `verify:pr` covers local automation and production browser builds; it does not prove that an extension loaded or that live behavior works. Finish with `git diff --check` and `git status --short`.\n\n3. For runtime, UI, manifest, permission, packaging, native, or plugin changes, read [browser-testing.md](references/browser-testing.md) and collect the required live evidence.\n4. Record every omitted command or browser check with its reason. For required coverage that another person must complete, name the browser and owner and leave the item pending.\n\nVerification is complete when every applicable automated check and browser check has a truthful result, the working tree matches the snapshot being tested, and remaining work is explicitly assigned.\n\n## 3. Commit and publish\n\n1. Review the final diff and stage only intended paths.\n2. Create a Conventional Commit with a lowercase scope and a header no longer than 100 characters. Add `Fixes #<issue>` or `Closes #<issue>` when appropriate. Retain the active agent's project-standard co-author footer; Codex-authored commits use:\n\n   ```text\n   Co-authored-by: Codex <codex@users.noreply.github.com>\n   ```\n\n3. Inspect the commit with `git show --stat --format=fuller HEAD` and `git status --short`. Require no staged or unstaged changes in the contribution's paths; preserve and disclose unrelated pre-existing work. `HEAD` is the tested commit only while the verified paths match it, and any later change invalidates affected evidence.\n4. When publishing is authorized, push only the topic branch and open or update a draft PR targeting `main`. Contributors never push directly to `main`; use force-push only with explicit user approval — this repository squash-merges, so a merged branch will later look unmerged (see repo-traps.md) and must be deleted, not re-pushed. Exception: this rule governs contribution workflows; a maintainer with push access working outside a contribution follows the project-wide default push rules in `CLAUDE.md`/`AGENTS.md`, which take precedence.\n5. In the PR, state the linked Issue or direct authorization/rationale, scope, tested commit, commands run, live browser evidence, screenshots for UI changes, and all pending checks. Re-run affected checks after review changes.\n6. Verify the PR author, base branch, commit set, changed files, and CI state with `gh` before handoff.\n\nPublishing is complete when the focused diff is on a topic branch, the draft PR accurately reports its evidence and gaps, and the user receives the PR URL plus pending review or CI work.\n\nAfter an Issue fix lands, leave a short comment in the reporter's language: the fix has landed, it will be available in the next version, and the Issue may be reopened if the problem remains.\n",".claude/rules/typescript.md":"---\nglobs: [\"src/**/*.ts\", \"src/**/*.tsx\"]\n---\n\n# TypeScript Coding Standards\n\n## DOs\n- Prefer plain objects with interfaces/types for data structures\n- Use `map`, `filter`, `reduce` for immutability\n- Use `private`/`protected` in classes\n- Use `unknown` + narrowing (custom type guards) for type safety\n- Use named exports: `export function X`\n- Functional React: hooks at top level, strictly functional components\n\n## DON'Ts\n- **No `any` type.** Use `unknown` if you must, then narrow it.\n- **No global variables** outside defined Services.\n- **Avoid ad hoc storage in UI components** (`src/components/`, `src/pages/popup/`). Prefer `StorageKeys` plus existing storage helpers/services; direct `chrome.storage` / `browser.storage` is acceptable when matching established popup settings patterns, bulk reads/writes, or storage listeners.\n- **No God Components.** Business logic belongs in `features/*/services/` or custom hooks, not UI files.\n- **No magic strings.** Use constants or enums (StorageKeys, CSS classes).\n- **No `console.log` in production.** Use `LoggerService` for critical info; oxlint only warns, so do not add new ones.\n\n## Testing (Vitest + jsdom)\n- Chrome `chrome` object is globally mocked in `src/tests/setup.ts`\n- Mock specific storage: `vi.mocked(chrome.storage.sync.get).mockResolvedValue({ key: 'val' })`\n- jsdom cannot create trusted events: code that tells a person's input from a script's goes through `isUserEvent` (`src/core/utils/userEvent.ts`), and tests mock it\n- Run: `bun run test`, `bun run test <filename>`, `bun run test:coverage`\n",".claude/rules/i18n.md":"---\nglobs: [\"src/locales/**\", \"src/features/plugins/verbs/*/messages/**\"]\n---\n\n# i18n / Translation Rules\n\n## 10 Locales (ALL must be updated together)\n`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`\n\n## When adding a new translation key\n1. Add the key to ALL 10 `src/locales/*/messages.json` files\n2. English (`en`) is the source — write it first, then translate to all others\n3. Each key is a top-level entry in Chrome's `{ \"key\": { \"message\": \"…\", \"description\": \"…\" } }` format; no nested keys\n4. Run `bun run i18n:check`: it fails when any locale is missing a key from `en` or has an extra one\n\n## When removing a translation key\n- Remove from ALL 10 locale files\n\n## Primitive fragments\n- A string used only inside one primitive folder goes in `src/features/plugins/verbs/<name>/messages/<locale>.json` (same format, all 10 locales), not in `src/locales`; it reaches `getTranslationSync` but not `chrome.i18n`. Type its keys by augmenting `PrimitiveMessages` (see `src/utils/translations.ts`).\n- `bun run i18n:check` also requires every fragment locale to match its `en.json` and forbids keys that collide with `src/locales` or another fragment.\n\n## Quality\n- Translations should be natural, not machine-literal\n- Arabic (`ar`) is RTL — ensure UI handles it (see `src/core/utils/rtl.ts`)\n",".agents/skills/voyager-contribute/references/browser-testing.md":"# Browser verification\n\nRead this reference only when a change affects extension runtime, UI, manifests, permissions, packaging, native integration, or plugins.\n\n## Evidence\n\nReport only the highest level actually completed:\n\n- **Build**: the command succeeds and the expected artifact exists.\n- **Loaded**: that artifact is enabled once in the browser with no manifest or load error.\n- **Live**: after reloading the extension and target tab, the changed workflow works and existing state remains intact. The `dist_chrome_dev` build reloads itself after each dev build, so there only the tab needs refreshing; the [verify-in-browser](../../verify-in-browser/SKILL.md) skill covers confirming the loaded build.\n\nA build, unit test, or code inspection is not `Loaded` or `Live` evidence.\nFor `Loaded` or `Live`, record the browser name, exact version, scenario, result, and redacted evidence.\n\n## Route by risk\n\n| Change                                                                                           | Required evidence before review is complete                                                                                                                                                |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Docs, comments, or test-only work                                                                | Relevant automated checks; explain why browsers are not applicable.                                                                                                                        |\n| Pure logic with no browser API or visible behavior change                                        | `bun run verify:pr`; add focused tests.                                                                                                                                                    |\n| Popup, content script, background, shared CSS, storage, or plugin runtime                        | `verify:pr`, then `Loaded` and `Live` on Chrome and Firefox. Add Edge for Chromium-specific, manifest, permission, or packaging behavior. Add Safari whenever Safari behavior is affected. |\n| Browser-specific code, permissions, native messaging, Swift, signing, packaging, or release work | `Loaded` and `Live` on each affected browser, builds for the others, and the feature-specific regression checks. Use the Safari or release skill where applicable.                         |\n| Bundled official or builtin plugin                                                               | `Live` on Chrome, Edge, Firefox, and Safari before merge unless the manifest explicitly supports fewer browsers, plus the plugin checks below.                                             |\n\nMissing hardware or access may be covered by another contributor. Record `Needs <browser> test; owner: @name`; a draft PR may remain open, but it is not review-ready until required evidence is added.\n\n## Artifacts\n\n| Browser | Command                                        | Load target                                                                                                                              |\n| ------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| Chrome  | `bun run dev:chrome` or `bun run build:chrome` | `dist_chrome_dev` for routine development (load once; it reloads itself after each dev build); `dist_chrome` for production verification |\n| Edge    | `bun run build:edge`                           | `dist_edge`                                                                                                                              |\n| Firefox | `bun run build:firefox`                        | `dist_firefox/manifest.json` via `about:debugging`                                                                                       |\n| Safari  | `bun run build:safari`                         | `dist_safari`; then follow the `update-safari-extension` skill                                                                           |\n\n`bun run build:all` and `bun run verify:pr` exclude Edge (the Edge zip step needs the external `zip` executable, which Windows lacks by default — CI's `Build (edge)` covers it). `bun run build:browsers` includes the Edge package build.\n\n## Baseline smoke\n\nFor each required browser:\n\n- Load exactly one enabled Voyager artifact with no manifest or startup error.\n- Open the popup and exercise the changed workflow on every affected site.\n- Check relevant popup, background/service-worker, and page consoles for new errors.\n- Reload the extension and target page; confirm UI, listeners, styles, and notifications are not duplicated.\n- Confirm existing settings and stored data remain intact.\n- For visual changes, check light and dark themes, alignment, spacing, narrow and wide layouts as applicable.\n- Preserve `/u/<index>/...` account scope when the feature is account-sensitive.\n\n## Plugin checks\n\nFor `src/features/plugins/catalog/`, catalog mappings, or builtin/native plugins:\n\n- Confirm the plugin appears once from the intended bundled source.\n- Match the default enabled state of the nearest sibling plugin unless an approved migration says otherwise.\n- Enable, disable, re-enable, and reload; verify state persistence and complete teardown without duplication.\n- Test every declared target site and confirm unrelated sites are unchanged. Test both start and stop paths for builtin JS plugins.\n- Keep custom-site access as an explicit optional grant; direct `<all_urls>` permission requires prior user approval.\n- Attach redacted screenshots or recordings for visible behavior. Never expose account data, conversations, tokens, signing identities, or native handoff URLs.\n",".agents/skills/voyager-contribute/references/repo-traps.md":"# Repository-Specific Traps\n\nEvery item below cost a real contributor at least one review round (source PR in parentheses). CI catches none of them — read this before writing code, not after review.\n\n## Git and PR mechanics\n\n- **This repository squash-merges.** After your PR merges, your local branch will show as unmerged or conflicting — that is normal. Delete it and branch fresh from `main`; never re-open a PR from the old branch (#867 was closed as a self-duplicate created exactly this way).\n- **The PR title becomes the squash commit header.** Title it `type(scope): imperative summary` — never the branch name or \"Fixes #N\" (#859, #865, #867).\n- **Check `git diff origin/main --stat` (or whichever remote tracks the base repository's `main`) before opening the PR** so unrelated churn (assets, lockfiles) never rides along (#865 accidentally wiped `sponsors.svg`).\n- **`@codex review` triggers the automated review** (bare `@codex` does nothing). Trigger it once per new head, re-trigger after any rebase/force-push, and don't re-trigger while a run is pending (#854). The human reviewer finalizes only after Codex has covered the exact current head SHA.\n\n## Reuse the sibling precedent\n\nGrep for the existing primitive before writing parallel logic — reviewers treat a one-off reimplementation of an existing behavior as a defect:\n\n- Send/keyboard handling: reuse the `sendBehavior` detector — it already handles localized labels (发送/送信/enviar) and icon-only buttons, and respects the `gvCtrlEnterSend` setting. An English-only selector or unconditional Enter handler will be rejected (#854).\n- Text insertion: `chatInput` has multiline-aware insertion; a bare `createTextNode` path flattens multiline prompt bodies (#854).\n- Export CSS: extend a shared style builder (see `buildKatexExportStyles`) instead of pasting CSS into a second export service (#847).\n- Export DOM walking: `DOMContentExtractor` must use ONE shared DOM-order traversal helper — Gemini nests arbitrary `section`/`div`/`response-element` layers, and any second code path will miss one (#847 spent 4+ rounds on this).\n- Body-level popovers, global listeners, overlays: copy an existing `gv-pm-*` integration (close-outside handlers, teardown, theme overrides).\n\n## New-site features go through the plugin system\n\n- Adding support for a new site (ChatGPT, Claude, …) goes through the plugin system: declarative CSS+JSON plugins belong in the bundled catalog (`src/features/plugins/catalog/` via `BundledCatalogPluginSource`); only native-function plugins that genuinely need JS go in `src/features/plugins/builtin/` with optional host permission, dynamic content-script registration, and a `start`/`stop` lifecycle with teardown. Plugins start disabled unless an approved default lists them in `DEFAULT_ENABLED_PLUGIN_IDS` (`storage/pluginDefaults.ts`; today only the bundled catalog plugin `voyager.chatgpt-folders`), and even then the content script runs only on origins the user granted.\n- `builtin/` also holds the packaged Gemini / AI Studio official plugins (`gemini*Manifest.ts`, `aistudio*Manifest.ts`). Those are not new-site plugins: their switch is the old sync setting (`storage/legacySettingBindings.ts`), so their default is that binding's reading of a missing key (usually the native feature's old default), and they run only where the content script admits them. Follow `create-voyager-plugin`'s \"Convert a native Gemini or AI Studio feature\" for them. Never add static `content_scripts` or required `host_permissions` to `manifest*.json` — manifest permission escalation without prior Issue approval is a hard stop (#865).\n- Platform registries are additive: never delete or narrow another platform's adapter or tests to make room for yours (#865).\n- Any storage-toggled feature needs a full lifecycle — working start AND destroy at page load and on runtime toggle. Hiding the UI is not disabling the feature (#854).\n\n## Docs and locale mirrors\n\n- \"The docs\" are 20+ surfaces: the main `README.md`, nine `.github/README_*.md` locale mirrors, ten `docs/<locale>/` trees, and `docs/public/llms*.txt`. The green `i18n` CI check only compares `src/locales/*/messages.json` keys — it covers none of these (#874, #853).\n- Never delete a published docs route — there is no redirect layer, so replace retired pages with a localized notice at the original URL (#874).\n\n## Theme and data correctness\n\n- Host theme markers (`.theme-host.dark-theme`, `body.dark`, `html.dark`, …) belong only in each `site.json` `theme` descriptor; `src/pages/content/platformTheme/scheme.ts` reads it and stamps `html[data-gv-scheme]`. Voyager CSS keys off `html[data-gv-scheme='dark' | 'light']` and JS uses `getScheme()` / `subscribeScheme()`; `contentStyleTheme.test.ts` rejects host markers in CSS. Test theme logic against the descriptor with conflicting markers, not single signals (#859).\n- Theme the whole export artifact consistently — a dark diagram on a forced-white document is not dark-mode support (#847).\n- Gemini conversation IDs are namespaced (`gemini:conv:<id>`); shared code that handles raw IDs silently orphans existing starred/bookmark data (#865). Preserve `/u/<index>/...` account scope in every constructed route.\n- Prompt/folder data has multiple merge entry points (`utils/merge.ts` plus page-level Drive merges); route data-shape changes through one shared merge helper, and never drop or rename existing user records on conflict (#854).\n\n## Scope discipline\n\n- The reviewer diffs your behavior against the Issue's confirmed scope and blocks deviations in either direction. When an edge case tempts you to change a product constraint (e.g. enforce name uniqueness), ask in the Issue first (#854); state non-goals explicitly in the PR description.\n- Trivially verifiable one-line fixes may go straight to PR (#876). Features require explicit maintainer approval of the approach, in the Issue or a direct instruction in the current task; assignment alone is not approval (#865).\n","src/features/plugins/README.md":"# Plugin Ecosystem (`src/features/plugins`)\n\nA self-contained subsystem that lets Voyager run **plugins** — units that inject\nstyles / DOM changes into AI chat sites (Gemini, AI Studio, ChatGPT, Claude,\nDeepSeek, …). Voyager's own features can migrate onto this over time; third parties\ncan ship their own plugins against the same contract.\n\n## Two design constraints that shaped everything\n\n1. **Chrome MV3 forbids remotely-hosted code.** You may download _data_ (JSON/CSS)\n   and run it through engine logic that ships **inside** the package; you may not\n   download and execute JS. (`chrome.userScripts` is the only sanctioned escape\n   hatch, and it's gated behind a per-user \"Allow User Scripts\" toggle and is\n   unavailable on Safari.)\n2. **The core is GPL-3.0 with many copyright holders.** It can't be relicensed or\n   closed. Monetizable / proprietary plugins must therefore be **independent works**\n   — and _data read by a GPL engine is not a derivative work of it._\n\nBoth constraints point at the same answer → **declarative-first** plugins.\n\n## Two tiers\n\n| Tier                    | Ships                       | Store-safe                     | Runs on                   | Status                                          |\n| ----------------------- | --------------------------- | ------------------------------ | ------------------------- | ----------------------------------------------- |\n| `declarative` (default) | CSS + JSON (`domOps`)       | ✅ everywhere, remote-loadable | Chrome / Firefox / Safari | **implemented**                                 |\n| `scripted` (advanced)   | JS via `chrome.userScripts` | gated toggle, no Safari        | Chrome / Firefox          | reserved (gated, runtime is a future milestone) |\n\n## Architecture\n\n```\nPluginSource[]  ──►  manifests        SiteRegistry ──► SiteAdapter (current URL)\n   (native builtin,\n    bundled catalog,\n    remote host catalog,\n    local imports)                         ▼\npluginState (storage) ─► enabled?    DeclarativeEngine (interprets contributions)\nEntitlementProvider  ─► entitled?         │  styles + domOps, reversible, idempotent\n        └──────────►  PluginHost.reconcile() ──► engine.mount/unmount\n```\n\n- **`types.ts`** — the whole contract: `PluginManifest`, `PluginContributions`,\n  `DomOperation` (discriminated union — the main extension point), `SiteAdapter`,\n  and the `PluginSource` / `EntitlementProvider` seams.\n- **`sites/`** — `SiteAdapter` per site + a `SiteRegistry` that resolves the\n  current URL. Site-specific selectors live **only** in an adapter, behind the\n  fixed semantic vocabulary in `sites/semanticKeys.ts` (`userTurn`,\n  `assistantTurn`, `thinkingBlock`, `codeBlock`, `composer`, `sidebar`,\n  `sidePanel`, `headerActions`, `scrollContainer`, `composerRoot`,\n  `sendButton`), so a site redesign is a\n  one-file fix. Every adapter is data: `adapters/gemini.ts`, `adapters/aistudio.ts`,\n  `adapters/claude.ts`, `adapters/chatgpt.ts` and `adapters/deepseek.ts` are\n  one-line shells over their `site.json`: edit the JSON, not the TS. Gemini and\n  AI Studio stay native surfaces (`NATIVE_SITE_IDS`, `sites/hostPolicy.ts`); their\n  `site.json` is packaged with the extension and never published remotely.\n  Gemini's `userTurn`/`assistantTurn` are the joined membership lists from\n  `core/gemini/turnSelectors.ts`, bound by `adapters/gemini.parity.test.ts`;\n  timeline, export and highlight keep reading the ordered lists there.\n  `sites/siteAdapterData.ts` holds\n  `validateSiteAdapterData`, the one validator for the bundled `site.json`, the\n  remote override and `catalog:build`.\n- **`runtime/declarativeEngine.ts`** — applies a manifest's `styles` + `domOps`.\n  Reversible (full teardown), idempotent, and uses a `childList`-only\n  MutationObserver so its own mutations can't loop. Pure DOM → all platforms.\n- **`runtime/PluginHost.ts`** — orchestrator. Loads manifests, checks\n  match-URL + enabled + engine-range + entitlement, mounts/unmounts, reacts to\n  state changes. All dependencies injected → fully unit-testable.\n- **`runtime/officialAdmission.ts`** — when each packaged Gemini/AI Studio\n  official plugin may mount: the content script admits it where its native\n  module used to start, and `close` denies whatever is still pending.\n- **`runtime/resourceClaims.ts`, `resourceArbiter.ts`, `reconcileArbitration.ts`,\n  `releaseLedger.ts`** — exclusive page resources (`timeline-rail`, the composer\n  send mode, …): which plugin holds each, why the others are `blocked`, and\n  waiting until a released runtime has really shut down.\n- **`runtime/stateMachine.ts`** — `startMachine`, the runtime for features with\n  interruptible phases: a pure `transition`, one `PluginScope` per status, late\n  callbacks from a left status ignored. Used by export selection, default\n  model, draft restore, reply notifications and both batch deletes; see\n  [the state machine plan](../../../.github/docs/STATE_MACHINE_PLAN.md).\n- **`manifest/validate.ts`** — turns `unknown` into a typed manifest or a list of\n  issues. Remote catalog plugins are untrusted; bundled catalog plugins use\n  the same validator so official data cannot drift from the runtime contract.\n- **`storage/pluginState.ts`** — per-plugin enable state in `chrome.storage.local`.\n- **`sources/` `entitlement/`** — the swap points for native first-party\n  features, bundled official declarative plugins, the per-host remote catalog,\n  the user's locally imported plugins, and a future paid (Stripe/account) store. `sources/defaultSources.ts` also\n  owns `mergePluginRecords`, the rules that pick which copy of a plugin id wins.\n- **`local/`** — user-imported declarative plugins: the `local.*` id namespace,\n  the import gate, storage, the read-only `LocalPluginSource` and the inspect\n  summary the popup shows. See \"Write your own plugin locally\" below.\n- **`remote/`** — the per-host remote catalog channel: a read-only source over a\n  storage cache, plus a background-only fetcher. See below.\n- **`catalog/`** — the bundled official data: one directory per site, each\n  holding that site's `site.json` and its declarative plugins. This\n  keeps official CSS/JSON changes in the same PR, CI run, and release as engine\n  or popup changes.\n\n```\ncatalog/\n  marketplace.json                        list of public catalog plugins (tests, plugin:new)\n  sites/index.ts                          import.meta.glob discovery\n  sites/<site>/site.json                  the site adapter, as data\n  sites/<site>/plugins/<id>/plugin.json   a declarative plugin\n  sites/<site>/plugins/<id>/style.css     its styles\n  sites/<site>/plugins/<id>/README.md     what it fixes and why\n```\n\n`catalog/sites/index.ts` finds every `site.json` and `plugin.json` with\n`import.meta.glob`, so adding a site or a plugin is adding files: there is no\nmapping table. (`scripts/build-plugin-catalog.ts` reads the same tree from disk,\nbecause `import.meta.glob` does not exist under Bun.) Sites today are\n`aistudio`, `chatgpt`, `claude`, `deepseek` and `gemini`. A plugin's `matches`\nmust stay inside its site's `matches` (plan D18) or the build fails, and\n`bun run catalog:build` validates every site and plugin before publishing.\nGemini and AI Studio are validated like the rest but never published: the host\npolicy (`allowsRemoteCatalog`) keeps their four hosts out of the per-host\ncatalog, and their plugins stay out of `marketplace.json`.\n\n`catalog/marketplace.json` is **not** that mapping table, and no page reads it:\nthe site's plugin table is built from the manifests themselves\n(`docs/.vitepress/pluginsPage.ts`, which lists the Gemini and AI Studio\npackaged plugins too). It stays as the list of public catalog plugins that\n`catalog/sites/index.test.ts` keeps in sync with discovery and `plugin:new`\nappends to, so a new plugin still needs an entry whose `source` is the\ncatalog-relative path (`sites/deepseek/plugins/reading-width/plugin.json`).\nPlugins under `sites/gemini/` and `sites/aistudio/` are not listed.\n\n## Authoring a declarative plugin\n\n```jsonc\n{\n  \"id\": \"vendor.my-plugin\",\n  \"name\": \"My Plugin\",\n  \"version\": \"1.0.0\",\n  \"description\": \"...\",\n  \"author\": \"...\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://claude.ai/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"file\": \"style.css\" }],\n    \"domOps\": [{ \"op\": \"addClass\", \"target\": \"body\", \"className\": \"gv-plugin-readable\" }],\n  },\n}\n```\n\nAn official plugin is authored as files under its platform's directory:\n\n1. Pick the site it belongs to. If that site has no `catalog/sites/<site>/`\n   directory yet, write its `site.json` first (`id` equal to the directory name,\n   `label`, `matches`, `selectors` keyed by the semantic vocabulary, `theme`,\n   `brandColor`, `capabilities`, optional `conversationIdPattern` and\n   `turnKeyAttributes`). Nothing registers it: the registry picks it up from the\n   file. `turnKeyAttributes` names the attributes that keep a user message's\n   identity across remounts; send tracking (`sends/trackUserSends.ts`, behind\n   folder Activity and catalog timeline timestamps) runs only on a site that\n   names them, its `composer` and `userTurn`, and a `conversationIdPattern`.\n2. Create `catalog/sites/<site>/plugins/<id>/` with `plugin.json`, `style.css`\n   and a short `README.md`.\n3. Keep the plugin's `matches` inside the site's `matches` (D18).\n4. Add the entry to `catalog/marketplace.json` (`plugin:new` does it for you).\n5. Run the suite: discovery, D18 and the marketplace index are all asserted in\n   `catalog/sites/index.test.ts`.\n\nManifests may keep tiny CSS inline with `{ \"css\": \"...\" }`, but the preferred\nauthoring shape is `{ \"file\": \"style.css\" }` next to `plugin.json`. The bundled\nsource and the published catalog file both resolve that CSS to inline text,\nreject anything that loads, from the page's own origin too (`@import`,\n`image-set()`, `image()`, `cross-fade()`, `src()`, a `url()` other than a\nraster-image `data:` URL or `#fragment`, or any string that starts with an\nexternal URL), and normalize\nit before the runtime sees it. A `data:` URL in `url()` must declare a raster\ntype (`image/png`, `jpeg`, `gif`, `webp`, `avif`, `bmp` or an icon type);\n`data:image/svg+xml` is refused in every encoding, because an SVG used as a\nfilter, mask or clip-path resource can load images of its own. For\nuser settings, `{{settingKey}}` tokens can be used in CSS text or in\n`setAttribute` / `setStyle` DOM op values; a common pattern is for CSS files to\nuse a normal custom property and for a `setStyle` op to set that variable from a\nsetting.\n\nSelect settings render their declared value/label options in the popup. Localize\neach option under `i18n.<locale>.settings.<key>.options.<value>` (engine 1.6.0+).\nA select needs at least one option, unique string values (`\"\"` included) and a\ndefault among them; a stored choice the manifest no longer offers resolves to\nthat default in both the popup and the host (`runtime/resolvePluginSettings.ts`).\n\nSetting fields may declare `enabledBy: \"<booleanKey>\"` to depend on another\nboolean setting in the same plugin. The popup keeps number, select and boolean\ncontrols visible with their values, but disables them unless the controlling\nsetting's resolved value is `true`; stored values remain intact. The key must\nbe non-empty and name another boolean field without its own `enabledBy`\n(one level only). This affects popup controls; page behavior keeps its own\nboolean gate.\nEngine 1.7.0 ignores `enabledBy` and leaves the control enabled, so this field\nrequires no higher engine floor.\n\n`target` is a CSS selector string, or `{ \"kind\": \"semantic\", \"key\": \"userTurn\" }`\nto use the site adapter's stable selector for one of the eleven semantic keys in\n`sites/semanticKeys.ts`; a `site.json` may not invent a key outside that\nvocabulary. Supported ops: `addClass`,\n`setAttribute`, `setStyle`, `hide`, plus `native` (below). All are reversible.\nInline styles are layered per longhand property: a `setStyle` value or a whole\n`style` attribute is expanded into longhands (a trailing `!important` is kept as\npriority), so turning one plugin off restores only the longhands it wrote and never\nanother live plugin's value. A `var()` shorthand written with `setStyle` stays whole.\nClasses must be `gv-` prefixed (content-script rule). `setAttribute` names are an\nexact-match allowlist: `data-*`, `aria-*`, `title`, `role`, `lang`, `dir`,\n`hidden`, `tabindex`, `draggable`, `spellcheck`, `translate` and `style`; URL,\n`on*`, `class` and `id` attributes are refused because a selector can reach any\npage element. `setStyle` values and `style` attributes follow the CSS rule\nabove, and attribute values may not contain an external URL. All of this is\nchecked again after `{{setting}}` substitution (`manifest/sinkGuards.ts`).\n\n## Write your own plugin locally (`local/`)\n\nA user can run a declarative plugin they wrote without publishing it:\n\n1. Write `plugin.json` exactly as above, plus its `style.css` (or inline the CSS\n   as `{ \"css\": \"...\" }`). Any lowercase reverse-dotted `id` works; it is stored\n   as `local.<id>`.\n2. Open the popup and find **Local plugins**: on Claude, ChatGPT or DeepSeek it\n   sits on the plugin page; on Gemini and AI Studio it is a settings card\n   (collapsed until opened, or until a local plugin targets the page, which\n   then also shows its toggle and settings). By default it follows cloud sync\n   and the eight plugin topic cards and comes before folders through general\n   (`NATIVE_POPUP_SECTION_IDS` in `pages/popup/utils/nativeSiteCapabilities.ts`);\n   users can reorder it. Choose **Import files** (pick\n   the manifest together with its `.css` files) or **Paste JSON** (a manifest\n   with its CSS inlined).\n3. A rejected import lists every problem as `path: message` and leaves the\n   installed version untouched. An accepted one is stored **disabled** and its\n   inspect view shows the sites it matches, the CSS size, every page change,\n   each Voyager primitive it calls with its params, and its settings.\n4. Turn it on from the plugin list like any other plugin; it applies live. To\n   update it, edit the files and import again: a re-import also lands disabled,\n   so the user re-inspects the new version (it may add a `native` op) and turns\n   it back on. A plugin with a `native` op that was running when it was\n   re-imported keeps its mounted version until the page reloads, as official\n   ones do (D7). **Export** downloads the stored manifest with CSS inlined;\n   **Remove** deletes it with its enable state and settings.\n\n**Describe a change in one sentence.** The same panel can write the manifest\nwith any AI the user already uses, while Voyager itself stays offline:\n\n- `local/pluginAuthoringPrompt.ts` turns the sentence and a chosen site (the\n  active tab's when supported) into a self-contained prompt: the request, the\n  plugin contract for that site (see \"One contract, four outputs\" below), and\n  \"reply with exactly one ```json block\".\n- The user copies the prompt and sends it themselves. Voyager never sends it,\n  opens or deep-links an AI page, reads one back, or makes a request.\n- `local/pluginReply.ts` takes the pasted reply (bounded, linear fence scan;\n  one ```json or unlabeled block, or bare JSON; zero or several blocks are\n  refused by name) through `readLocalPluginFiles` and the gate without\n  installing it. The popup closes while the user is in their AI tab, so this\n  step works on its own in a fresh popup.\n- `local/pluginPreview.ts` describes the result in plain terms (sites, each\n  change, warnings for hidden content via `hide`, `hidden` or a hiding inline\n  style, raw selectors, a replaced version, a\n  reply that misses the selected site (the active tab's, or the user's pick;\n  recomputed when the picker changes, no prompt needed), and a `theme` the\n  prompt asked the AI to leave out; a primitive is listed as a change, not\n  flagged, because the prompt offers it where the gate accepts it). CSS is not summarized: its full\n  source is shown with a plain warning that it can change anything, hiding\n  included, since reading its effect would be a guess. It builds on `inspectPlugin`, and\n  the same inspect view opens under it on demand. **Import** hands the exact previewed\n  object to `importLocalPlugin`, so it lands disabled and follows the re-import\n  rules above, but only over the install the preview saw: if the same id was\n  installed, changed or removed meanwhile, nothing is written and the preview\n  is refreshed for a new review.\n\n**One contract, four outputs.** `local/authoring/pluginContract.ts`\n(`buildPluginContract(site, { audience })`) is the single description of what a\nlocal plugin may contain: manifest fields, styles, page changes, settings, the\nlocal rules, the site's anchors and notes, the primitives (read only through\n`primitiveReference.ts`), what is not possible, and examples. Four outputs\nrender it:\n\n- the popup prompt above (`audience: 'chat'`: inline CSS, no `theme`, primitives\n  where the site allows them, plus a pointer to the public skill for an AI that\n  can browse; the pasted contract wins where the two differ);\n- the public agent skill `docs/public/skills/write-voyager-plugin/SKILL.md`\n  (`authoringDocs.ts`), committed and served at\n  `https://voyager.nagi.fun/skills/write-voyager-plugin/SKILL.md`. Regenerate it\n  with `bun run skills:build`; `authoringDocs.test.ts` fails while it is stale,\n  so a new primitive, a changed `site.json` or a changed local rule shows up\n  there;\n- the `AGENTS.md` that `create-voyager-plugin init` writes next to a new plugin;\n- the examples, which `pluginContract.test.ts` runs through\n  `validateLocalManifest` (file examples through `readLocalPluginFiles`) for\n  every site and both audiences, so no output can teach what the gate refuses.\n\n`packages/create-voyager-plugin/` bundles the same gate and the bundled site\nadapters into one dependency-free Node file (`bun run cli:build`): `init` writes\na passing `plugin.json`, `style.css` and `AGENTS.md`, and `check` runs\n`readLocalPluginFiles` plus `validateLocalManifest` offline, exit 0 or 1. Its\ntests hold it to parity with the in-process gate over a corpus. The package\nversion follows the extension (`scripts/bump-version.js`).\n\nThe gate (`local/validateLocalManifest.ts`) is the remote catalog's gate plus\nlocal-only rules, never a weaker one: `validateManifest` with the CSS and\nrendered-sink guards, `tier: \"declarative\"` only, `native` ops only for primitives\nthis build ships with params that match their contract and an `engine` floor\nat or above their `sinceEngine` (`manifest/primitiveChecks.ts`, shared with\n`plugin:check`), and `matches` inside a plugin platform's `matches` or a native\nsurface's (`sites/nativeSurfaces.ts`: Gemini, AI Studio), so enabling a local\nplugin can only request host access an official plugin could, or none at all.\nA plugin for Gemini or AI Studio may not declare `theme` (those pages keep\nVoyager's own accent). Its `native` ops follow a capability policy per (site,\nhandler) in `sites/hostPolicy.ts`, derived from the owner table\n`sites/featureOwners.ts`: only primitives explicitly listed in a plugin-owned\nentry's `handlers` open on that site. Fixed-id builtins can own a feature with\nno public primitive: Quote, Highlight, prevent auto-scroll, completion\nnotifications and NanoBanana’s two controls are such builtins. Their owner flips\nkeep other native ops closed. A multi-site manifest must be legal on each site\nits patterns reach. Import, paste and the CLI enforce this policy; re-reading stored plugins\n(`LocalPluginSource`, the popup list, review-and-enable) passes\n`hostPolicy: 'defer'`, which skips only this site policy, so a build that\ncloses a primitive again keeps the user's plugin and reports it\n`blocked: host-policy`.\n`local/localPluginRules.ts` adds three rules that turn a plugin which would\nimport and then do nothing into an actionable error: `addClass` adds one\n`gv-` prefixed class; every semantic key the plugin relies on (op targets,\n`requires.semantic`, a primitive's keys and the keys its `semanticKey` params\nname) is declared by every site its\n`matches` target, or the popup would only show `needs-semantic`; and settings\nare `boolean`, `number` or `select`, the kinds the popup renders a control for.\nStored records are re-validated on every read by\n`local/LocalPluginSource.ts`, and at runtime `PluginHost` and the brand theme\nrefuse a theme or native op by the page's real host\n(`sites/hostPolicy.ts`), whatever the patterns say. By design, a local\nplugin's own CSS can still restyle anything on Gemini, Voyager's UI and its\n`--gv-*` variables included; only `theme` and the closed native ops are refused.\n\n**Gemini and AI Studio.** The packaged official plugins\n(`catalog/sites/{gemini,aistudio}/` and fixed-id entries in `builtin/`) and local\nplugins target them; neither goes through the remote catalog, which never\nserves these hosts and never publishes their manifests. The official ones replace native modules feature by\nfeature. Stage 7 retires migrated native startup callbacks and registrations; the content script\nadmits each official plugin where its native module used to start, and the\nplugin follows the native setting it replaces (`storage/legacySettingBindings.ts`).\nIts row in a popup topic card writes those legacy-bound keys. The owner table has no `legacy` option: rolling a feature back to its\nnative module means reverting both stage 7 retirement commits (startup wiring 9c2161df and\nnative wrappers 0ae260c1) and the commit that removed that option. Permanent page services remain in `nativeFeatures.ts`; their\naccount bridge, history/message caches, selection host, sent-quote styling,\nprevent-auto-scroll bridge, fork first-send finisher and default-model stale-menu cleanup stay\nindependent of optional plugin switches. The last service only removes old menu UI while the sync\nsetting is false; the plugin alone selects models and creates new stars.\nThe page send-key coordinator is the one service started elsewhere:\n`startPageSendKeys` in `pages/content/inputStartup.ts` creates it before `PluginHost`.\nThey resolve semantic keys through the native\n`geminiAdapter` / `aistudioAdapter`, which expose the keys their packaged\n`site.json` declares (the Gemini `sidebar` key also covers Voyager's folder\npanel, so prefer a precise CSS selector there). The manifest already injects the content\nscript and grants these hosts, so enabling one needs no permission prompt and\n`pluginsToOriginPatterns` leaves their origins out of dynamic registration\n(registering them would inject Voyager twice). `PluginHost` mounts them with\nthe same reversible engine as elsewhere, and turning one off removes its\nclasses and stylesheet. AI Studio's master switch (`GV_AISTUDIO_ENABLED`,\n`runtime/surfaceSwitch.ts`) governs plugins too: while Voyager is off on AI\nStudio ordinary plugins do not mount, and flipping it unmounts or remounts them live. Gemini\nhas no page-wide master switch. Its **Show Timeline** switch controls only the\nrail and navigation shortcut lease; saved stars and hierarchy remain, and the\nmessage timestamp labels plugin follows **Show Message Timestamps** independently;\nhistory and turn identity remain page services.\n\nThe packaged owners in [`sites/featureOwners.ts`](sites/featureOwners.ts) and\nthe builtins in [`builtin/index.ts`](builtin/index.ts) cover:\n\n- Gemini: Chat width, edit input width, font size, line height and paragraph spacing, sidebar width,\n  sidebar auto-hide, sidebar full hide, Gems sidebar count, hide input halo; formula copy, Canvas\n  copy, Deep Research export, user LaTeX, code collapse, Mermaid, WaveDrom, ECharts, Markdown fix,\n  watermark download and preview; Vim input, prompt history, input collapse, Ctrl+Enter send, Safari\n  Enter fix, draft save, quote reply; timeline, message timestamps, highlights, prevent auto-scroll,\n  response notifications, batch delete, research pack, default model, fork, usage status, context\n  sync. The Gems sidebar row is greyed out in the popup: its registry entry `requires` an\n  unmet condition with the `gemsSidebarUnavailableHint` note, because Gemini moved Gems into its\n  More menu. The plugin and its code are kept.\n- AI Studio: Formula copy, Enter send, sidebar width, batch delete.\n- ChatGPT: Formula copy, Vim input, timeline, conversation export, temporary chat handoff, folders,\n  response notifications.\n- Claude: Formula copy, Vim input, timeline.\n- Shared: Visual effects on already-injected pages, including authorized Prompt Manager custom\n  sites.\n\nOther sites' declarative plugins live in `catalog/sites/`. Every packaged Gemini / AI Studio\nofficial plugin has a popup row in the\n[`officialPluginRegistry.ts`](../../pages/popup/officialPlugins/officialPluginRegistry.ts).\nFor the next migration, follow the [plan §7.12 recipe](../../../.github/docs/GEMINI_PLUGIN_PLATFORM_PLAN.md#712-剩余小功能迁移2026-10-16)\nand the [popup design §5](../../../.github/docs/POPUP_PLUGIN_LIST_DESIGN.md#5-以后每项迁移的步骤).\n\n**NanoBanana watermark removal.** Two packaged fixed-ID builtins,\n`voyager.gemini-watermark-download` and `voyager.gemini-watermark-preview`, map\none-to-one to the existing popup switches and are admitted at the former native\nstart point. Both derive enablement through `core/utils/watermarkSettings.ts`:\n`gvWatermarkDownloadEnabled` and `gvWatermarkPreviewEnabled` take precedence if\neither holds a boolean, with an unset sibling retaining the historical `true`\ndefault; otherwise `geminiWatermarkRemoverEnabled` controls both, and no saved\npreference means both are off. The popup writes the same keys; none are copied\ninto `gvPluginsState`. Neither builtin opens a public native primitive or adds\npermissions. The shared image worker and download bridge remain packaged\nservices. Safari keeps its static MAIN-world hooks, asynchronous extension-message\nrouting and authenticated page-fetch fallback; full-size browser downloads keep\nusing that path. There is no dedicated watermark-processing Swift channel.\n\nDisabling preview invalidates pending DOM writes. It restores the original\n`src`/`srcset` pair only while `src` still equals Voyager’s blob URL and `srcset`\nis still absent. If the host changed either attribute, the current pair stays\nintact. A blob URL is revoked only once `src` no longer references it; a host\n`srcset` may still fall back to that blob, which is retained until document unload.\nDisabling download removal stops accepting new removal work immediately;\nalready-accepted downloads finish once with their setting at acceptance, including\nacross an off/on cycle. Static MAIN-world hooks cannot be unregistered: their\nremoval path checks the current setting and stays inert when off. Independent\nread-only watermark health inspection may continue while removal is off.\nThe accepted download is a one-shot promise with one response and the existing\ntimeout fallback, independent of either activation’s lease. It does not gain a\n`startMachine`: no intermediate phase is cancelled by the switch, and aborting\na state scope here would lose the response an accepted request still needs.\n\n**Shared Visual effects.** `voyager.visual-effects` is one packaged builtin on every\nalready-injected page, including authorized Prompt Manager custom sites. It is admitted\nbefore normal initialization and excluded from plugin subframes, as its native predecessor\nwas. It remains independent of AI Studio's master switch. Its `<all_urls>` runtime match\nadds no host permission or content-script registration, and its fixed ID never requests an\norigin grant. `gvVisualEffect` is authoritative across sites: any string takes precedence\n(unknown strings render nothing), otherwise `gvSnowEffect === true` selects snow. The trusted\nenum value binding fills `effect`; nothing is copied into `gvPluginsState`. The popup's\nVisual effects row writes those legacy-bound keys on every site: in the Page layout topic\ncard on Gemini and AI Studio, and in a plugin site's plugin card once Voyager can run there\n(a site enabled for Prompt Manager or a plugin). `legacyEnableWrite` selects snow when enabled and `off` when\ndisabled; both clear the legacy boolean. No previous effect is remembered. PluginScope owns\nthe activation's selection while the page holds renderers through a graceful drain; re-enable\nresumes them, and page teardown destroys canvases, renderer frames and workers immediately.\n\nLocal `turnNavigator` on consumer Gemini requires literal `params: { \"dataBackend\": \"shared\" }`\n(engine >=1.9.0). It reads and edits the same native stars, levels and collapse state, waits for\nboth page-owned timestamp caches, and inherits the official rail’s admission. Its own plugin\nswitch controls the rail independently of **Show Timeline**. Both claim `timeline-rail`: the\nofficial setting wins on a cold page, while a mounted holder keeps the rail until disabled.\nTurn off **Show Timeline** before enabling a local replacement; turn the local plugin off before\nreturning to the official rail. Shared manifests stay within `https://gemini.google.com/*` and\ncannot override turn, route or account identity. Business, Enterprise and custom coverage\ndo not admit this native-data rail. The zero-request promise holds: `isEligibleCatalogHost` refuses every native host,\nso a Gemini page has no catalog host, never asks for a check and never reads a catalog cache, and\nthe background refuses a forced check for them too. A minimal Gemini plugin:\n\n```json\n{\n  \"id\": \"me.gemini-compact-turns\",\n  \"name\": \"Compact Gemini turns\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Tighter spacing between my messages\",\n  \"author\": \"me\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://gemini.google.com/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"css\": \".gv-plugin-compact-turn{margin-block:4px!important}\" }],\n    \"domOps\": [\n      {\n        \"op\": \"addClass\",\n        \"target\": { \"kind\": \"semantic\", \"key\": \"userTurn\" },\n        \"className\": \"gv-plugin-compact-turn\"\n      }\n    ]\n  }\n}\n```\n\nOwnership and precedence:\n\n- **`local.*` is reserved.** Ids are forced into it on import; `plugin:check`\n  rejects an official plugin in it and `mergePluginRecords` drops any builtin,\n  bundled or remote record that claims one. A local plugin can therefore never\n  replace an official one, and its enable state and settings in\n  `storage/pluginState.ts` never share a key with one.\n- **Merged last, outside the kill switch.** The remote catalog says nothing about\n  a user's own plugin, and an enabled local plugin never makes a page eligible\n  for a catalog check (`remote/hostCatalogPolicy.ts`). A kindless or official\n  source cannot serve a `local.*` id either: only `kind: 'local'` does.\n- **Storage.** Manifests live in `chrome.storage.local` under\n  `StorageKeys.PLUGIN_LOCAL_MANIFESTS` (at most 50; per import at most\n  1,000,000 characters read, 32 style entries and 1,000,000 characters of CSS\n  after file expansion, checked before anything is expanded or scanned). An\n  import writes the manifest and its `enabled: false` in one `storage.set`, so\n  no page ever sees the new version enabled. Mutations reject on a failed read\n  (never write back a map rebuilt from nothing), keep entries this build cannot\n  read, and hold the `gv-local-plugins` Web Lock (`storage/pluginStorageLock.ts`),\n  which every plugin-state writer shares, so neither a second popup nor a late\n  setting toggle or Drive restore can write an old `enabled: true` back over a\n  new version. Content scripts cannot share that lock, so they send setting\n  changes to the background (`requestPluginSetting`). They stay on the device:\n  the enable state rides the plugin-state Drive backup, but the manifests\n  themselves do not, and a restore may switch a local plugin off but never on;\n  export them to keep a copy.\n\n## Primitives (`verbs/`) and the `native` op\n\nShipped primitives (`verbs/<name>/contract.ts`): `formulaCopy` (since 1.3.0), `vimInput` (since 1.4.0,\n`composer` param, defaults to the adapter's `composer` selector) and `turnNavigator` (since 1.4.0,\n`turn` / `conversationIdPattern` / `scrollContainer` / `yieldWhen` / `position`, all optional and\ndefaulting to the adapter, plus the optional `conversationIdAttribute` attribute and `turnItem`\nselector since 1.5.0, and `dataBackend: \"shared\"` on consumer Gemini since 1.9.0) and `userLatex` (since 1.7.0, optional `turn` selector defaulting to the\nadapter's `userTurn`, plus an optional `unit` of `leaf` (default) or `turn` since 1.9.0; renders the LaTeX a user typed with Gemini's renderer and restores the text on\nunmount) and the code-block diagram primitives `mermaid`, `echarts` and `wavedrom` (all since\n1.7.0, optional `turn` / `codeBlock` / `code` / `codeLine` / `language` selectors defaulting to\nthe adapter's `assistantTurn` and `codeBlock`). Each is Gemini's renderer core plus the shared\nengine in `verbs/codeBlockDiagram.ts`, which draws a diagram in a shadow-root panel before the\nhost's untouched code block; a label counts as a language only when it is shaped like an id, so a\nhost's localized \"plain text\" label leaves the block to content detection. New diagram kinds follow\nthe PATTERN note at the top of that file, and each site gets one `diagram-rendering` plugin listing\nits diagram ops: ChatGPT's lists `echarts` and `wavedrom`, since ChatGPT draws Mermaid itself, and\n`mermaid` waits for a site that needs it. `codeCollapse` (since 1.8.0, optional\n`code` / `action` selectors, blocks from the adapter's `codeBlock` inside `assistantTurn`\nand outside `thinkingBlock`) is Gemini's long code block collapse on another site: one engine in\n`pages/content/codeBlockCollapse/engine.ts` drives both, Gemini with its blocks expanded until the\nuser collapses them, the primitive with them collapsed plus expand all / collapse all. The formula-copy,\nVim, Claude-timeline and ChatGPT-timeline builtins are now manifests that invoke these primitives;\nthe shared timeline engine lives in `../timeline/TimelineEngine.ts`. Its catalog adapter\n(`../timeline/adapters/catalog/`) accumulates markers across virtualized windows (`turnMerge.ts`)\nand keeps stars and hierarchy in per-site local keys, while stars remain available in Saved Library. Star ids come from the URL at\neach read or write (`conversationId.ts`); a manifest's `conversationIdPattern` wins over the site\nadapter's. A mounted timeline publishes that namespace (`activeStarNamespace.ts`) so ChatGPT\nexport's starred filter reads the same ids. A site with a `conversationIdPattern` cannot star a\nroute that does not match it. Without a site adapter the timeline stays inert: its stars, outline\nand placement are keyed by the site id. A star is written only for a turn proven to be the current conversation's:\nthe host's own id for it where the site names one (`conversationIdAttribute` on an ancestor, as\nClaude's `data-conv-id`, or inside the turn's `turnItem`; a turn without it is unstarrable), else\nthe URL when the turn entered the page (`turnOwnership.ts`).\n\n`tableCopy` (since 1.8.0, no parameters) finds tables inside the adapter's\n`assistantTurn` and puts a shadow-root toolbar before each reply table that copies it as\nMarkdown or TSV. The Markdown is conversation export's own `extractTable`, and TSV reads the same\n`collectTableRows`, so formulas stay LaTeX and copy never drifts from export.\n\n`collapse` (since 1.8.0) limits the visible height of long content and\nplaces one expand/collapse button after each target. Its optional `target` is a semantic key:\n`userTurn` (default), `assistantTurn` or `thinkingBlock`; raw selectors are not accepted.\n`maxPx` defaults to 320 and accepts finite numbers from 80 to 2000; `startCollapsed` defaults\nto `true`. Content at or below `maxPx + 16` pixels gets no toggle. Streaming content gains a\ntoggle when it grows past that threshold, and an expanded element stays expanded while the\nsame host element survives. Labels are localized, buttons work with the keyboard, and styling\nsupports Voyager's light/dark scheme and RTL layout. Unmount restores the host layout.\n\nBecause `target` is configurable, the contract has no fixed semantic requirements. Authors\nmust declare the chosen key in `requires.semantic`, including `userTurn` when omitting\n`target`; a missing adapter selector leaves the primitive inactive. Targeting `assistantTurn`\nclips the complete reply, including any thinking content inside it. Choose `thinkingBlock`\nseparately when only that content should collapse. The primitive has no host-specific checks:\navoid content that already has native collapse, such as DeepSeek's `.ds-collapsible-text`.\nThere is no expand-all control. A plugin can expose `maxPx` and `startCollapsed` as settings\nusing `\"params\": { \"target\": \"userTurn\", \"maxPx\": \"{{maxHeight}}\", \"startCollapsed\": \"{{startCollapsed}}\" }`.\n\n`sendShortcut` claims one composer send mode per page on every site: a second plugin\nreports a conflict while the current holder keeps running. The optional `mode`\n(since 1.9.0) is `ctrlEnterSends` (default) or `enterSends`. Gemini only accepts\nthe default, AI Studio only the latter, via the packaged page coordinator; Gemini\nEnterprise accepts neither. Composition, Process and keyCode 229 are left to the IME.\nLocal send plugins are open on Gemini and AI Studio. Disable the corresponding\nofficial send switch to hand the page's mode to a local plugin; the Safari fix\nremains paired with Gemini's packaged send owner.\n\n`turnNavigator` likewise claims `timeline-rail` on every site, by design: a second rail on\nChatGPT, Claude or DeepSeek is paused as a conflict while the mounted one keeps running.\n\n`draftSave` (since 1.8.0, no parameters; reads `composer` and\n`sendButton`, and stays inert without `conversationIdPattern`) is Gemini's draft auto-save on a\ncatalog site. It shares `pages/content/draftSave/draftStore.ts` (debounce, empty means remove,\nfive newest per prefix) and the composer reader/writer in `core/services/composer/`. Drafts are\nstored in `chrome.storage.local` as `gvDraft:<siteId>:conv:<id>`, or `gvDraft:<siteId>:new` for\nevery page without a conversation id, so they never meet Gemini's `gvDraft_<path>` entries.\nA draft is restored once per conversation entry and only into an empty composer; a restore\nstill loading is abandoned once the person types (`core/utils/userEvent.ts` `isUserEvent`, so a\nhost filling its composer while mounting does not count). A draft is dropped when the composer\nempties within two seconds of Enter, the send button or a form submit, or when that send moves\nthe new-chat page to its conversation; switching from one conversation to another never counts\nas a send. While a `sendShortcut` plugin holds the composer's Enter\n(`verbs/composerEnterOwner.ts`), Enter is never read as a send: only its button click is. ChatGPT temporary chats save\nnothing. These sites have no account index in their routes, so drafts are per site and\nconversation, not per account. Unmount stops everything and keeps stored drafts.\n\nOn Gemini, the same primitive selects the trusted Gemini policy and shares native\n`gvDraft_<full pathname>` records, including `/u/N` and Gem routes. Official and\nlocal draft plugins can run together; writes and five-record pruning are queued\non this page. A confirmed first send clears its source new-chat draft, while\nnavigation without a send keeps it. Restore checks route, account and input edits\nafter asynchronous waits, and never restores into an old-question edit input.\nUnload makes a best-effort final save; asynchronous storage cannot be guaranteed\nafter browser closure. Changing email within one account slot still shares stored\ndrafts. Gemini Enterprise stays inactive. Drafts and their key format survive\nany rollback of the plugin owner.\n\n`quoteReply` (since 1.8.0, no parameters; reads `assistantTurn` and\n`composer`) is Gemini's quote reply on a catalog site. Selecting text wholly inside one\nassistant turn shows a localized Quote button near the selection end; activating it appends the\ntext to the composer as a Markdown quote (every line prefixed `> `, a blank line after an\nexisting draft) and never sends. Selections touching a user turn or the adapter's\n`thinkingBlock` show nothing. Rendered KaTeX is quoted as its LaTeX source through\n`features/formulaCopy/formulaSource.ts`; line prefixing and Gemini math replacement are shared\nwith Gemini's quote reply in `core/utils/quotedText.ts`. A missing composer shows a toast and\nkeeps the selection. Unmount removes the button, its style and every listener synchronously.\n\n`copyButton` (since 1.8.0; required `target` of `userTurn`, `assistantTurn`, `thinkingBlock`\nor `codeBlock`; optional `format`, which accepts only `text` today) places a small copy button above each\nmatched element. It copies the element's visible text without Voyager's own controls and\nconfirms with a toast; the element itself is left untouched.\n\n`counterBadge` (since 1.8.0; `target` is `composer` or `assistantTurn`,\n`metric` is `chars` (default), `words`, `cjkChars` or `lines`) shows a small read-only count.\nFor `composer` it sits above the adapter's `composerRoot` (or the composer's parent), reads the\ndraft with `readComposer` and hides while the draft is empty; for `assistantTurn` each reply\ngets a badge at its end, recounted from `copyableText` once streaming settles. The label is CSS\ngenerated content, so copying, export and the counts themselves never include it. Shipped\nplugins use only the composer target.\n\nSome behaviour cannot be expressed as CSS or as the reversible DOM operations.\nA **primitive** is that behaviour, written once as first-party TypeScript inside\nthe extension and given a name a manifest can call:\n\n```jsonc\n{\n  \"engine\": \">=1.3.0\",\n  \"requires\": { \"handlers\": [\"formulaCopy\"] },\n  \"contributes\": {\n    \"domOps\": [{ \"op\": \"native\", \"handler\": \"formulaCopy\", \"params\": {} }],\n  },\n}\n```\n\nThe manifest picks a primitive and configures it. It never supplies logic: no\nconditions, no ordering, no code. `params` is configuration, checked by that\nprimitive's own hand-written guard before the primitive sees it (plan C1, D17).\nThat is what keeps a remote manifest data rather than remotely-hosted code, and\nit is why `native` is safe on the same channel as CSS.\n\nNative params may use `{{settingKey}}` in string values, recursively inside\nobjects and arrays. Parameter names and object keys stay literal. A whole-value\ntoken preserves the setting's number, boolean or string type; embedded tokens\nproduce a string. For example, `turnNavigator` can receive\n`\"params\": { \"position\": \"{{side}}\" }` from a select setting with `left` and\n`right` options. Expansion runs once, so tokens inside setting values remain\nliteral. Every referenced key must exist in `contributes.settings`, and the\nsetting type must fit the primitive contract wherever statically knowable.\nManifest loading checks substituted defaults. Each activation substitutes\nresolved stored values and runs the primitive's `validateParams`; an invalid\nresult skips the op through the same diagnostic path as invalid static params.\nSettings changes serialize disposal and reactivation for templated primitive\nops; static params keep the existing in-place update path.\n\nParams substitution requires engine 1.8.0, alongside any higher primitive or\nparameter floor. It has shipped, so plugins that rely only on it declare a concrete\n`\">=1.8.0\"`; `\">=next\"` is for a primitive or parameter that is still unreleased.\nThe capability floor remains 1.8.0 in later releases. Engine 1.7.0 passes tokens\nliterally to primitive guards, so a selector token can be accepted yet match\nnothing; enforcing the floor makes an older build report `needs-engine`.\n\nEach primitive is one folder, `verbs/<name>/`, and adding the folder is all\nthe registration there is:\n\n- **`contract.ts`** — data only: `export const contract` with the primitive's\n  `name` (the folder name), `sinceEngine`, the semantic keys it reads from the\n  adapter, and its parameter spec (`type`, `required`, `description`; a\n  param that names a semantic key the primitive reads is `type: 'semanticKey'`,\n  optionally narrowed by `allowedKeys`). It imports only types and data modules\n  (`contractTypes.ts`, `codeBlockDiagramParams.ts`), so Bun scripts and the CLI\n  can load it without DOM or implementations; `primitiveFolders.test.ts`\n  enforces this.\n- **`index.ts`** — the implementation: a primitive object carrying that\n  contract, a `validateParams` guard, and `activate(scope, params, context)`.\n  Every side effect is registered on the `PluginScope`, so unmount pays them\n  all back, and the primitive reports how many elements it acts on through\n  `context.setTargetCounter`.\n- **`paramsBaseline.json`** — the published params (D9, below).\n\n`verbs/contracts.ts` collects the contracts, sorted by name, through\n`import.meta.glob`; Bun has no glob, so Bun scripts and the CLI build feed the\nsame files from disk (`scripts/lib/primitiveContractsSeam.ts`). `verbs/registry.ts`\nfinds the implementations the same way, and `verifyPrimitiveRegistry()` names\nany folder whose contract and implementation disagree. The primitive enums in\n`docs/public/plugin.schema.json` are generated: run `bun run skills:build`\nafter adding a primitive (it also refreshes the public skill).\n\n`PLUGIN_ENGINE_VERSION` records the released engine version and is never\nhand-bumped by a contributor. A new primitive or optional parameter declares\n`sinceEngine: 'next'`; its dependent catalog plugin uses `\"engine\": \">=next\"`.\nThe shared resolver maps `next` to the released engine plus one minor, patch\nzero (1.8.0 → 1.9.0). While any contract or param is `next`, this tree runs that\neffective engine version, so its bundled unreleased plugins are usable.\n\nA manifest that uses a primitive must set an `engine` range whose **minimum** is\nat least its resolved `sinceEngine`, and `bun run catalog:build` fails the build\notherwise (plan §5, §8). Contributor checks, CLI checks and bundled loading\nresolve the same notation. Catalog publishing writes only concrete versions,\nso a released extension filters an update requiring the next engine without\nmistaking it for a remote kill switch. Local imports require concrete versions;\nthe public authoring skill and CLI scaffold document resolved primitive floors.\n\n`bun run bump` stamps all unpublished contracts, param baselines and dependent\ncatalog manifest ranges, and advances the engine only when `next` exists. A\nrelease gate rejects any remaining `next`; extension-version bumps without\nunpublished engine additions leave the engine version unchanged.\n\nThe primitive engine floor makes an older Voyager report `needs-engine`\n(\"update Voyager\") instead of `needs-handler`, which means a configuration\nmistake. `catalog:build` also rejects a handler\nwith no contract, and a semantic key the plugin's own site does not define.\n\nA param added to a shipped primitive carries its own `sinceEngine` (D9: params\nare only ever added). An older engine rejects a param it does not know and skips\nthe whole op, so a manifest that sets the param needs an `engine` floor at least\nthat high; `plugin:check` and `catalog:build` enforce it alongside the primitive\nfloor, and `builtin.test.ts` applies the same check to the builtins.\n\n`requires` states the same needs declaratively:\n\n- `requires.handlers` lists the primitives the plugin invokes. Every `native` op\n  is implied, so listing it is documentation, not duplication.\n- `requires.semantic` lists semantic keys the plugin depends on beyond the ones\n  its ops target. A primitive's own `semantic` keys come from its contract, and\n  the key a `semanticKey` param names (every option, when the param is a select\n  setting's `{{key}}` token) comes from the op's params.\n\n`runtime/pluginStatus.ts` derives both sets (`requiredHandlers`,\n`requiredSemanticKeys`) and turns them into a status, which `PluginHost` reports\nfor every plugin that targets the page instead of quietly filtering it:\n\n| Kind             | Meaning                                 | Popup                                         |\n| ---------------- | --------------------------------------- | --------------------------------------------- |\n| `needs-engine`   | `engine` range above this build         | toggle disabled, \"needs Voyager ≥ x.y\"        |\n| `needs-handler`  | a primitive or runtime it lacks         | toggle disabled, \"update Voyager\"             |\n| `needs-semantic` | the site adapter lacks a key it needs   | toggle disabled, names the site               |\n| `ready`          | compatible, not mounted yet             | normal toggle                                 |\n| `mounted`        | enabled and running on this page        | normal toggle                                 |\n| `no-effect`      | mounted but found nothing (D12)         | toggle stays on, yellow warning               |\n| `blocked`        | compatible, kept off by `blockedReason` | toggle keeps the user's choice, note says why |\n\n`blockedReason` (with `blockedBy` naming the other plugin where there is one):\n\n| Reason            | Meaning                                                                                       | Clears                              |\n| ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------- |\n| `host-policy`     | this build refuses its `theme` or a `native` op on this site                                  | in a build that allows it           |\n| `duplicate-claim` | the manifest starts one page-wide runtime twice                                               | when the manifest is fixed          |\n| `conflict`        | `blockedBy` holds or reserves a page-wide runtime it needs                                    | when the user turns one of them off |\n| `releasing`       | next in line, waiting for `blockedBy` to finish shutting the runtime down                     | by itself                           |\n| `pending`         | `blockedBy",".agents/skills/voyager-contribute/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/voyager-contribute/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/agents/openai.yaml)",".agents/skills/update-safari-extension/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/update-safari-extension/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/agents/openai.yaml)",".agents/skills/release/references/changelog.md":"# References Skill\n\nPath: `.agents/skills/release/references/changelog.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/changelog.md)",".agents/skills/release/references/release-body.md":"# References Skill\n\nPath: `.agents/skills/release/references/release-body.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/release-body.md)",".agents/skills/release/references/safari-dmg.md":"# References Skill\n\nPath: `.agents/skills/release/references/safari-dmg.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/safari-dmg.md)",".agents/skills/release/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/release/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/agents/openai.yaml)",".agents/skills/issue-review/agents/openai.yaml":"# Agents Skill\n\nPath: `.agents/skills/issue-review/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/agents/openai.yaml)","src/features/plugins/catalog/marketplace.json":"# Claude Plugin Marketplace Catalog\n\nPath: `src/features/plugins/catalog/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/marketplace.json)","src/features/plugins/catalog/plugins/claude-reading-width/plugin.json":"# claude-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/plugin.json)","src/features/plugins/catalog/plugins/claude-reading-width/README.md":"# claude-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/README.md)","src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json":"# claude-cjk-render-fix Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json)","src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md":"# claude-cjk-render-fix Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md)","src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json":"# chatgpt-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json)","src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md":"# chatgpt-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md)"},"items":[{"name":"openai.yaml","path":".agents/skills/issue-review/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"# Agents Skill\n\nPath: `.agents/skills/issue-review/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/agents/openai.yaml)","tokens":300,"sizeBytes":204},{"name":"SKILL.md","path":".agents/skills/issue-review/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/SKILL.md","title":"issue-review","category":"anthropic-skill","format":"markdown","content":"---\nname: issue-review\ndescription: Investigate or fix a referenced Voyager GitHub issue, within the requested delivery scope.\n---\n\n# Issue Review\n\n1. Begin with `gh issue view <number-or-url> --comments` (add `--repo owner/repo` when needed). Read the actual issue before locating the cause in the repository.\n2. Match completion to the request: investigation delivers evidence, root cause or remaining uncertainty, and a recommended fix; implementation delivers the scoped change and applicable verification from `AGENTS.md`.\n3. When the request includes committing or publishing, use `voyager-contribute`. A fix commit or PR includes `Closes #<number>` or `Fixes #<number>`; verify any created commit with `git show --stat --format=fuller HEAD`. An investigation or uncommitted fix needs no commit inspection.\n4. When authorized to post, report the actual status in the reporter's language. Say the fix has landed and is coming in the next version only after it has landed. Close the issue only when the fix has landed or another agreed resolution is satisfied and closing is authorized; a local fix or an open PR alone is not completion of the issue.\n","frontmatter":{"name":"issue-review","description":"Investigate or fix a referenced Voyager GitHub issue, within the requested delivery scope."},"isInternal":false,"tokens":243,"sizeBytes":1155},{"name":"openai.yaml","path":".agents/skills/release/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"# Agents Skill\n\nPath: `.agents/skills/release/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/agents/openai.yaml)","tokens":300,"sizeBytes":194},{"name":"changelog.md","path":".agents/skills/release/references/changelog.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/changelog.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# References Skill\n\nPath: `.agents/skills/release/references/changelog.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/changelog.md)","tokens":300,"sizeBytes":208},{"name":"release-body.md","path":".agents/skills/release/references/release-body.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/release-body.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# References Skill\n\nPath: `.agents/skills/release/references/release-body.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/release-body.md)","tokens":300,"sizeBytes":214},{"name":"safari-dmg.md","path":".agents/skills/release/references/safari-dmg.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/safari-dmg.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# References Skill\n\nPath: `.agents/skills/release/references/safari-dmg.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/safari-dmg.md)","tokens":300,"sizeBytes":210},{"name":"SKILL.md","path":".agents/skills/release/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/SKILL.md","title":"release","category":"anthropic-skill","format":"markdown","content":"---\nname: release\ndescription: Prepare, publish, or recover a Voyager release. Use for version bumps and releases, failed store submissions, or Safari release failures.\nmetadata:\n  version: '1.5.0'\n---\n\n# Voyager Release\n\nUse repository scripts and `.github/workflows/release.yml` as the source of truth. Normal releases use CI: a release tag builds the browser artifacts, signs and notarizes Safari, creates the GitHub Release, and submits the stores.\n\n## Choose the requested workflow\n\nRead only the reference needed for the current task:\n\n- New release, version bump, or interrupted release preparation: [normal-release.md](references/normal-release.md).\n- Failed Chrome or Edge submission, or an urgent Firefox-only hotfix: [store-recovery.md](references/store-recovery.md).\n- Safari CI failure or an explicitly requested local recovery artifact: [safari-dmg.md](references/safari-dmg.md).\n\n## Authorization and publication boundaries\n\nThe user's request and existing approval define the scope; loading this skill grants no additional permission. Complete authorized preparation and verification, and reuse approval for the same version, destination, and action. A diagnosis or preparation request does not by itself authorize publication.\n\nBefore a tag push, store submission, or replacement upload, verify that the concrete action is authorized. Ask only when that authorization is missing or the version, scope, or a possible release blocker needs a user decision; prepare the reviewable result first. Explain any newly discovered change to the approved release.\n\n- A tag push triggers the public GitHub Release, Safari signing/notarization and Sparkle feed, Firefox AMO submission, Chrome Web Store publication, and Edge Add-ons submission.\n- Moving a published tag requires explicit authorization for that operation and is allowed only while no release artifacts have been produced. Otherwise recover the affected store or prepare a new version; never silently retag.\n- Preserve unrelated changes and stage release paths explicitly. Never bypass hooks with `--no-verify`.\n- Keep Safari compatibility bundle IDs and signing requirements intact. Do not run a second local Safari release or upload a second DMG while CI is producing the release.\n- Failed required checks block tagging and publication. Fix failures caused by the authorized work and rerun affected checks; ask only when resolving a blocker needs new scope or a risk decision. An accepted unresolved failure remains reported as failed and never bypasses artifact signing, notarization, or privacy requirements.\n\n## Completion\n\nFor preparation, report the version, scoped changes, verification results, and remaining publication step. For diagnosis, report the failing stage, evidence, and the concrete recovery path. Do not advance either request into publication automatically.\n\nFor publication or recovery, finish the applicable workflow and verify its actual result: GitHub assets/body, Safari notarization/privacy evidence and signed Sparkle feed, and the requested stores' signing/submission outcomes. Distinguish submission success from store availability, and report pending review or failures. A successful GitHub Release alone does not prove every store or Safari succeeded.\n","frontmatter":{"name":"release","description":"Prepare, publish, or recover a Voyager release. Use for version bumps and releases, failed store submissions, or Safari release failures.","metadata":{"version":"1.5.0"}},"isInternal":false,"tokens":608,"sizeBytes":3255},{"name":"openai.yaml","path":".agents/skills/update-safari-extension/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"# Agents Skill\n\nPath: `.agents/skills/update-safari-extension/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/agents/openai.yaml)","tokens":300,"sizeBytes":226},{"name":"SKILL.md","path":".agents/skills/update-safari-extension/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/SKILL.md","title":"update-safari-extension","category":"anthropic-skill","format":"markdown","content":"---\nname: update-safari-extension\ndescription: Build, reload, and verify Voyager in Safari for web-extension changes, native integration, or signed-app testing while preserving installed apps and extension data.\n---\n\n# Update Safari Extension\n\nUse the least invasive route that can test the change. A successful build and a successful live Safari check are separate evidence.\n\n## Choose the runtime route\n\nCheck `git status --short --branch -uall` and inspect Safari Settings > Developer/Extensions. Record whether Voyager is temporary or app-installed and enabled before changing it.\n\n- **Web extension:** TypeScript, React, CSS, manifest, or bundled assets that stay inside the extension use the temporary route below.\n- **Native or signed app:** Swift, entitlements, Sparkle, signing, packaging, or any call to `browser.runtime.sendNativeMessage` require [native-app.md](references/native-app.md). Temporary extensions cannot launch the containing-app handler, including iCloud sync, Safari Google Drive authorization, and native notifications.\n- **Native behavior:** For Drive authorization, notification delivery/clicks, or account-scoped sync verification, also read [native-behavior.md](references/native-behavior.md).\n\n## Default: temporary extension\n\n1. Run `bun run build:safari`. This builds `dist_safari` and verifies Safari resource wiring.\n2. In Safari Settings > Developer > Temporary Extensions, click Voyager's **Reload**. If absent, choose **Add Temporary Extension…** and select `dist_safari`.\n3. Reload the target Gemini, AI Studio, ChatGPT, or Claude tab once to load the current content script.\n4. Open the Voyager popup and test the changed behavior.\n\nSafari removes temporary extensions after 24 hours or when Safari quits. Re-add `dist_safari`; a routine UI refresh does not require signed installation.\n\n## Safety and recovery\n\n- Preserve `/Applications/Voyager.app` and the legacy `/Applications/Gemini Voyager.app`. Replacing an installed app or changing distribution signing/provisioning requires an explicit signed-app test request. Local development profile refresh is covered by the native route.\n- Preserve Safari extension containers, preferences, storage, permissions, bundle identifiers, and versions; changing them is not a stale-build remedy.\n- Use Safari's **Reload** for normal refreshes. If it looks stale, verify the built asset, reload the target tab, and reopen the popup. For launch/URL routing failures or duplicate entries, use the read-only diagnosis in [native-app.md](references/native-app.md).\n- Registration changes (`pluginkit -r`/`-a` or manual unregister/register) are targeted recovery only, with Safari closed, an exact stale/current path pair, and a recovery plan. Reuse explicit user authorization that covers this recovery; otherwise complete read-only diagnosis and prepare the plan before asking. A generic reload request does not authorize it.\n- Never bulk-reset LaunchServices or unregister/remove an app in `/Applications` without explicit permission. Avoid launching apps from Archives, backups, disk images, or old DerivedData; duplicate bundle identifiers can route Safari or custom URLs to the wrong copy.\n- Keep signing identities, provisioning-profile contents, Keychain data, Apple account details, notarization credentials, and CI secret values out of output and commits. Refer only to repository-documented environment-variable names. Keep generated apps, archives, DerivedData, and signed artifacts out of commits unless explicitly tracked.\n- If a feature disappears, stop the update attempt, preserve app and extension data, return to the previously working extension route, and verify the feature.\n\n## Evidence and completion\n\nReport the highest tier actually proven:\n\n1. **Build:** `bun run build:safari` passes.\n2. **Artifact:** expected JS/CSS/resources exist in `dist_safari` and the Xcode bundle wiring check passes.\n3. **Loaded:** Safari shows the intended temporary or containing-app extension enabled once, without an unexpected duplicate.\n4. **Live behavior:** the popup/page visibly shows the changed UI or behavior after reloading the target tab.\n5. **State safety:** pre-existing cards/settings remain visible. A missing card is not evidence of successful loading.\n\nInspect the reloaded popup for popup changes; reload the website for content-script changes; close and reopen the popup for background/native changes. Claim the latest build is loaded only after checking live behavior or a build-specific visible marker in Safari. A marker alone does not prove the changed behavior; report any unverified behavior or state safety.\n\nA standalone popup tab has no source-tab account scope. For account-scoped sync checks or a toolbar popover dismissed before automation reaches its button, follow the testing boundaries in [native-behavior.md](references/native-behavior.md).\n","frontmatter":{"name":"update-safari-extension","description":"Build, reload, and verify Voyager in Safari for web-extension changes, native integration, or signed-app testing while preserving installed apps and extension data."},"isInternal":false,"tokens":958,"sizeBytes":4864},{"name":"openai.yaml","path":".agents/skills/voyager-contribute/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"# Agents Skill\n\nPath: `.agents/skills/voyager-contribute/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/agents/openai.yaml)","tokens":300,"sizeBytes":216},{"name":"browser-testing.md","path":".agents/skills/voyager-contribute/references/browser-testing.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/references/browser-testing.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Browser verification\n\nRead this reference only when a change affects extension runtime, UI, manifests, permissions, packaging, native integration, or plugins.\n\n## Evidence\n\nReport only the highest level actually completed:\n\n- **Build**: the command succeeds and the expected artifact exists.\n- **Loaded**: that artifact is enabled once in the browser with no manifest or load error.\n- **Live**: after reloading the extension and target tab, the changed workflow works and existing state remains intact. The `dist_chrome_dev` build reloads itself after each dev build, so there only the tab needs refreshing; the [verify-in-browser](../../verify-in-browser/SKILL.md) skill covers confirming the loaded build.\n\nA build, unit test, or code inspection is not `Loaded` or `Live` evidence.\nFor `Loaded` or `Live`, record the browser name, exact version, scenario, result, and redacted evidence.\n\n## Route by risk\n\n| Change                                                                                           | Required evidence before review is complete                                                                                                                                                |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Docs, comments, or test-only work                                                                | Relevant automated checks; explain why browsers are not applicable.                                                                                                                        |\n| Pure logic with no browser API or visible behavior change                                        | `bun run verify:pr`; add focused tests.                                                                                                                                                    |\n| Popup, content script, background, shared CSS, storage, or plugin runtime                        | `verify:pr`, then `Loaded` and `Live` on Chrome and Firefox. Add Edge for Chromium-specific, manifest, permission, or packaging behavior. Add Safari whenever Safari behavior is affected. |\n| Browser-specific code, permissions, native messaging, Swift, signing, packaging, or release work | `Loaded` and `Live` on each affected browser, builds for the others, and the feature-specific regression checks. Use the Safari or release skill where applicable.                         |\n| Bundled official or builtin plugin                                                               | `Live` on Chrome, Edge, Firefox, and Safari before merge unless the manifest explicitly supports fewer browsers, plus the plugin checks below.                                             |\n\nMissing hardware or access may be covered by another contributor. Record `Needs <browser> test; owner: @name`; a draft PR may remain open, but it is not review-ready until required evidence is added.\n\n## Artifacts\n\n| Browser | Command                                        | Load target                                                                                                                              |\n| ------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| Chrome  | `bun run dev:chrome` or `bun run build:chrome` | `dist_chrome_dev` for routine development (load once; it reloads itself after each dev build); `dist_chrome` for production verification |\n| Edge    | `bun run build:edge`                           | `dist_edge`                                                                                                                              |\n| Firefox | `bun run build:firefox`                        | `dist_firefox/manifest.json` via `about:debugging`                                                                                       |\n| Safari  | `bun run build:safari`                         | `dist_safari`; then follow the `update-safari-extension` skill                                                                           |\n\n`bun run build:all` and `bun run verify:pr` exclude Edge (the Edge zip step needs the external `zip` executable, which Windows lacks by default — CI's `Build (edge)` covers it). `bun run build:browsers` includes the Edge package build.\n\n## Baseline smoke\n\nFor each required browser:\n\n- Load exactly one enabled Voyager artifact with no manifest or startup error.\n- Open the popup and exercise the changed workflow on every affected site.\n- Check relevant popup, background/service-worker, and page consoles for new errors.\n- Reload the extension and target page; confirm UI, listeners, styles, and notifications are not duplicated.\n- Confirm existing settings and stored data remain intact.\n- For visual changes, check light and dark themes, alignment, spacing, narrow and wide layouts as applicable.\n- Preserve `/u/<index>/...` account scope when the feature is account-sensitive.\n\n## Plugin checks\n\nFor `src/features/plugins/catalog/`, catalog mappings, or builtin/native plugins:\n\n- Confirm the plugin appears once from the intended bundled source.\n- Match the default enabled state of the nearest sibling plugin unless an approved migration says otherwise.\n- Enable, disable, re-enable, and reload; verify state persistence and complete teardown without duplication.\n- Test every declared target site and confirm unrelated sites are unchanged. Test both start and stop paths for builtin JS plugins.\n- Keep custom-site access as an explicit optional grant; direct `<all_urls>` permission requires prior user approval.\n- Attach redacted screenshots or recordings for visible behavior. Never expose account data, conversations, tokens, signing identities, or native handoff URLs.\n","isInternal":false,"tokens":954,"sizeBytes":6040},{"name":"repo-traps.md","path":".agents/skills/voyager-contribute/references/repo-traps.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/references/repo-traps.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Repository-Specific Traps\n\nEvery item below cost a real contributor at least one review round (source PR in parentheses). CI catches none of them — read this before writing code, not after review.\n\n## Git and PR mechanics\n\n- **This repository squash-merges.** After your PR merges, your local branch will show as unmerged or conflicting — that is normal. Delete it and branch fresh from `main`; never re-open a PR from the old branch (#867 was closed as a self-duplicate created exactly this way).\n- **The PR title becomes the squash commit header.** Title it `type(scope): imperative summary` — never the branch name or \"Fixes #N\" (#859, #865, #867).\n- **Check `git diff origin/main --stat` (or whichever remote tracks the base repository's `main`) before opening the PR** so unrelated churn (assets, lockfiles) never rides along (#865 accidentally wiped `sponsors.svg`).\n- **`@codex review` triggers the automated review** (bare `@codex` does nothing). Trigger it once per new head, re-trigger after any rebase/force-push, and don't re-trigger while a run is pending (#854). The human reviewer finalizes only after Codex has covered the exact current head SHA.\n\n## Reuse the sibling precedent\n\nGrep for the existing primitive before writing parallel logic — reviewers treat a one-off reimplementation of an existing behavior as a defect:\n\n- Send/keyboard handling: reuse the `sendBehavior` detector — it already handles localized labels (发送/送信/enviar) and icon-only buttons, and respects the `gvCtrlEnterSend` setting. An English-only selector or unconditional Enter handler will be rejected (#854).\n- Text insertion: `chatInput` has multiline-aware insertion; a bare `createTextNode` path flattens multiline prompt bodies (#854).\n- Export CSS: extend a shared style builder (see `buildKatexExportStyles`) instead of pasting CSS into a second export service (#847).\n- Export DOM walking: `DOMContentExtractor` must use ONE shared DOM-order traversal helper — Gemini nests arbitrary `section`/`div`/`response-element` layers, and any second code path will miss one (#847 spent 4+ rounds on this).\n- Body-level popovers, global listeners, overlays: copy an existing `gv-pm-*` integration (close-outside handlers, teardown, theme overrides).\n\n## New-site features go through the plugin system\n\n- Adding support for a new site (ChatGPT, Claude, …) goes through the plugin system: declarative CSS+JSON plugins belong in the bundled catalog (`src/features/plugins/catalog/` via `BundledCatalogPluginSource`); only native-function plugins that genuinely need JS go in `src/features/plugins/builtin/` with optional host permission, dynamic content-script registration, and a `start`/`stop` lifecycle with teardown. Plugins start disabled unless an approved default lists them in `DEFAULT_ENABLED_PLUGIN_IDS` (`storage/pluginDefaults.ts`; today only the bundled catalog plugin `voyager.chatgpt-folders`), and even then the content script runs only on origins the user granted.\n- `builtin/` also holds the packaged Gemini / AI Studio official plugins (`gemini*Manifest.ts`, `aistudio*Manifest.ts`). Those are not new-site plugins: their switch is the old sync setting (`storage/legacySettingBindings.ts`), so their default is that binding's reading of a missing key (usually the native feature's old default), and they run only where the content script admits them. Follow `create-voyager-plugin`'s \"Convert a native Gemini or AI Studio feature\" for them. Never add static `content_scripts` or required `host_permissions` to `manifest*.json` — manifest permission escalation without prior Issue approval is a hard stop (#865).\n- Platform registries are additive: never delete or narrow another platform's adapter or tests to make room for yours (#865).\n- Any storage-toggled feature needs a full lifecycle — working start AND destroy at page load and on runtime toggle. Hiding the UI is not disabling the feature (#854).\n\n## Docs and locale mirrors\n\n- \"The docs\" are 20+ surfaces: the main `README.md`, nine `.github/README_*.md` locale mirrors, ten `docs/<locale>/` trees, and `docs/public/llms*.txt`. The green `i18n` CI check only compares `src/locales/*/messages.json` keys — it covers none of these (#874, #853).\n- Never delete a published docs route — there is no redirect layer, so replace retired pages with a localized notice at the original URL (#874).\n\n## Theme and data correctness\n\n- Host theme markers (`.theme-host.dark-theme`, `body.dark`, `html.dark`, …) belong only in each `site.json` `theme` descriptor; `src/pages/content/platformTheme/scheme.ts` reads it and stamps `html[data-gv-scheme]`. Voyager CSS keys off `html[data-gv-scheme='dark' | 'light']` and JS uses `getScheme()` / `subscribeScheme()`; `contentStyleTheme.test.ts` rejects host markers in CSS. Test theme logic against the descriptor with conflicting markers, not single signals (#859).\n- Theme the whole export artifact consistently — a dark diagram on a forced-white document is not dark-mode support (#847).\n- Gemini conversation IDs are namespaced (`gemini:conv:<id>`); shared code that handles raw IDs silently orphans existing starred/bookmark data (#865). Preserve `/u/<index>/...` account scope in every constructed route.\n- Prompt/folder data has multiple merge entry points (`utils/merge.ts` plus page-level Drive merges); route data-shape changes through one shared merge helper, and never drop or rename existing user records on conflict (#854).\n\n## Scope discipline\n\n- The reviewer diffs your behavior against the Issue's confirmed scope and blocks deviations in either direction. When an edge case tempts you to change a product constraint (e.g. enforce name uniqueness), ask in the Issue first (#854); state non-goals explicitly in the PR description.\n- Trivially verifiable one-line fixes may go straight to PR (#876). Features require explicit maintainer approval of the approach, in the Issue or a direct instruction in the current task; assignment alone is not approval (#865).\n","isInternal":false,"tokens":1326,"sizeBytes":6010},{"name":"SKILL.md","path":".agents/skills/voyager-contribute/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/SKILL.md","title":"voyager-contribute","category":"anthropic-skill","format":"markdown","content":"---\nname: voyager-contribute\ndescription: Prepare Voyager commits, pull requests, and contribution evidence, or claim an issue and check review readiness.\n---\n\n# Voyager Contribution\n\nUse only the stages included in the user's request; a readiness review delivers findings, while an authorized commit or publication continues through verification and handoff. Route issue investigation to `issue-review`, Safari loading or native verification to `update-safari-extension`, and releases to `release` when needed.\n\n## 1. Preflight\n\n1. Inspect `git status --short --branch`, the current branch, and the diff. Preserve unrelated work.\n2. Read the linked Issue and its comments when one exists. A new feature needs explicit maintainer approval of the approach, either in the Issue or as a direct instruction in the current task; assignment or `/claim` only selects an owner.\n   The PR gate (`.github/workflows/pr-gate.yml`) closes any PR from an author outside `.github/MAINTAINERS` and `.github/APPROVED_CONTRIBUTORS` unless it has a `fix:` / `fix(scope):` title and stays within 20 files and 1,000 changed lines, or a maintainer added the `maintainer-approved` label. Docs, refactor and feature PRs from such authors need that alignment first. A maintainer's `/approve @user` both assigns the issue and adds the user to `APPROVED_CONTRIBUTORS`.\n3. Work on one focused topic branch targeting `main`. Keep secrets and generated `dist_*` artifacts out of commits.\n4. Read [repo-traps.md](references/repo-traps.md) — the repository-specific pitfalls that have cost past contributors the most review rounds.\n5. Use `gh` as the source of truth for Issue/PR state. Before any GitHub write, confirm `gh auth status` shows the account you intend to contribute as. If GitHub CLI is unavailable, perform Issue/PR reads and writes through the GitHub web UI instead and note that in the PR.\n\nPreflight is complete when the Issue or rationale, approval state, intended scope, current branch, and clean ownership of every changed file are known.\n\n## 2. Verify\n\n1. Add or update regression tests for behavior changes. If no useful automated test exists, record the reason.\n2. Use the verification matrix in [AGENTS.md](../../../AGENTS.md#verification): code PRs require `bun run verify:pr`; prose-only changes use the applicable document checks. During implementation, run focused checks; reuse passing evidence for unchanged inputs. If formatting or lint fixes are needed, apply them to the intended files with `bun run format <paths>` / `bun run lint <paths>` and inspect the diff before checking the final tree. Read-only reviews use check variants.\n\n   `verify:pr` covers local automation and production browser builds; it does not prove that an extension loaded or that live behavior works. Finish with `git diff --check` and `git status --short`.\n\n3. For runtime, UI, manifest, permission, packaging, native, or plugin changes, read [browser-testing.md](references/browser-testing.md) and collect the required live evidence.\n4. Record every omitted command or browser check with its reason. For required coverage that another person must complete, name the browser and owner and leave the item pending.\n\nVerification is complete when every applicable automated check and browser check has a truthful result, the working tree matches the snapshot being tested, and remaining work is explicitly assigned.\n\n## 3. Commit and publish\n\n1. Review the final diff and stage only intended paths.\n2. Create a Conventional Commit with a lowercase scope and a header no longer than 100 characters. Add `Fixes #<issue>` or `Closes #<issue>` when appropriate. Retain the active agent's project-standard co-author footer; Codex-authored commits use:\n\n   ```text\n   Co-authored-by: Codex <codex@users.noreply.github.com>\n   ```\n\n3. Inspect the commit with `git show --stat --format=fuller HEAD` and `git status --short`. Require no staged or unstaged changes in the contribution's paths; preserve and disclose unrelated pre-existing work. `HEAD` is the tested commit only while the verified paths match it, and any later change invalidates affected evidence.\n4. When publishing is authorized, push only the topic branch and open or update a draft PR targeting `main`. Contributors never push directly to `main`; use force-push only with explicit user approval — this repository squash-merges, so a merged branch will later look unmerged (see repo-traps.md) and must be deleted, not re-pushed. Exception: this rule governs contribution workflows; a maintainer with push access working outside a contribution follows the project-wide default push rules in `CLAUDE.md`/`AGENTS.md`, which take precedence.\n5. In the PR, state the linked Issue or direct authorization/rationale, scope, tested commit, commands run, live browser evidence, screenshots for UI changes, and all pending checks. Re-run affected checks after review changes.\n6. Verify the PR author, base branch, commit set, changed files, and CI state with `gh` before handoff.\n\nPublishing is complete when the focused diff is on a topic branch, the draft PR accurately reports its evidence and gaps, and the user receives the PR URL plus pending review or CI work.\n\nAfter an Issue fix lands, leave a short comment in the reporter's language: the fix has landed, it will be available in the next version, and the Issue may be reopened if the problem remains.\n","frontmatter":{"name":"voyager-contribute","description":"Prepare Voyager commits, pull requests, and contribution evidence, or claim an issue and check review readiness."},"isInternal":false,"tokens":1164,"sizeBytes":5389},{"name":"content-scripts.md","path":".claude/rules/content-scripts.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.claude/rules/content-scripts.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"---\nglobs: [\"src/pages/content/**\", \"src/styles/contentStyle/**\"]\n---\n\n# Content Script Rules\n\n## CSS\n- All injected CSS classes MUST be prefixed with `gv-` (e.g., `.gv-rtl`, `.gv-pm-trigger`)\n- Shared/static injected CSS goes in the matching part under `src/styles/contentStyle/` by default; the build joins the parts in file-name order into the shipped `contentStyle.css`, so the number prefix is cascade order. Add a numbered part rather than grow one past the commit size limit. Feature-specific dynamic CSS may be injected by content modules or the plugin runtime when values are computed at runtime; keep it `gv-` prefixed and tear it down.\n- Support both light and dark themes through `html[data-gv-scheme='dark']` / `html[data-gv-scheme='light']`, which `platformTheme` stamps from each site's `site.json` theme descriptor. Never target a host's own theme markers (`.theme-host.dark-theme`, `body.dark-theme`, `html.dark`, …); `src/core/utils/__tests__/contentStyleTheme.test.ts` rejects them.\n- RTL layout: use `body.gv-rtl` selector for RTL overrides (see `src/core/utils/rtl.ts`)\n\n## Storage\n- Content scripts use `chrome.storage` / `browser.storage` directly — this is the exception to the \"use StorageService\" rule\n- Outside content scripts, prefer `StorageKeys` plus existing storage helpers/services. Direct `chrome.storage` / `browser.storage` still exists in popup/background/options for established settings patterns, bulk reads/writes, and storage listeners.\n\n## DOM Injection\n- Each content script sub-module in `src/pages/content/` is self-contained\n- Bridge between host sites (Gemini, AI Studio, Claude, ChatGPT, DeepSeek) and the extension — any host DOM may change without notice\n- Safari has platform-specific manifest and native-bridge paths, but cloud sync,\n  watermark removal, and image export are supported. Do not add an `isSafari()`\n  skip from historical assumptions; verify the current Safari implementation\n  and its browser-specific tests first.\n- Extension context can be invalidated after update/reload. Use `isExtensionContextInvalidatedError()`.\n\n## Material Symbols Icons\n- Popup Material Symbols are bundled locally through `public/fonts/material-symbols-outlined.css` and `.ttf`; verify the glyph exists in the bundled font or update the bundled font assets. Do not add a remote Google Fonts URL.\n","frontmatter":{"globs":["src/pages/content/**","src/styles/contentStyle/**"]},"isInternal":false,"tokens":524,"sizeBytes":2347},{"name":"high-complexity.md","path":".claude/rules/high-complexity.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.claude/rules/high-complexity.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"---\nglobs:\n  [\n    'src/core/services/StorageService.ts',\n    'src/core/services/DataBackupService.ts',\n    'src/core/services/GoogleDrive*.ts',\n    'src/core/services/AccountIsolationService.ts',\n    'src/features/folder/**',\n    'src/features/export/**',\n    'src/pages/content/folder/**',\n    'src/pages/content/export/**',\n    'src/features/plugins/storage/**',\n  ]\n---\n\n# High-Complexity Modules — Edit with Caution\n\n| Module                                     | Risk                                                                                                                                                                | Notes                                               |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |\n| `StorageService`                           | Typed sync/local storage wrapper used where suitable. Persistence is also handled by direct `chrome.storage`/`browser.storage` paths and migration/backup services. | Do not modify lightly.                              |\n| `DataBackupService`                        | Multi-layer backup. Race conditions during unload.                                                                                                                  | Critical for data safety.                           |\n| `GoogleDriveSyncService`                   | OAuth2 cloud sync: folders, prompts, settings, plugins, starred, forks, timeline hierarchy and highlights, scoped per account. Split across `GoogleDrive*.ts`.        | Requires OAuth2 identity.                           |\n| `AccountIsolationService`                  | Hard account isolation for multi-account.                                                                                                                           | Integrates with Drive sync.                         |\n| `features/folder` + `pages/content/folder` | Drag-and-drop + cloud sync UI. DOM manipulation + state sync.                                                                                                       | Watch for infinite loops.                           |\n| `features/export` + `pages/content/export` | JSON/MD/PDF/Image export + Deep Research.                                                                                                                           | Fragile to host UI changes. Multi-browser compat.   |\n| `features/plugins/storage`                | Plugin on/off and settings. The legacy bindings decide which store is authoritative for each plugin, and so what backup and restore keep. | A bound id ignores `gvPluginsState`; keep both backups restorable in either order. |\n\n## Changes to behavior or data\n\nApply these checks when changing logic, serialized formats, account scope, or data handling in these modules. Comments, documentation, and formatting-only changes use the prose/formatting checks in `AGENTS.md`.\n\n1. Read the entire file first — not just the section you plan to change\n2. List all existing features that might be affected\n3. Ensure zero destructiveness to user data\n4. Run the full test suite after changes; reuse that result if a later verification step checks the same unchanged inputs\n","frontmatter":{"globs":["src/core/services/StorageService.ts","src/core/services/DataBackupService.ts","src/core/services/GoogleDrive*.ts","src/core/services/AccountIsolationService.ts","src/features/folder/**","src/features/export/**","src/pages/content/folder/**","src/pages/content/export/**","src/features/plugins/storage/**"]},"isInternal":false,"tokens":518,"sizeBytes":3371},{"name":"i18n.md","path":".claude/rules/i18n.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.claude/rules/i18n.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"---\nglobs: [\"src/locales/**\", \"src/features/plugins/verbs/*/messages/**\"]\n---\n\n# i18n / Translation Rules\n\n## 10 Locales (ALL must be updated together)\n`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`\n\n## When adding a new translation key\n1. Add the key to ALL 10 `src/locales/*/messages.json` files\n2. English (`en`) is the source — write it first, then translate to all others\n3. Each key is a top-level entry in Chrome's `{ \"key\": { \"message\": \"…\", \"description\": \"…\" } }` format; no nested keys\n4. Run `bun run i18n:check`: it fails when any locale is missing a key from `en` or has an extra one\n\n## When removing a translation key\n- Remove from ALL 10 locale files\n\n## Primitive fragments\n- A string used only inside one primitive folder goes in `src/features/plugins/verbs/<name>/messages/<locale>.json` (same format, all 10 locales), not in `src/locales`; it reaches `getTranslationSync` but not `chrome.i18n`. Type its keys by augmenting `PrimitiveMessages` (see `src/utils/translations.ts`).\n- `bun run i18n:check` also requires every fragment locale to match its `en.json` and forbids keys that collide with `src/locales` or another fragment.\n\n## Quality\n- Translations should be natural, not machine-literal\n- Arabic (`ar`) is RTL — ensure UI handles it (see `src/core/utils/rtl.ts`)\n","frontmatter":{"globs":["src/locales/**","src/features/plugins/verbs/*/messages/**"]},"isInternal":false,"tokens":361,"sizeBytes":1316},{"name":"typescript.md","path":".claude/rules/typescript.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.claude/rules/typescript.md","title":"Rules Skill","category":"anthropic-skill","format":"markdown","content":"---\nglobs: [\"src/**/*.ts\", \"src/**/*.tsx\"]\n---\n\n# TypeScript Coding Standards\n\n## DOs\n- Prefer plain objects with interfaces/types for data structures\n- Use `map`, `filter`, `reduce` for immutability\n- Use `private`/`protected` in classes\n- Use `unknown` + narrowing (custom type guards) for type safety\n- Use named exports: `export function X`\n- Functional React: hooks at top level, strictly functional components\n\n## DON'Ts\n- **No `any` type.** Use `unknown` if you must, then narrow it.\n- **No global variables** outside defined Services.\n- **Avoid ad hoc storage in UI components** (`src/components/`, `src/pages/popup/`). Prefer `StorageKeys` plus existing storage helpers/services; direct `chrome.storage` / `browser.storage` is acceptable when matching established popup settings patterns, bulk reads/writes, or storage listeners.\n- **No God Components.** Business logic belongs in `features/*/services/` or custom hooks, not UI files.\n- **No magic strings.** Use constants or enums (StorageKeys, CSS classes).\n- **No `console.log` in production.** Use `LoggerService` for critical info; oxlint only warns, so do not add new ones.\n\n## Testing (Vitest + jsdom)\n- Chrome `chrome` object is globally mocked in `src/tests/setup.ts`\n- Mock specific storage: `vi.mocked(chrome.storage.sync.get).mockResolvedValue({ key: 'val' })`\n- jsdom cannot create trusted events: code that tells a person's input from a script's goes through `isUserEvent` (`src/core/utils/userEvent.ts`), and tests mock it\n- Run: `bun run test`, `bun run test <filename>`, `bun run test:coverage`\n","frontmatter":{"globs":["src/**/*.ts","src/**/*.tsx"]},"isInternal":false,"tokens":391,"sizeBytes":1571},{"name":"marketplace.json","path":"src/features/plugins/catalog/marketplace.json","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/marketplace.json","title":"Claude Plugin Marketplace Catalog","category":"marketplace","format":"json","content":"# Claude Plugin Marketplace Catalog\n\nPath: `src/features/plugins/catalog/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/marketplace.json)","tokens":300,"sizeBytes":223},{"name":"plugin.json","path":"src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json","title":"chatgpt-reading-width Plugin Manifest","category":"plugin-manifest","format":"json","content":"# chatgpt-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json)","tokens":300,"sizeBytes":277},{"name":"README.md","path":"src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md","title":"chatgpt-reading-width Documentation","category":"plugin-manifest","format":"markdown","content":"# chatgpt-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md)","tokens":300,"sizeBytes":271},{"name":"plugin.json","path":"src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json","title":"claude-cjk-render-fix Plugin Manifest","category":"plugin-manifest","format":"json","content":"# claude-cjk-render-fix Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json)","tokens":300,"sizeBytes":277},{"name":"README.md","path":"src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md","title":"claude-cjk-render-fix Documentation","category":"plugin-manifest","format":"markdown","content":"# claude-cjk-render-fix Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md)","tokens":300,"sizeBytes":271},{"name":"plugin.json","path":"src/features/plugins/catalog/plugins/claude-reading-width/plugin.json","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/plugin.json","title":"claude-reading-width Plugin Manifest","category":"plugin-manifest","format":"json","content":"# claude-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/plugin.json)","tokens":300,"sizeBytes":274},{"name":"README.md","path":"src/features/plugins/catalog/plugins/claude-reading-width/README.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/README.md","title":"claude-reading-width Documentation","category":"plugin-manifest","format":"markdown","content":"# claude-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/README.md)","tokens":300,"sizeBytes":268},{"name":"README.md","path":"src/features/plugins/README.md","rawUrl":"https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/README.md","title":"plugins Documentation","category":"plugin-manifest","format":"markdown","content":"# Plugin Ecosystem (`src/features/plugins`)\n\nA self-contained subsystem that lets Voyager run **plugins** — units that inject\nstyles / DOM changes into AI chat sites (Gemini, AI Studio, ChatGPT, Claude,\nDeepSeek, …). Voyager's own features can migrate onto this over time; third parties\ncan ship their own plugins against the same contract.\n\n## Two design constraints that shaped everything\n\n1. **Chrome MV3 forbids remotely-hosted code.** You may download _data_ (JSON/CSS)\n   and run it through engine logic that ships **inside** the package; you may not\n   download and execute JS. (`chrome.userScripts` is the only sanctioned escape\n   hatch, and it's gated behind a per-user \"Allow User Scripts\" toggle and is\n   unavailable on Safari.)\n2. **The core is GPL-3.0 with many copyright holders.** It can't be relicensed or\n   closed. Monetizable / proprietary plugins must therefore be **independent works**\n   — and _data read by a GPL engine is not a derivative work of it._\n\nBoth constraints point at the same answer → **declarative-first** plugins.\n\n## Two tiers\n\n| Tier                    | Ships                       | Store-safe                     | Runs on                   | Status                                          |\n| ----------------------- | --------------------------- | ------------------------------ | ------------------------- | ----------------------------------------------- |\n| `declarative` (default) | CSS + JSON (`domOps`)       | ✅ everywhere, remote-loadable | Chrome / Firefox / Safari | **implemented**                                 |\n| `scripted` (advanced)   | JS via `chrome.userScripts` | gated toggle, no Safari        | Chrome / Firefox          | reserved (gated, runtime is a future milestone) |\n\n## Architecture\n\n```\nPluginSource[]  ──►  manifests        SiteRegistry ──► SiteAdapter (current URL)\n   (native builtin,\n    bundled catalog,\n    remote host catalog,\n    local imports)                         ▼\npluginState (storage) ─► enabled?    DeclarativeEngine (interprets contributions)\nEntitlementProvider  ─► entitled?         │  styles + domOps, reversible, idempotent\n        └──────────►  PluginHost.reconcile() ──► engine.mount/unmount\n```\n\n- **`types.ts`** — the whole contract: `PluginManifest`, `PluginContributions`,\n  `DomOperation` (discriminated union — the main extension point), `SiteAdapter`,\n  and the `PluginSource` / `EntitlementProvider` seams.\n- **`sites/`** — `SiteAdapter` per site + a `SiteRegistry` that resolves the\n  current URL. Site-specific selectors live **only** in an adapter, behind the\n  fixed semantic vocabulary in `sites/semanticKeys.ts` (`userTurn`,\n  `assistantTurn`, `thinkingBlock`, `codeBlock`, `composer`, `sidebar`,\n  `sidePanel`, `headerActions`, `scrollContainer`, `composerRoot`,\n  `sendButton`), so a site redesign is a\n  one-file fix. Every adapter is data: `adapters/gemini.ts`, `adapters/aistudio.ts`,\n  `adapters/claude.ts`, `adapters/chatgpt.ts` and `adapters/deepseek.ts` are\n  one-line shells over their `site.json`: edit the JSON, not the TS. Gemini and\n  AI Studio stay native surfaces (`NATIVE_SITE_IDS`, `sites/hostPolicy.ts`); their\n  `site.json` is packaged with the extension and never published remotely.\n  Gemini's `userTurn`/`assistantTurn` are the joined membership lists from\n  `core/gemini/turnSelectors.ts`, bound by `adapters/gemini.parity.test.ts`;\n  timeline, export and highlight keep reading the ordered lists there.\n  `sites/siteAdapterData.ts` holds\n  `validateSiteAdapterData`, the one validator for the bundled `site.json`, the\n  remote override and `catalog:build`.\n- **`runtime/declarativeEngine.ts`** — applies a manifest's `styles` + `domOps`.\n  Reversible (full teardown), idempotent, and uses a `childList`-only\n  MutationObserver so its own mutations can't loop. Pure DOM → all platforms.\n- **`runtime/PluginHost.ts`** — orchestrator. Loads manifests, checks\n  match-URL + enabled + engine-range + entitlement, mounts/unmounts, reacts to\n  state changes. All dependencies injected → fully unit-testable.\n- **`runtime/officialAdmission.ts`** — when each packaged Gemini/AI Studio\n  official plugin may mount: the content script admits it where its native\n  module used to start, and `close` denies whatever is still pending.\n- **`runtime/resourceClaims.ts`, `resourceArbiter.ts`, `reconcileArbitration.ts`,\n  `releaseLedger.ts`** — exclusive page resources (`timeline-rail`, the composer\n  send mode, …): which plugin holds each, why the others are `blocked`, and\n  waiting until a released runtime has really shut down.\n- **`runtime/stateMachine.ts`** — `startMachine`, the runtime for features with\n  interruptible phases: a pure `transition`, one `PluginScope` per status, late\n  callbacks from a left status ignored. Used by export selection, default\n  model, draft restore, reply notifications and both batch deletes; see\n  [the state machine plan](../../../.github/docs/STATE_MACHINE_PLAN.md).\n- **`manifest/validate.ts`** — turns `unknown` into a typed manifest or a list of\n  issues. Remote catalog plugins are untrusted; bundled catalog plugins use\n  the same validator so official data cannot drift from the runtime contract.\n- **`storage/pluginState.ts`** — per-plugin enable state in `chrome.storage.local`.\n- **`sources/` `entitlement/`** — the swap points for native first-party\n  features, bundled official declarative plugins, the per-host remote catalog,\n  the user's locally imported plugins, and a future paid (Stripe/account) store. `sources/defaultSources.ts` also\n  owns `mergePluginRecords`, the rules that pick which copy of a plugin id wins.\n- **`local/`** — user-imported declarative plugins: the `local.*` id namespace,\n  the import gate, storage, the read-only `LocalPluginSource` and the inspect\n  summary the popup shows. See \"Write your own plugin locally\" below.\n- **`remote/`** — the per-host remote catalog channel: a read-only source over a\n  storage cache, plus a background-only fetcher. See below.\n- **`catalog/`** — the bundled official data: one directory per site, each\n  holding that site's `site.json` and its declarative plugins. This\n  keeps official CSS/JSON changes in the same PR, CI run, and release as engine\n  or popup changes.\n\n```\ncatalog/\n  marketplace.json                        list of public catalog plugins (tests, plugin:new)\n  sites/index.ts                          import.meta.glob discovery\n  sites/<site>/site.json                  the site adapter, as data\n  sites/<site>/plugins/<id>/plugin.json   a declarative plugin\n  sites/<site>/plugins/<id>/style.css     its styles\n  sites/<site>/plugins/<id>/README.md     what it fixes and why\n```\n\n`catalog/sites/index.ts` finds every `site.json` and `plugin.json` with\n`import.meta.glob`, so adding a site or a plugin is adding files: there is no\nmapping table. (`scripts/build-plugin-catalog.ts` reads the same tree from disk,\nbecause `import.meta.glob` does not exist under Bun.) Sites today are\n`aistudio`, `chatgpt`, `claude`, `deepseek` and `gemini`. A plugin's `matches`\nmust stay inside its site's `matches` (plan D18) or the build fails, and\n`bun run catalog:build` validates every site and plugin before publishing.\nGemini and AI Studio are validated like the rest but never published: the host\npolicy (`allowsRemoteCatalog`) keeps their four hosts out of the per-host\ncatalog, and their plugins stay out of `marketplace.json`.\n\n`catalog/marketplace.json` is **not** that mapping table, and no page reads it:\nthe site's plugin table is built from the manifests themselves\n(`docs/.vitepress/pluginsPage.ts`, which lists the Gemini and AI Studio\npackaged plugins too). It stays as the list of public catalog plugins that\n`catalog/sites/index.test.ts` keeps in sync with discovery and `plugin:new`\nappends to, so a new plugin still needs an entry whose `source` is the\ncatalog-relative path (`sites/deepseek/plugins/reading-width/plugin.json`).\nPlugins under `sites/gemini/` and `sites/aistudio/` are not listed.\n\n## Authoring a declarative plugin\n\n```jsonc\n{\n  \"id\": \"vendor.my-plugin\",\n  \"name\": \"My Plugin\",\n  \"version\": \"1.0.0\",\n  \"description\": \"...\",\n  \"author\": \"...\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://claude.ai/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"file\": \"style.css\" }],\n    \"domOps\": [{ \"op\": \"addClass\", \"target\": \"body\", \"className\": \"gv-plugin-readable\" }],\n  },\n}\n```\n\nAn official plugin is authored as files under its platform's directory:\n\n1. Pick the site it belongs to. If that site has no `catalog/sites/<site>/`\n   directory yet, write its `site.json` first (`id` equal to the directory name,\n   `label`, `matches`, `selectors` keyed by the semantic vocabulary, `theme`,\n   `brandColor`, `capabilities`, optional `conversationIdPattern` and\n   `turnKeyAttributes`). Nothing registers it: the registry picks it up from the\n   file. `turnKeyAttributes` names the attributes that keep a user message's\n   identity across remounts; send tracking (`sends/trackUserSends.ts`, behind\n   folder Activity and catalog timeline timestamps) runs only on a site that\n   names them, its `composer` and `userTurn`, and a `conversationIdPattern`.\n2. Create `catalog/sites/<site>/plugins/<id>/` with `plugin.json`, `style.css`\n   and a short `README.md`.\n3. Keep the plugin's `matches` inside the site's `matches` (D18).\n4. Add the entry to `catalog/marketplace.json` (`plugin:new` does it for you).\n5. Run the suite: discovery, D18 and the marketplace index are all asserted in\n   `catalog/sites/index.test.ts`.\n\nManifests may keep tiny CSS inline with `{ \"css\": \"...\" }`, but the preferred\nauthoring shape is `{ \"file\": \"style.css\" }` next to `plugin.json`. The bundled\nsource and the published catalog file both resolve that CSS to inline text,\nreject anything that loads, from the page's own origin too (`@import`,\n`image-set()`, `image()`, `cross-fade()`, `src()`, a `url()` other than a\nraster-image `data:` URL or `#fragment`, or any string that starts with an\nexternal URL), and normalize\nit before the runtime sees it. A `data:` URL in `url()` must declare a raster\ntype (`image/png`, `jpeg`, `gif`, `webp`, `avif`, `bmp` or an icon type);\n`data:image/svg+xml` is refused in every encoding, because an SVG used as a\nfilter, mask or clip-path resource can load images of its own. For\nuser settings, `{{settingKey}}` tokens can be used in CSS text or in\n`setAttribute` / `setStyle` DOM op values; a common pattern is for CSS files to\nuse a normal custom property and for a `setStyle` op to set that variable from a\nsetting.\n\nSelect settings render their declared value/label options in the popup. Localize\neach option under `i18n.<locale>.settings.<key>.options.<value>` (engine 1.6.0+).\nA select needs at least one option, unique string values (`\"\"` included) and a\ndefault among them; a stored choice the manifest no longer offers resolves to\nthat default in both the popup and the host (`runtime/resolvePluginSettings.ts`).\n\nSetting fields may declare `enabledBy: \"<booleanKey>\"` to depend on another\nboolean setting in the same plugin. The popup keeps number, select and boolean\ncontrols visible with their values, but disables them unless the controlling\nsetting's resolved value is `true`; stored values remain intact. The key must\nbe non-empty and name another boolean field without its own `enabledBy`\n(one level only). This affects popup controls; page behavior keeps its own\nboolean gate.\nEngine 1.7.0 ignores `enabledBy` and leaves the control enabled, so this field\nrequires no higher engine floor.\n\n`target` is a CSS selector string, or `{ \"kind\": \"semantic\", \"key\": \"userTurn\" }`\nto use the site adapter's stable selector for one of the eleven semantic keys in\n`sites/semanticKeys.ts`; a `site.json` may not invent a key outside that\nvocabulary. Supported ops: `addClass`,\n`setAttribute`, `setStyle`, `hide`, plus `native` (below). All are reversible.\nInline styles are layered per longhand property: a `setStyle` value or a whole\n`style` attribute is expanded into longhands (a trailing `!important` is kept as\npriority), so turning one plugin off restores only the longhands it wrote and never\nanother live plugin's value. A `var()` shorthand written with `setStyle` stays whole.\nClasses must be `gv-` prefixed (content-script rule). `setAttribute` names are an\nexact-match allowlist: `data-*`, `aria-*`, `title`, `role`, `lang`, `dir`,\n`hidden`, `tabindex`, `draggable`, `spellcheck`, `translate` and `style`; URL,\n`on*`, `class` and `id` attributes are refused because a selector can reach any\npage element. `setStyle` values and `style` attributes follow the CSS rule\nabove, and attribute values may not contain an external URL. All of this is\nchecked again after `{{setting}}` substitution (`manifest/sinkGuards.ts`).\n\n## Write your own plugin locally (`local/`)\n\nA user can run a declarative plugin they wrote without publishing it:\n\n1. Write `plugin.json` exactly as above, plus its `style.css` (or inline the CSS\n   as `{ \"css\": \"...\" }`). Any lowercase reverse-dotted `id` works; it is stored\n   as `local.<id>`.\n2. Open the popup and find **Local plugins**: on Claude, ChatGPT or DeepSeek it\n   sits on the plugin page; on Gemini and AI Studio it is a settings card\n   (collapsed until opened, or until a local plugin targets the page, which\n   then also shows its toggle and settings). By default it follows cloud sync\n   and the eight plugin topic cards and comes before folders through general\n   (`NATIVE_POPUP_SECTION_IDS` in `pages/popup/utils/nativeSiteCapabilities.ts`);\n   users can reorder it. Choose **Import files** (pick\n   the manifest together with its `.css` files) or **Paste JSON** (a manifest\n   with its CSS inlined).\n3. A rejected import lists every problem as `path: message` and leaves the\n   installed version untouched. An accepted one is stored **disabled** and its\n   inspect view shows the sites it matches, the CSS size, every page change,\n   each Voyager primitive it calls with its params, and its settings.\n4. Turn it on from the plugin list like any other plugin; it applies live. To\n   update it, edit the files and import again: a re-import also lands disabled,\n   so the user re-inspects the new version (it may add a `native` op) and turns\n   it back on. A plugin with a `native` op that was running when it was\n   re-imported keeps its mounted version until the page reloads, as official\n   ones do (D7). **Export** downloads the stored manifest with CSS inlined;\n   **Remove** deletes it with its enable state and settings.\n\n**Describe a change in one sentence.** The same panel can write the manifest\nwith any AI the user already uses, while Voyager itself stays offline:\n\n- `local/pluginAuthoringPrompt.ts` turns the sentence and a chosen site (the\n  active tab's when supported) into a self-contained prompt: the request, the\n  plugin contract for that site (see \"One contract, four outputs\" below), and\n  \"reply with exactly one ```json block\".\n- The user copies the prompt and sends it themselves. Voyager never sends it,\n  opens or deep-links an AI page, reads one back, or makes a request.\n- `local/pluginReply.ts` takes the pasted reply (bounded, linear fence scan;\n  one ```json or unlabeled block, or bare JSON; zero or several blocks are\n  refused by name) through `readLocalPluginFiles` and the gate without\n  installing it. The popup closes while the user is in their AI tab, so this\n  step works on its own in a fresh popup.\n- `local/pluginPreview.ts` describes the result in plain terms (sites, each\n  change, warnings for hidden content via `hide`, `hidden` or a hiding inline\n  style, raw selectors, a replaced version, a\n  reply that misses the selected site (the active tab's, or the user's pick;\n  recomputed when the picker changes, no prompt needed), and a `theme` the\n  prompt asked the AI to leave out; a primitive is listed as a change, not\n  flagged, because the prompt offers it where the gate accepts it). CSS is not summarized: its full\n  source is shown with a plain warning that it can change anything, hiding\n  included, since reading its effect would be a guess. It builds on `inspectPlugin`, and\n  the same inspect view opens under it on demand. **Import** hands the exact previewed\n  object to `importLocalPlugin`, so it lands disabled and follows the re-import\n  rules above, but only over the install the preview saw: if the same id was\n  installed, changed or removed meanwhile, nothing is written and the preview\n  is refreshed for a new review.\n\n**One contract, four outputs.** `local/authoring/pluginContract.ts`\n(`buildPluginContract(site, { audience })`) is the single description of what a\nlocal plugin may contain: manifest fields, styles, page changes, settings, the\nlocal rules, the site's anchors and notes, the primitives (read only through\n`primitiveReference.ts`), what is not possible, and examples. Four outputs\nrender it:\n\n- the popup prompt above (`audience: 'chat'`: inline CSS, no `theme`, primitives\n  where the site allows them, plus a pointer to the public skill for an AI that\n  can browse; the pasted contract wins where the two differ);\n- the public agent skill `docs/public/skills/write-voyager-plugin/SKILL.md`\n  (`authoringDocs.ts`), committed and served at\n  `https://voyager.nagi.fun/skills/write-voyager-plugin/SKILL.md`. Regenerate it\n  with `bun run skills:build`; `authoringDocs.test.ts` fails while it is stale,\n  so a new primitive, a changed `site.json` or a changed local rule shows up\n  there;\n- the `AGENTS.md` that `create-voyager-plugin init` writes next to a new plugin;\n- the examples, which `pluginContract.test.ts` runs through\n  `validateLocalManifest` (file examples through `readLocalPluginFiles`) for\n  every site and both audiences, so no output can teach what the gate refuses.\n\n`packages/create-voyager-plugin/` bundles the same gate and the bundled site\nadapters into one dependency-free Node file (`bun run cli:build`): `init` writes\na passing `plugin.json`, `style.css` and `AGENTS.md`, and `check` runs\n`readLocalPluginFiles` plus `validateLocalManifest` offline, exit 0 or 1. Its\ntests hold it to parity with the in-process gate over a corpus. The package\nversion follows the extension (`scripts/bump-version.js`).\n\nThe gate (`local/validateLocalManifest.ts`) is the remote catalog's gate plus\nlocal-only rules, never a weaker one: `validateManifest` with the CSS and\nrendered-sink guards, `tier: \"declarative\"` only, `native` ops only for primitives\nthis build ships with params that match their contract and an `engine` floor\nat or above their `sinceEngine` (`manifest/primitiveChecks.ts`, shared with\n`plugin:check`), and `matches` inside a plugin platform's `matches` or a native\nsurface's (`sites/nativeSurfaces.ts`: Gemini, AI Studio), so enabling a local\nplugin can only request host access an official plugin could, or none at all.\nA plugin for Gemini or AI Studio may not declare `theme` (those pages keep\nVoyager's own accent). Its `native` ops follow a capability policy per (site,\nhandler) in `sites/hostPolicy.ts`, derived from the owner table\n`sites/featureOwners.ts`: only primitives explicitly listed in a plugin-owned\nentry's `handlers` open on that site. Fixed-id builtins can own a feature with\nno public primitive: Quote, Highlight, prevent auto-scroll, completion\nnotifications and NanoBanana’s two controls are such builtins. Their owner flips\nkeep other native ops closed. A multi-site manifest must be legal on each site\nits patterns reach. Import, paste and the CLI enforce this policy; re-reading stored plugins\n(`LocalPluginSource`, the popup list, review-and-enable) passes\n`hostPolicy: 'defer'`, which skips only this site policy, so a build that\ncloses a primitive again keeps the user's plugin and reports it\n`blocked: host-policy`.\n`local/localPluginRules.ts` adds three rules that turn a plugin which would\nimport and then do nothing into an actionable error: `addClass` adds one\n`gv-` prefixed class; every semantic key the plugin relies on (op targets,\n`requires.semantic`, a primitive's keys and the keys its `semanticKey` params\nname) is declared by every site its\n`matches` target, or the popup would only show `needs-semantic`; and settings\nare `boolean`, `number` or `select`, the kinds the popup renders a control for.\nStored records are re-validated on every read by\n`local/LocalPluginSource.ts`, and at runtime `PluginHost` and the brand theme\nrefuse a theme or native op by the page's real host\n(`sites/hostPolicy.ts`), whatever the patterns say. By design, a local\nplugin's own CSS can still restyle anything on Gemini, Voyager's UI and its\n`--gv-*` variables included; only `theme` and the closed native ops are refused.\n\n**Gemini and AI Studio.** The packaged official plugins\n(`catalog/sites/{gemini,aistudio}/` and fixed-id entries in `builtin/`) and local\nplugins target them; neither goes through the remote catalog, which never\nserves these hosts and never publishes their manifests. The official ones replace native modules feature by\nfeature. Stage 7 retires migrated native startup callbacks and registrations; the content script\nadmits each official plugin where its native module used to start, and the\nplugin follows the native setting it replaces (`storage/legacySettingBindings.ts`).\nIts row in a popup topic card writes those legacy-bound keys. The owner table has no `legacy` option: rolling a feature back to its\nnative module means reverting both stage 7 retirement commits (startup wiring 9c2161df and\nnative wrappers 0ae260c1) and the commit that removed that option. Permanent page services remain in `nativeFeatures.ts`; their\naccount bridge, history/message caches, selection host, sent-quote styling,\nprevent-auto-scroll bridge, fork first-send finisher and default-model stale-menu cleanup stay\nindependent of optional plugin switches. The last service only removes old menu UI while the sync\nsetting is false; the plugin alone selects models and creates new stars.\nThe page send-key coordinator is the one service started elsewhere:\n`startPageSendKeys` in `pages/content/inputStartup.ts` creates it before `PluginHost`.\nThey resolve semantic keys through the native\n`geminiAdapter` / `aistudioAdapter`, which expose the keys their packaged\n`site.json` declares (the Gemini `sidebar` key also covers Voyager's folder\npanel, so prefer a precise CSS selector there). The manifest already injects the content\nscript and grants these hosts, so enabling one needs no permission prompt and\n`pluginsToOriginPatterns` leaves their origins out of dynamic registration\n(registering them would inject Voyager twice). `PluginHost` mounts them with\nthe same reversible engine as elsewhere, and turning one off removes its\nclasses and stylesheet. AI Studio's master switch (`GV_AISTUDIO_ENABLED`,\n`runtime/surfaceSwitch.ts`) governs plugins too: while Voyager is off on AI\nStudio ordinary plugins do not mount, and flipping it unmounts or remounts them live. Gemini\nhas no page-wide master switch. Its **Show Timeline** switch controls only the\nrail and navigation shortcut lease; saved stars and hierarchy remain, and the\nmessage timestamp labels plugin follows **Show Message Timestamps** independently;\nhistory and turn identity remain page services.\n\nThe packaged owners in [`sites/featureOwners.ts`](sites/featureOwners.ts) and\nthe builtins in [`builtin/index.ts`](builtin/index.ts) cover:\n\n- Gemini: Chat width, edit input width, font size, line height and paragraph spacing, sidebar width,\n  sidebar auto-hide, sidebar full hide, Gems sidebar count, hide input halo; formula copy, Canvas\n  copy, Deep Research export, user LaTeX, code collapse, Mermaid, WaveDrom, ECharts, Markdown fix,\n  watermark download and preview; Vim input, prompt history, input collapse, Ctrl+Enter send, Safari\n  Enter fix, draft save, quote reply; timeline, message timestamps, highlights, prevent auto-scroll,\n  response notifications, batch delete, research pack, default model, fork, usage status, context\n  sync. The Gems sidebar row is greyed out in the popup: its registry entry `requires` an\n  unmet condition with the `gemsSidebarUnavailableHint` note, because Gemini moved Gems into its\n  More menu. The plugin and its code are kept.\n- AI Studio: Formula copy, Enter send, sidebar width, batch delete.\n- ChatGPT: Formula copy, Vim input, timeline, conversation export, temporary chat handoff, folders,\n  response notifications.\n- Claude: Formula copy, Vim input, timeline.\n- Shared: Visual effects on already-injected pages, including authorized Prompt Manager custom\n  sites.\n\nOther sites' declarative plugins live in `catalog/sites/`. Every packaged Gemini / AI Studio\nofficial plugin has a popup row in the\n[`officialPluginRegistry.ts`](../../pages/popup/officialPlugins/officialPluginRegistry.ts).\nFor the next migration, follow the [plan §7.12 recipe](../../../.github/docs/GEMINI_PLUGIN_PLATFORM_PLAN.md#712-剩余小功能迁移2026-10-16)\nand the [popup design §5](../../../.github/docs/POPUP_PLUGIN_LIST_DESIGN.md#5-以后每项迁移的步骤).\n\n**NanoBanana watermark removal.** Two packaged fixed-ID builtins,\n`voyager.gemini-watermark-download` and `voyager.gemini-watermark-preview`, map\none-to-one to the existing popup switches and are admitted at the former native\nstart point. Both derive enablement through `core/utils/watermarkSettings.ts`:\n`gvWatermarkDownloadEnabled` and `gvWatermarkPreviewEnabled` take precedence if\neither holds a boolean, with an unset sibling retaining the historical `true`\ndefault; otherwise `geminiWatermarkRemoverEnabled` controls both, and no saved\npreference means both are off. The popup writes the same keys; none are copied\ninto `gvPluginsState`. Neither builtin opens a public native primitive or adds\npermissions. The shared image worker and download bridge remain packaged\nservices. Safari keeps its static MAIN-world hooks, asynchronous extension-message\nrouting and authenticated page-fetch fallback; full-size browser downloads keep\nusing that path. There is no dedicated watermark-processing Swift channel.\n\nDisabling preview invalidates pending DOM writes. It restores the original\n`src`/`srcset` pair only while `src` still equals Voyager’s blob URL and `srcset`\nis still absent. If the host changed either attribute, the current pair stays\nintact. A blob URL is revoked only once `src` no longer references it; a host\n`srcset` may still fall back to that blob, which is retained until document unload.\nDisabling download removal stops accepting new removal work immediately;\nalready-accepted downloads finish once with their setting at acceptance, including\nacross an off/on cycle. Static MAIN-world hooks cannot be unregistered: their\nremoval path checks the current setting and stays inert when off. Independent\nread-only watermark health inspection may continue while removal is off.\nThe accepted download is a one-shot promise with one response and the existing\ntimeout fallback, independent of either activation’s lease. It does not gain a\n`startMachine`: no intermediate phase is cancelled by the switch, and aborting\na state scope here would lose the response an accepted request still needs.\n\n**Shared Visual effects.** `voyager.visual-effects` is one packaged builtin on every\nalready-injected page, including authorized Prompt Manager custom sites. It is admitted\nbefore normal initialization and excluded from plugin subframes, as its native predecessor\nwas. It remains independent of AI Studio's master switch. Its `<all_urls>` runtime match\nadds no host permission or content-script registration, and its fixed ID never requests an\norigin grant. `gvVisualEffect` is authoritative across sites: any string takes precedence\n(unknown strings render nothing), otherwise `gvSnowEffect === true` selects snow. The trusted\nenum value binding fills `effect`; nothing is copied into `gvPluginsState`. The popup's\nVisual effects row writes those legacy-bound keys on every site: in the Page layout topic\ncard on Gemini and AI Studio, and in a plugin site's plugin card once Voyager can run there\n(a site enabled for Prompt Manager or a plugin). `legacyEnableWrite` selects snow when enabled and `off` when\ndisabled; both clear the legacy boolean. No previous effect is remembered. PluginScope owns\nthe activation's selection while the page holds renderers through a graceful drain; re-enable\nresumes them, and page teardown destroys canvases, renderer frames and workers immediately.\n\nLocal `turnNavigator` on consumer Gemini requires literal `params: { \"dataBackend\": \"shared\" }`\n(engine >=1.9.0). It reads and edits the same native stars, levels and collapse state, waits for\nboth page-owned timestamp caches, and inherits the official rail’s admission. Its own plugin\nswitch controls the rail independently of **Show Timeline**. Both claim `timeline-rail`: the\nofficial setting wins on a cold page, while a mounted holder keeps the rail until disabled.\nTurn off **Show Timeline** before enabling a local replacement; turn the local plugin off before\nreturning to the official rail. Shared manifests stay within `https://gemini.google.com/*` and\ncannot override turn, route or account identity. Business, Enterprise and custom coverage\ndo not admit this native-data rail. The zero-request promise holds: `isEligibleCatalogHost` refuses every native host,\nso a Gemini page has no catalog host, never asks for a check and never reads a catalog cache, and\nthe background refuses a forced check for them too. A minimal Gemini plugin:\n\n```json\n{\n  \"id\": \"me.gemini-compact-turns\",\n  \"name\": \"Compact Gemini turns\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Tighter spacing between my messages\",\n  \"author\": \"me\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://gemini.google.com/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"css\": \".gv-plugin-compact-turn{margin-block:4px!important}\" }],\n    \"domOps\": [\n      {\n        \"op\": \"addClass\",\n        \"target\": { \"kind\": \"semantic\", \"key\": \"userTurn\" },\n        \"className\": \"gv-plugin-compact-turn\"\n      }\n    ]\n  }\n}\n```\n\nOwnership and precedence:\n\n- **`local.*` is reserved.** Ids are forced into it on import; `plugin:check`\n  rejects an official plugin in it and `mergePluginRecords` drops any builtin,\n  bundled or remote record that claims one. A local plugin can therefore never\n  replace an official one, and its enable state and settings in\n  `storage/pluginState.ts` never share a key with one.\n- **Merged last, outside the kill switch.** The remote catalog says nothing about\n  a user's own plugin, and an enabled local plugin never makes a page eligible\n  for a catalog check (`remote/hostCatalogPolicy.ts`). A kindless or official\n  source cannot serve a `local.*` id either: only `kind: 'local'` does.\n- **Storage.** Manifests live in `chrome.storage.local` under\n  `StorageKeys.PLUGIN_LOCAL_MANIFESTS` (at most 50; per import at most\n  1,000,000 characters read, 32 style entries and 1,000,000 characters of CSS\n  after file expansion, checked before anything is expanded or scanned). An\n  import writes the manifest and its `enabled: false` in one `storage.set`, so\n  no page ever sees the new version enabled. Mutations reject on a failed read\n  (never write back a map rebuilt from nothing), keep entries this build cannot\n  read, and hold the `gv-local-plugins` Web Lock (`storage/pluginStorageLock.ts`),\n  which every plugin-state writer shares, so neither a second popup nor a late\n  setting toggle or Drive restore can write an old `enabled: true` back over a\n  new version. Content scripts cannot share that lock, so they send setting\n  changes to the background (`requestPluginSetting`). They stay on the device:\n  the enable state rides the plugin-state Drive backup, but the manifests\n  themselves do not, and a restore may switch a local plugin off but never on;\n  export them to keep a copy.\n\n## Primitives (`verbs/`) and the `native` op\n\nShipped primitives (`verbs/<name>/contract.ts`): `formulaCopy` (since 1.3.0), `vimInput` (since 1.4.0,\n`composer` param, defaults to the adapter's `composer` selector) and `turnNavigator` (since 1.4.0,\n`turn` / `conversationIdPattern` / `scrollContainer` / `yieldWhen` / `position`, all optional and\ndefaulting to the adapter, plus the optional `conversationIdAttribute` attribute and `turnItem`\nselector since 1.5.0, and `dataBackend: \"shared\"` on consumer Gemini since 1.9.0) and `userLatex` (since 1.7.0, optional `turn` selector defaulting to the\nadapter's `userTurn`, plus an optional `unit` of `leaf` (default) or `turn` since 1.9.0; renders the LaTeX a user typed with Gemini's renderer and restores the text on\nunmount) and the code-block diagram primitives `mermaid`, `echarts` and `wavedrom` (all since\n1.7.0, optional `turn` / `codeBlock` / `code` / `codeLine` / `language` selectors defaulting to\nthe adapter's `assistantTurn` and `codeBlock`). Each is Gemini's renderer core plus the shared\nengine in `verbs/codeBlockDiagram.ts`, which draws a diagram in a shadow-root panel before the\nhost's untouched code block; a label counts as a language only when it is shaped like an id, so a\nhost's localized \"plain text\" label leaves the block to content detection. New diagram kinds follow\nthe PATTERN note at the top of that file, and each site gets one `diagram-rendering` plugin listing\nits diagram ops: ChatGPT's lists `echarts` and `wavedrom`, since ChatGPT draws Mermaid itself, and\n`mermaid` waits for a site that needs it. `codeCollapse` (since 1.8.0, optional\n`code` / `action` selectors, blocks from the adapter's `codeBlock` inside `assistantTurn`\nand outside `thinkingBlock`) is Gemini's long code block collapse on another site: one engine in\n`pages/content/codeBlockCollapse/engine.ts` drives both, Gemini with its blocks expanded until the\nuser collapses them, the primitive with them collapsed plus expand all / collapse all. The formula-copy,\nVim, Claude-timeline and ChatGPT-timeline builtins are now manifests that invoke these primitives;\nthe shared timeline engine lives in `../timeline/TimelineEngine.ts`. Its catalog adapter\n(`../timeline/adapters/catalog/`) accumulates markers across virtualized windows (`turnMerge.ts`)\nand keeps stars and hierarchy in per-site local keys, while stars remain available in Saved Library. Star ids come from the URL at\neach read or write (`conversationId.ts`); a manifest's `conversationIdPattern` wins over the site\nadapter's. A mounted timeline publishes that namespace (`activeStarNamespace.ts`) so ChatGPT\nexport's starred filter reads the same ids. A site with a `conversationIdPattern` cannot star a\nroute that does not match it. Without a site adapter the timeline stays inert: its stars, outline\nand placement are keyed by the site id. A star is written only for a turn proven to be the current conversation's:\nthe host's own id for it where the site names one (`conversationIdAttribute` on an ancestor, as\nClaude's `data-conv-id`, or inside the turn's `turnItem`; a turn without it is unstarrable), else\nthe URL when the turn entered the page (`turnOwnership.ts`).\n\n`tableCopy` (since 1.8.0, no parameters) finds tables inside the adapter's\n`assistantTurn` and puts a shadow-root toolbar before each reply table that copies it as\nMarkdown or TSV. The Markdown is conversation export's own `extractTable`, and TSV reads the same\n`collectTableRows`, so formulas stay LaTeX and copy never drifts from export.\n\n`collapse` (since 1.8.0) limits the visible height of long content and\nplaces one expand/collapse button after each target. Its optional `target` is a semantic key:\n`userTurn` (default), `assistantTurn` or `thinkingBlock`; raw selectors are not accepted.\n`maxPx` defaults to 320 and accepts finite numbers from 80 to 2000; `startCollapsed` defaults\nto `true`. Content at or below `maxPx + 16` pixels gets no toggle. Streaming content gains a\ntoggle when it grows past that threshold, and an expanded element stays expanded while the\nsame host element survives. Labels are localized, buttons work with the keyboard, and styling\nsupports Voyager's light/dark scheme and RTL layout. Unmount restores the host layout.\n\nBecause `target` is configurable, the contract has no fixed semantic requirements. Authors\nmust declare the chosen key in `requires.semantic`, including `userTurn` when omitting\n`target`; a missing adapter selector leaves the primitive inactive. Targeting `assistantTurn`\nclips the complete reply, including any thinking content inside it. Choose `thinkingBlock`\nseparately when only that content should collapse. The primitive has no host-specific checks:\navoid content that already has native collapse, such as DeepSeek's `.ds-collapsible-text`.\nThere is no expand-all control. A plugin can expose `maxPx` and `startCollapsed` as settings\nusing `\"params\": { \"target\": \"userTurn\", \"maxPx\": \"{{maxHeight}}\", \"startCollapsed\": \"{{startCollapsed}}\" }`.\n\n`sendShortcut` claims one composer send mode per page on every site: a second plugin\nreports a conflict while the current holder keeps running. The optional `mode`\n(since 1.9.0) is `ctrlEnterSends` (default) or `enterSends`. Gemini only accepts\nthe default, AI Studio only the latter, via the packaged page coordinator; Gemini\nEnterprise accepts neither. Composition, Process and keyCode 229 are left to the IME.\nLocal send plugins are open on Gemini and AI Studio. Disable the corresponding\nofficial send switch to hand the page's mode to a local plugin; the Safari fix\nremains paired with Gemini's packaged send owner.\n\n`turnNavigator` likewise claims `timeline-rail` on every site, by design: a second rail on\nChatGPT, Claude or DeepSeek is paused as a conflict while the mounted one keeps running.\n\n`draftSave` (since 1.8.0, no parameters; reads `composer` and\n`sendButton`, and stays inert without `conversationIdPattern`) is Gemini's draft auto-save on a\ncatalog site. It shares `pages/content/draftSave/draftStore.ts` (debounce, empty means remove,\nfive newest per prefix) and the composer reader/writer in `core/services/composer/`. Drafts are\nstored in `chrome.storage.local` as `gvDraft:<siteId>:conv:<id>`, or `gvDraft:<siteId>:new` for\nevery page without a conversation id, so they never meet Gemini's `gvDraft_<path>` entries.\nA draft is restored once per conversation entry and only into an empty composer; a restore\nstill loading is abandoned once the person types (`core/utils/userEvent.ts` `isUserEvent`, so a\nhost filling its composer while mounting does not count). A draft is dropped when the composer\nempties within two seconds of Enter, the send button or a form submit, or when that send moves\nthe new-chat page to its conversation; switching from one conversation to another never counts\nas a send. While a `sendShortcut` plugin holds the composer's Enter\n(`verbs/composerEnterOwner.ts`), Enter is never read as a send: only its button click is. ChatGPT temporary chats save\nnothing. These sites have no account index in their routes, so drafts are per site and\nconversation, not per account. Unmount stops everything and keeps stored drafts.\n\nOn Gemini, the same primitive selects the trusted Gemini policy and shares native\n`gvDraft_<full pathname>` records, including `/u/N` and Gem routes. Official and\nlocal draft plugins can run together; writes and five-record pruning are queued\non this page. A confirmed first send clears its source new-chat draft, while\nnavigation without a send keeps it. Restore checks route, account and input edits\nafter asynchronous waits, and never restores into an old-question edit input.\nUnload makes a best-effort final save; asynchronous storage cannot be guaranteed\nafter browser closure. Changing email within one account slot still shares stored\ndrafts. Gemini Enterprise stays inactive. Drafts and their key format survive\nany rollback of the plugin owner.\n\n`quoteReply` (since 1.8.0, no parameters; reads `assistantTurn` and\n`composer`) is Gemini's quote reply on a catalog site. Selecting text wholly inside one\nassistant turn shows a localized Quote button near the selection end; activating it appends the\ntext to the composer as a Markdown quote (every line prefixed `> `, a blank line after an\nexisting draft) and never sends. Selections touching a user turn or the adapter's\n`thinkingBlock` show nothing. Rendered KaTeX is quoted as its LaTeX source through\n`features/formulaCopy/formulaSource.ts`; line prefixing and Gemini math replacement are shared\nwith Gemini's quote reply in `core/utils/quotedText.ts`. A missing composer shows a toast and\nkeeps the selection. Unmount removes the button, its style and every listener synchronously.\n\n`copyButton` (since 1.8.0; required `target` of `userTurn`, `assistantTurn`, `thinkingBlock`\nor `codeBlock`; optional `format`, which accepts only `text` today) places a small copy button above each\nmatched element. It copies the element's visible text without Voyager's own controls and\nconfirms with a toast; the element itself is left untouched.\n\n`counterBadge` (since 1.8.0; `target` is `composer` or `assistantTurn`,\n`metric` is `chars` (default), `words`, `cjkChars` or `lines`) shows a small read-only count.\nFor `composer` it sits above the adapter's `composerRoot` (or the composer's parent), reads the\ndraft with `readComposer` and hides while the draft is empty; for `assistantTurn` each reply\ngets a badge at its end, recounted from `copyableText` once streaming settles. The label is CSS\ngenerated content, so copying, export and the counts themselves never include it. Shipped\nplugins use only the composer target.\n\nSome behaviour cannot be expressed as CSS or as the reversible DOM operations.\nA **primitive** is that behaviour, written once as first-party TypeScript inside\nthe extension and given a name a manifest can call:\n\n```jsonc\n{\n  \"engine\": \">=1.3.0\",\n  \"requires\": { \"handlers\": [\"formulaCopy\"] },\n  \"contributes\": {\n    \"domOps\": [{ \"op\": \"native\", \"handler\": \"formulaCopy\", \"params\": {} }],\n  },\n}\n```\n\nThe manifest picks a primitive and configures it. It never supplies logic: no\nconditions, no ordering, no code. `params` is configuration, checked by that\nprimitive's own hand-written guard before the primitive sees it (plan C1, D17).\nThat is what keeps a remote manifest data rather than remotely-hosted code, and\nit is why `native` is safe on the same channel as CSS.\n\nNative params may use `{{settingKey}}` in string values, recursively inside\nobjects and arrays. Parameter names and object keys stay literal. A whole-value\ntoken preserves the setting's number, boolean or string type; embedded tokens\nproduce a string. For example, `turnNavigator` can receive\n`\"params\": { \"position\": \"{{side}}\" }` from a select setting with `left` and\n`right` options. Expansion runs once, so tokens inside setting values remain\nliteral. Every referenced key must exist in `contributes.settings`, and the\nsetting type must fit the primitive contract wherever statically knowable.\nManifest loading checks substituted defaults. Each activation substitutes\nresolved stored values and runs the primitive's `validateParams`; an invalid\nresult skips the op through the same diagnostic path as invalid static params.\nSettings changes serialize disposal and reactivation for templated primitive\nops; static params keep the existing in-place update path.\n\nParams substitution requires engine 1.8.0, alongside any higher primitive or\nparameter floor. It has shipped, so plugins that rely only on it declare a concrete\n`\">=1.8.0\"`; `\">=next\"` is for a primitive or parameter that is still unreleased.\nThe capability floor remains 1.8.0 in later releases. Engine 1.7.0 passes tokens\nliterally to primitive guards, so a selector token can be accepted yet match\nnothing; enforcing the floor makes an older build report `needs-engine`.\n\nEach primitive is one folder, `verbs/<name>/`, and adding the folder is all\nthe registration there is:\n\n- **`contract.ts`** — data only: `export const contract` with the primitive's\n  `name` (the folder name), `sinceEngine`, the semantic keys it reads from the\n  adapter, and its parameter spec (`type`, `required`, `description`; a\n  param that names a semantic key the primitive reads is `type: 'semanticKey'`,\n  optionally narrowed by `allowedKeys`). It imports only types and data modules\n  (`contractTypes.ts`, `codeBlockDiagramParams.ts`), so Bun scripts and the CLI\n  can load it without DOM or implementations; `primitiveFolders.test.ts`\n  enforces this.\n- **`index.ts`** — the implementation: a primitive object carrying that\n  contract, a `validateParams` guard, and `activate(scope, params, context)`.\n  Every side effect is registered on the `PluginScope`, so unmount pays them\n  all back, and the primitive reports how many elements it acts on through\n  `context.setTargetCounter`.\n- **`paramsBaseline.json`** — the published params (D9, below).\n\n`verbs/contracts.ts` collects the contracts, sorted by name, through\n`import.meta.glob`; Bun has no glob, so Bun scripts and the CLI build feed the\nsame files from disk (`scripts/lib/primitiveContractsSeam.ts`). `verbs/registry.ts`\nfinds the implementations the same way, and `verifyPrimitiveRegistry()` names\nany folder whose contract and implementation disagree. The primitive enums in\n`docs/public/plugin.schema.json` are generated: run `bun run skills:build`\nafter adding a primitive (it also refreshes the public skill).\n\n`PLUGIN_ENGINE_VERSION` records the released engine version and is never\nhand-bumped by a contributor. A new primitive or optional parameter declares\n`sinceEngine: 'next'`; its dependent catalog plugin uses `\"engine\": \">=next\"`.\nThe shared resolver maps `next` to the released engine plus one minor, patch\nzero (1.8.0 → 1.9.0). While any contract or param is `next`, this tree runs that\neffective engine version, so its bundled unreleased plugins are usable.\n\nA manifest that uses a primitive must set an `engine` range whose **minimum** is\nat least its resolved `sinceEngine`, and `bun run catalog:build` fails the build\notherwise (plan §5, §8). Contributor checks, CLI checks and bundled loading\nresolve the same notation. Catalog publishing writes only concrete versions,\nso a released extension filters an update requiring the next engine without\nmistaking it for a remote kill switch. Local imports require concrete versions;\nthe public authoring skill and CLI scaffold document resolved primitive floors.\n\n`bun run bump` stamps all unpublished contracts, param baselines and dependent\ncatalog manifest ranges, and advances the engine only when `next` exists. A\nrelease gate rejects any remaining `next`; extension-version bumps without\nunpublished engine additions leave the engine version unchanged.\n\nThe primitive engine floor makes an older Voyager report `needs-engine`\n(\"update Voyager\") instead of `needs-handler`, which means a configuration\nmistake. `catalog:build` also rejects a handler\nwith no contract, and a semantic key the plugin's own site does not define.\n\nA param added to a shipped primitive carries its own `sinceEngine` (D9: params\nare only ever added). An older engine rejects a param it does not know and skips\nthe whole op, so a manifest that sets the param needs an `engine` floor at least\nthat high; `plugin:check` and `catalog:build` enforce it alongside the primitive\nfloor, and `builtin.test.ts` applies the same check to the builtins.\n\n`requires` states the same needs declaratively:\n\n- `requires.handlers` lists the primitives the plugin invokes. Every `native` op\n  is implied, so listing it is documentation, not duplication.\n- `requires.semantic` lists semantic keys the plugin depends on beyond the ones\n  its ops target. A primitive's own `semantic` keys come from its contract, and\n  the key a `semanticKey` param names (every option, when the param is a select\n  setting's `{{key}}` token) comes from the op's params.\n\n`runtime/pluginStatus.ts` derives both sets (`requiredHandlers`,\n`requiredSemanticKeys`) and turns them into a status, which `PluginHost` reports\nfor every plugin that targets the page instead of quietly filtering it:\n\n| Kind             | Meaning                                 | Popup                                         |\n| ---------------- | --------------------------------------- | --------------------------------------------- |\n| `needs-engine`   | `engine` range above this build         | toggle disabled, \"needs Voyager ≥ x.y\"        |\n| `needs-handler`  | a primitive or runtime it lacks         | toggle disabled, \"update Voyager\"             |\n| `needs-semantic` | the site adapter lacks a key it needs   | toggle disabled, names the site               |\n| `ready`          | compatible, not mounted yet             | normal toggle                                 |\n| `mounted`        | enabled and running on this page        | normal toggle                                 |\n| `no-effect`      | mounted but found nothing (D12)         | toggle stays on, yellow warning               |\n| `blocked`        | compatible, kept off by `blockedReason` | toggle keeps the user's choice, note says why |\n\n`blockedReason` (with `blockedBy` naming the other plugin where there is one):\n\n| Reason            | Meaning                                                                                       | Clears                              |\n| ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------- |\n| `host-policy`     | this build refuses its `theme` or a `native` op on this site                                  | in a build that allows it           |\n| `duplicate-claim` | the manifest starts one page-wide runtime twice                                               | when the manifest is fixed          |\n| `conflict`        | `blockedBy` holds or reserves a page-wide runtime it needs                                    | when the user turns one of them off |\n| `releasing`       | next in line, waiting for `blockedBy` to finish shutting the runtime down                     | by itself                           |\n| `pending`         | `blockedBy","isInternal":false,"tokens":11635,"sizeBytes":50152}],"systemPromptSnippet":"<yakaai_skills repo=\"Nagi-ovo/voyager\">\n<!-- File: .agents/skills/issue-review/agents/openai.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Agents Skill\n\nPath: `.agents/skills/issue-review/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/issue-review/agents/openai.yaml)\n\n<!-- File: .agents/skills/issue-review/SKILL.md (Tokens: ~243 | Category: anthropic-skill) -->\n---\nname: issue-review\ndescription: Investigate or fix a referenced Voyager GitHub issue, within the requested delivery scope.\n---\n\n# Issue Review\n\n1. Begin with `gh issue view <number-or-url> --comments` (add `--repo owner/repo` when needed). Read the actual issue before locating the cause in the repository.\n2. Match completion to the request: investigation delivers evidence, root cause or remaining uncertainty, and a recommended fix; implementation delivers the scoped change and applicable verification from `AGENTS.md`.\n3. When the request includes committing or publishing, use `voyager-contribute`. A fix commit or PR includes `Closes #<number>` or `Fixes #<number>`; verify any created commit with `git show --stat --format=fuller HEAD`. An investigation or uncommitted fix needs no commit inspection.\n4. When authorized to post, report the actual status in the reporter's language. Say the fix has landed and is coming in the next version only after it has landed. Close the issue only when the fix has landed or another agreed resolution is satisfied and closing is authorized; a local fix or an open PR alone is not completion of the issue.\n\n<!-- File: .agents/skills/release/agents/openai.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Agents Skill\n\nPath: `.agents/skills/release/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/agents/openai.yaml)\n\n<!-- File: .agents/skills/release/references/changelog.md (Tokens: ~300 | Category: anthropic-skill) -->\n# References Skill\n\nPath: `.agents/skills/release/references/changelog.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/changelog.md)\n\n<!-- File: .agents/skills/release/references/release-body.md (Tokens: ~300 | Category: anthropic-skill) -->\n# References Skill\n\nPath: `.agents/skills/release/references/release-body.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/release-body.md)\n\n<!-- File: .agents/skills/release/references/safari-dmg.md (Tokens: ~300 | Category: anthropic-skill) -->\n# References Skill\n\nPath: `.agents/skills/release/references/safari-dmg.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/release/references/safari-dmg.md)\n\n<!-- File: .agents/skills/release/SKILL.md (Tokens: ~608 | Category: anthropic-skill) -->\n---\nname: release\ndescription: Prepare, publish, or recover a Voyager release. Use for version bumps and releases, failed store submissions, or Safari release failures.\nmetadata:\n  version: '1.5.0'\n---\n\n# Voyager Release\n\nUse repository scripts and `.github/workflows/release.yml` as the source of truth. Normal releases use CI: a release tag builds the browser artifacts, signs and notarizes Safari, creates the GitHub Release, and submits the stores.\n\n## Choose the requested workflow\n\nRead only the reference needed for the current task:\n\n- New release, version bump, or interrupted release preparation: [normal-release.md](references/normal-release.md).\n- Failed Chrome or Edge submission, or an urgent Firefox-only hotfix: [store-recovery.md](references/store-recovery.md).\n- Safari CI failure or an explicitly requested local recovery artifact: [safari-dmg.md](references/safari-dmg.md).\n\n## Authorization and publication boundaries\n\nThe user's request and existing approval define the scope; loading this skill grants no additional permission. Complete authorized preparation and verification, and reuse approval for the same version, destination, and action. A diagnosis or preparation request does not by itself authorize publication.\n\nBefore a tag push, store submission, or replacement upload, verify that the concrete action is authorized. Ask only when that authorization is missing or the version, scope, or a possible release blocker needs a user decision; prepare the reviewable result first. Explain any newly discovered change to the approved release.\n\n- A tag push triggers the public GitHub Release, Safari signing/notarization and Sparkle feed, Firefox AMO submission, Chrome Web Store publication, and Edge Add-ons submission.\n- Moving a published tag requires explicit authorization for that operation and is allowed only while no release artifacts have been produced. Otherwise recover the affected store or prepare a new version; never silently retag.\n- Preserve unrelated changes and stage release paths explicitly. Never bypass hooks with `--no-verify`.\n- Keep Safari compatibility bundle IDs and signing requirements intact. Do not run a second local Safari release or upload a second DMG while CI is producing the release.\n- Failed required checks block tagging and publication. Fix failures caused by the authorized work and rerun affected checks; ask only when resolving a blocker needs new scope or a risk decision. An accepted unresolved failure remains reported as failed and never bypasses artifact signing, notarization, or privacy requirements.\n\n## Completion\n\nFor preparation, report the version, scoped changes, verification results, and remaining publication step. For diagnosis, report the failing stage, evidence, and the concrete recovery path. Do not advance either request into publication automatically.\n\nFor publication or recovery, finish the applicable workflow and verify its actual result: GitHub assets/body, Safari notarization/privacy evidence and signed Sparkle feed, and the requested stores' signing/submission outcomes. Distinguish submission success from store availability, and report pending review or failures. A successful GitHub Release alone does not prove every store or Safari succeeded.\n\n<!-- File: .agents/skills/update-safari-extension/agents/openai.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Agents Skill\n\nPath: `.agents/skills/update-safari-extension/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/update-safari-extension/agents/openai.yaml)\n\n<!-- File: .agents/skills/update-safari-extension/SKILL.md (Tokens: ~958 | Category: anthropic-skill) -->\n---\nname: update-safari-extension\ndescription: Build, reload, and verify Voyager in Safari for web-extension changes, native integration, or signed-app testing while preserving installed apps and extension data.\n---\n\n# Update Safari Extension\n\nUse the least invasive route that can test the change. A successful build and a successful live Safari check are separate evidence.\n\n## Choose the runtime route\n\nCheck `git status --short --branch -uall` and inspect Safari Settings > Developer/Extensions. Record whether Voyager is temporary or app-installed and enabled before changing it.\n\n- **Web extension:** TypeScript, React, CSS, manifest, or bundled assets that stay inside the extension use the temporary route below.\n- **Native or signed app:** Swift, entitlements, Sparkle, signing, packaging, or any call to `browser.runtime.sendNativeMessage` require [native-app.md](references/native-app.md). Temporary extensions cannot launch the containing-app handler, including iCloud sync, Safari Google Drive authorization, and native notifications.\n- **Native behavior:** For Drive authorization, notification delivery/clicks, or account-scoped sync verification, also read [native-behavior.md](references/native-behavior.md).\n\n## Default: temporary extension\n\n1. Run `bun run build:safari`. This builds `dist_safari` and verifies Safari resource wiring.\n2. In Safari Settings > Developer > Temporary Extensions, click Voyager's **Reload**. If absent, choose **Add Temporary Extension…** and select `dist_safari`.\n3. Reload the target Gemini, AI Studio, ChatGPT, or Claude tab once to load the current content script.\n4. Open the Voyager popup and test the changed behavior.\n\nSafari removes temporary extensions after 24 hours or when Safari quits. Re-add `dist_safari`; a routine UI refresh does not require signed installation.\n\n## Safety and recovery\n\n- Preserve `/Applications/Voyager.app` and the legacy `/Applications/Gemini Voyager.app`. Replacing an installed app or changing distribution signing/provisioning requires an explicit signed-app test request. Local development profile refresh is covered by the native route.\n- Preserve Safari extension containers, preferences, storage, permissions, bundle identifiers, and versions; changing them is not a stale-build remedy.\n- Use Safari's **Reload** for normal refreshes. If it looks stale, verify the built asset, reload the target tab, and reopen the popup. For launch/URL routing failures or duplicate entries, use the read-only diagnosis in [native-app.md](references/native-app.md).\n- Registration changes (`pluginkit -r`/`-a` or manual unregister/register) are targeted recovery only, with Safari closed, an exact stale/current path pair, and a recovery plan. Reuse explicit user authorization that covers this recovery; otherwise complete read-only diagnosis and prepare the plan before asking. A generic reload request does not authorize it.\n- Never bulk-reset LaunchServices or unregister/remove an app in `/Applications` without explicit permission. Avoid launching apps from Archives, backups, disk images, or old DerivedData; duplicate bundle identifiers can route Safari or custom URLs to the wrong copy.\n- Keep signing identities, provisioning-profile contents, Keychain data, Apple account details, notarization credentials, and CI secret values out of output and commits. Refer only to repository-documented environment-variable names. Keep generated apps, archives, DerivedData, and signed artifacts out of commits unless explicitly tracked.\n- If a feature disappears, stop the update attempt, preserve app and extension data, return to the previously working extension route, and verify the feature.\n\n## Evidence and completion\n\nReport the highest tier actually proven:\n\n1. **Build:** `bun run build:safari` passes.\n2. **Artifact:** expected JS/CSS/resources exist in `dist_safari` and the Xcode bundle wiring check passes.\n3. **Loaded:** Safari shows the intended temporary or containing-app extension enabled once, without an unexpected duplicate.\n4. **Live behavior:** the popup/page visibly shows the changed UI or behavior after reloading the target tab.\n5. **State safety:** pre-existing cards/settings remain visible. A missing card is not evidence of successful loading.\n\nInspect the reloaded popup for popup changes; reload the website for content-script changes; close and reopen the popup for background/native changes. Claim the latest build is loaded only after checking live behavior or a build-specific visible marker in Safari. A marker alone does not prove the changed behavior; report any unverified behavior or state safety.\n\nA standalone popup tab has no source-tab account scope. For account-scoped sync checks or a toolbar popover dismissed before automation reaches its button, follow the testing boundaries in [native-behavior.md](references/native-behavior.md).\n\n<!-- File: .agents/skills/voyager-contribute/agents/openai.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Agents Skill\n\nPath: `.agents/skills/voyager-contribute/agents/openai.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/.agents/skills/voyager-contribute/agents/openai.yaml)\n\n<!-- File: .agents/skills/voyager-contribute/references/browser-testing.md (Tokens: ~954 | Category: anthropic-skill) -->\n# Browser verification\n\nRead this reference only when a change affects extension runtime, UI, manifests, permissions, packaging, native integration, or plugins.\n\n## Evidence\n\nReport only the highest level actually completed:\n\n- **Build**: the command succeeds and the expected artifact exists.\n- **Loaded**: that artifact is enabled once in the browser with no manifest or load error.\n- **Live**: after reloading the extension and target tab, the changed workflow works and existing state remains intact. The `dist_chrome_dev` build reloads itself after each dev build, so there only the tab needs refreshing; the [verify-in-browser](../../verify-in-browser/SKILL.md) skill covers confirming the loaded build.\n\nA build, unit test, or code inspection is not `Loaded` or `Live` evidence.\nFor `Loaded` or `Live`, record the browser name, exact version, scenario, result, and redacted evidence.\n\n## Route by risk\n\n| Change                                                                                           | Required evidence before review is complete                                                                                                                                                |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Docs, comments, or test-only work                                                                | Relevant automated checks; explain why browsers are not applicable.                                                                                                                        |\n| Pure logic with no browser API or visible behavior change                                        | `bun run verify:pr`; add focused tests.                                                                                                                                                    |\n| Popup, content script, background, shared CSS, storage, or plugin runtime                        | `verify:pr`, then `Loaded` and `Live` on Chrome and Firefox. Add Edge for Chromium-specific, manifest, permission, or packaging behavior. Add Safari whenever Safari behavior is affected. |\n| Browser-specific code, permissions, native messaging, Swift, signing, packaging, or release work | `Loaded` and `Live` on each affected browser, builds for the others, and the feature-specific regression checks. Use the Safari or release skill where applicable.                         |\n| Bundled official or builtin plugin                                                               | `Live` on Chrome, Edge, Firefox, and Safari before merge unless the manifest explicitly supports fewer browsers, plus the plugin checks below.                                             |\n\nMissing hardware or access may be covered by another contributor. Record `Needs <browser> test; owner: @name`; a draft PR may remain open, but it is not review-ready until required evidence is added.\n\n## Artifacts\n\n| Browser | Command                                        | Load target                                                                                                                              |\n| ------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| Chrome  | `bun run dev:chrome` or `bun run build:chrome` | `dist_chrome_dev` for routine development (load once; it reloads itself after each dev build); `dist_chrome` for production verification |\n| Edge    | `bun run build:edge`                           | `dist_edge`                                                                                                                              |\n| Firefox | `bun run build:firefox`                        | `dist_firefox/manifest.json` via `about:debugging`                                                                                       |\n| Safari  | `bun run build:safari`                         | `dist_safari`; then follow the `update-safari-extension` skill                                                                           |\n\n`bun run build:all` and `bun run verify:pr` exclude Edge (the Edge zip step needs the external `zip` executable, which Windows lacks by default — CI's `Build (edge)` covers it). `bun run build:browsers` includes the Edge package build.\n\n## Baseline smoke\n\nFor each required browser:\n\n- Load exactly one enabled Voyager artifact with no manifest or startup error.\n- Open the popup and exercise the changed workflow on every affected site.\n- Check relevant popup, background/service-worker, and page consoles for new errors.\n- Reload the extension and target page; confirm UI, listeners, styles, and notifications are not duplicated.\n- Confirm existing settings and stored data remain intact.\n- For visual changes, check light and dark themes, alignment, spacing, narrow and wide layouts as applicable.\n- Preserve `/u/<index>/...` account scope when the feature is account-sensitive.\n\n## Plugin checks\n\nFor `src/features/plugins/catalog/`, catalog mappings, or builtin/native plugins:\n\n- Confirm the plugin appears once from the intended bundled source.\n- Match the default enabled state of the nearest sibling plugin unless an approved migration says otherwise.\n- Enable, disable, re-enable, and reload; verify state persistence and complete teardown without duplication.\n- Test every declared target site and confirm unrelated sites are unchanged. Test both start and stop paths for builtin JS plugins.\n- Keep custom-site access as an explicit optional grant; direct `<all_urls>` permission requires prior user approval.\n- Attach redacted screenshots or recordings for visible behavior. Never expose account data, conversations, tokens, signing identities, or native handoff URLs.\n\n<!-- File: .agents/skills/voyager-contribute/references/repo-traps.md (Tokens: ~1326 | Category: anthropic-skill) -->\n# Repository-Specific Traps\n\nEvery item below cost a real contributor at least one review round (source PR in parentheses). CI catches none of them — read this before writing code, not after review.\n\n## Git and PR mechanics\n\n- **This repository squash-merges.** After your PR merges, your local branch will show as unmerged or conflicting — that is normal. Delete it and branch fresh from `main`; never re-open a PR from the old branch (#867 was closed as a self-duplicate created exactly this way).\n- **The PR title becomes the squash commit header.** Title it `type(scope): imperative summary` — never the branch name or \"Fixes #N\" (#859, #865, #867).\n- **Check `git diff origin/main --stat` (or whichever remote tracks the base repository's `main`) before opening the PR** so unrelated churn (assets, lockfiles) never rides along (#865 accidentally wiped `sponsors.svg`).\n- **`@codex review` triggers the automated review** (bare `@codex` does nothing). Trigger it once per new head, re-trigger after any rebase/force-push, and don't re-trigger while a run is pending (#854). The human reviewer finalizes only after Codex has covered the exact current head SHA.\n\n## Reuse the sibling precedent\n\nGrep for the existing primitive before writing parallel logic — reviewers treat a one-off reimplementation of an existing behavior as a defect:\n\n- Send/keyboard handling: reuse the `sendBehavior` detector — it already handles localized labels (发送/送信/enviar) and icon-only buttons, and respects the `gvCtrlEnterSend` setting. An English-only selector or unconditional Enter handler will be rejected (#854).\n- Text insertion: `chatInput` has multiline-aware insertion; a bare `createTextNode` path flattens multiline prompt bodies (#854).\n- Export CSS: extend a shared style builder (see `buildKatexExportStyles`) instead of pasting CSS into a second export service (#847).\n- Export DOM walking: `DOMContentExtractor` must use ONE shared DOM-order traversal helper — Gemini nests arbitrary `section`/`div`/`response-element` layers, and any second code path will miss one (#847 spent 4+ rounds on this).\n- Body-level popovers, global listeners, overlays: copy an existing `gv-pm-*` integration (close-outside handlers, teardown, theme overrides).\n\n## New-site features go through the plugin system\n\n- Adding support for a new site (ChatGPT, Claude, …) goes through the plugin system: declarative CSS+JSON plugins belong in the bundled catalog (`src/features/plugins/catalog/` via `BundledCatalogPluginSource`); only native-function plugins that genuinely need JS go in `src/features/plugins/builtin/` with optional host permission, dynamic content-script registration, and a `start`/`stop` lifecycle with teardown. Plugins start disabled unless an approved default lists them in `DEFAULT_ENABLED_PLUGIN_IDS` (`storage/pluginDefaults.ts`; today only the bundled catalog plugin `voyager.chatgpt-folders`), and even then the content script runs only on origins the user granted.\n- `builtin/` also holds the packaged Gemini / AI Studio official plugins (`gemini*Manifest.ts`, `aistudio*Manifest.ts`). Those are not new-site plugins: their switch is the old sync setting (`storage/legacySettingBindings.ts`), so their default is that binding's reading of a missing key (usually the native feature's old default), and they run only where the content script admits them. Follow `create-voyager-plugin`'s \"Convert a native Gemini or AI Studio feature\" for them. Never add static `content_scripts` or required `host_permissions` to `manifest*.json` — manifest permission escalation without prior Issue approval is a hard stop (#865).\n- Platform registries are additive: never delete or narrow another platform's adapter or tests to make room for yours (#865).\n- Any storage-toggled feature needs a full lifecycle — working start AND destroy at page load and on runtime toggle. Hiding the UI is not disabling the feature (#854).\n\n## Docs and locale mirrors\n\n- \"The docs\" are 20+ surfaces: the main `README.md`, nine `.github/README_*.md` locale mirrors, ten `docs/<locale>/` trees, and `docs/public/llms*.txt`. The green `i18n` CI check only compares `src/locales/*/messages.json` keys — it covers none of these (#874, #853).\n- Never delete a published docs route — there is no redirect layer, so replace retired pages with a localized notice at the original URL (#874).\n\n## Theme and data correctness\n\n- Host theme markers (`.theme-host.dark-theme`, `body.dark`, `html.dark`, …) belong only in each `site.json` `theme` descriptor; `src/pages/content/platformTheme/scheme.ts` reads it and stamps `html[data-gv-scheme]`. Voyager CSS keys off `html[data-gv-scheme='dark' | 'light']` and JS uses `getScheme()` / `subscribeScheme()`; `contentStyleTheme.test.ts` rejects host markers in CSS. Test theme logic against the descriptor with conflicting markers, not single signals (#859).\n- Theme the whole export artifact consistently — a dark diagram on a forced-white document is not dark-mode support (#847).\n- Gemini conversation IDs are namespaced (`gemini:conv:<id>`); shared code that handles raw IDs silently orphans existing starred/bookmark data (#865). Preserve `/u/<index>/...` account scope in every constructed route.\n- Prompt/folder data has multiple merge entry points (`utils/merge.ts` plus page-level Drive merges); route data-shape changes through one shared merge helper, and never drop or rename existing user records on conflict (#854).\n\n## Scope discipline\n\n- The reviewer diffs your behavior against the Issue's confirmed scope and blocks deviations in either direction. When an edge case tempts you to change a product constraint (e.g. enforce name uniqueness), ask in the Issue first (#854); state non-goals explicitly in the PR description.\n- Trivially verifiable one-line fixes may go straight to PR (#876). Features require explicit maintainer approval of the approach, in the Issue or a direct instruction in the current task; assignment alone is not approval (#865).\n\n<!-- File: .agents/skills/voyager-contribute/SKILL.md (Tokens: ~1164 | Category: anthropic-skill) -->\n---\nname: voyager-contribute\ndescription: Prepare Voyager commits, pull requests, and contribution evidence, or claim an issue and check review readiness.\n---\n\n# Voyager Contribution\n\nUse only the stages included in the user's request; a readiness review delivers findings, while an authorized commit or publication continues through verification and handoff. Route issue investigation to `issue-review`, Safari loading or native verification to `update-safari-extension`, and releases to `release` when needed.\n\n## 1. Preflight\n\n1. Inspect `git status --short --branch`, the current branch, and the diff. Preserve unrelated work.\n2. Read the linked Issue and its comments when one exists. A new feature needs explicit maintainer approval of the approach, either in the Issue or as a direct instruction in the current task; assignment or `/claim` only selects an owner.\n   The PR gate (`.github/workflows/pr-gate.yml`) closes any PR from an author outside `.github/MAINTAINERS` and `.github/APPROVED_CONTRIBUTORS` unless it has a `fix:` / `fix(scope):` title and stays within 20 files and 1,000 changed lines, or a maintainer added the `maintainer-approved` label. Docs, refactor and feature PRs from such authors need that alignment first. A maintainer's `/approve @user` both assigns the issue and adds the user to `APPROVED_CONTRIBUTORS`.\n3. Work on one focused topic branch targeting `main`. Keep secrets and generated `dist_*` artifacts out of commits.\n4. Read [repo-traps.md](references/repo-traps.md) — the repository-specific pitfalls that have cost past contributors the most review rounds.\n5. Use `gh` as the source of truth for Issue/PR state. Before any GitHub write, confirm `gh auth status` shows the account you intend to contribute as. If GitHub CLI is unavailable, perform Issue/PR reads and writes through the GitHub web UI instead and note that in the PR.\n\nPreflight is complete when the Issue or rationale, approval state, intended scope, current branch, and clean ownership of every changed file are known.\n\n## 2. Verify\n\n1. Add or update regression tests for behavior changes. If no useful automated test exists, record the reason.\n2. Use the verification matrix in [AGENTS.md](../../../AGENTS.md#verification): code PRs require `bun run verify:pr`; prose-only changes use the applicable document checks. During implementation, run focused checks; reuse passing evidence for unchanged inputs. If formatting or lint fixes are needed, apply them to the intended files with `bun run format <paths>` / `bun run lint <paths>` and inspect the diff before checking the final tree. Read-only reviews use check variants.\n\n   `verify:pr` covers local automation and production browser builds; it does not prove that an extension loaded or that live behavior works. Finish with `git diff --check` and `git status --short`.\n\n3. For runtime, UI, manifest, permission, packaging, native, or plugin changes, read [browser-testing.md](references/browser-testing.md) and collect the required live evidence.\n4. Record every omitted command or browser check with its reason. For required coverage that another person must complete, name the browser and owner and leave the item pending.\n\nVerification is complete when every applicable automated check and browser check has a truthful result, the working tree matches the snapshot being tested, and remaining work is explicitly assigned.\n\n## 3. Commit and publish\n\n1. Review the final diff and stage only intended paths.\n2. Create a Conventional Commit with a lowercase scope and a header no longer than 100 characters. Add `Fixes #<issue>` or `Closes #<issue>` when appropriate. Retain the active agent's project-standard co-author footer; Codex-authored commits use:\n\n   ```text\n   Co-authored-by: Codex <codex@users.noreply.github.com>\n   ```\n\n3. Inspect the commit with `git show --stat --format=fuller HEAD` and `git status --short`. Require no staged or unstaged changes in the contribution's paths; preserve and disclose unrelated pre-existing work. `HEAD` is the tested commit only while the verified paths match it, and any later change invalidates affected evidence.\n4. When publishing is authorized, push only the topic branch and open or update a draft PR targeting `main`. Contributors never push directly to `main`; use force-push only with explicit user approval — this repository squash-merges, so a merged branch will later look unmerged (see repo-traps.md) and must be deleted, not re-pushed. Exception: this rule governs contribution workflows; a maintainer with push access working outside a contribution follows the project-wide default push rules in `CLAUDE.md`/`AGENTS.md`, which take precedence.\n5. In the PR, state the linked Issue or direct authorization/rationale, scope, tested commit, commands run, live browser evidence, screenshots for UI changes, and all pending checks. Re-run affected checks after review changes.\n6. Verify the PR author, base branch, commit set, changed files, and CI state with `gh` before handoff.\n\nPublishing is complete when the focused diff is on a topic branch, the draft PR accurately reports its evidence and gaps, and the user receives the PR URL plus pending review or CI work.\n\nAfter an Issue fix lands, leave a short comment in the reporter's language: the fix has landed, it will be available in the next version, and the Issue may be reopened if the problem remains.\n\n<!-- File: .claude/rules/content-scripts.md (Tokens: ~524 | Category: anthropic-skill) -->\n---\nglobs: [\"src/pages/content/**\", \"src/styles/contentStyle/**\"]\n---\n\n# Content Script Rules\n\n## CSS\n- All injected CSS classes MUST be prefixed with `gv-` (e.g., `.gv-rtl`, `.gv-pm-trigger`)\n- Shared/static injected CSS goes in the matching part under `src/styles/contentStyle/` by default; the build joins the parts in file-name order into the shipped `contentStyle.css`, so the number prefix is cascade order. Add a numbered part rather than grow one past the commit size limit. Feature-specific dynamic CSS may be injected by content modules or the plugin runtime when values are computed at runtime; keep it `gv-` prefixed and tear it down.\n- Support both light and dark themes through `html[data-gv-scheme='dark']` / `html[data-gv-scheme='light']`, which `platformTheme` stamps from each site's `site.json` theme descriptor. Never target a host's own theme markers (`.theme-host.dark-theme`, `body.dark-theme`, `html.dark`, …); `src/core/utils/__tests__/contentStyleTheme.test.ts` rejects them.\n- RTL layout: use `body.gv-rtl` selector for RTL overrides (see `src/core/utils/rtl.ts`)\n\n## Storage\n- Content scripts use `chrome.storage` / `browser.storage` directly — this is the exception to the \"use StorageService\" rule\n- Outside content scripts, prefer `StorageKeys` plus existing storage helpers/services. Direct `chrome.storage` / `browser.storage` still exists in popup/background/options for established settings patterns, bulk reads/writes, and storage listeners.\n\n## DOM Injection\n- Each content script sub-module in `src/pages/content/` is self-contained\n- Bridge between host sites (Gemini, AI Studio, Claude, ChatGPT, DeepSeek) and the extension — any host DOM may change without notice\n- Safari has platform-specific manifest and native-bridge paths, but cloud sync,\n  watermark removal, and image export are supported. Do not add an `isSafari()`\n  skip from historical assumptions; verify the current Safari implementation\n  and its browser-specific tests first.\n- Extension context can be invalidated after update/reload. Use `isExtensionContextInvalidatedError()`.\n\n## Material Symbols Icons\n- Popup Material Symbols are bundled locally through `public/fonts/material-symbols-outlined.css` and `.ttf`; verify the glyph exists in the bundled font or update the bundled font assets. Do not add a remote Google Fonts URL.\n\n<!-- File: .claude/rules/high-complexity.md (Tokens: ~518 | Category: anthropic-skill) -->\n---\nglobs:\n  [\n    'src/core/services/StorageService.ts',\n    'src/core/services/DataBackupService.ts',\n    'src/core/services/GoogleDrive*.ts',\n    'src/core/services/AccountIsolationService.ts',\n    'src/features/folder/**',\n    'src/features/export/**',\n    'src/pages/content/folder/**',\n    'src/pages/content/export/**',\n    'src/features/plugins/storage/**',\n  ]\n---\n\n# High-Complexity Modules — Edit with Caution\n\n| Module                                     | Risk                                                                                                                                                                | Notes                                               |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |\n| `StorageService`                           | Typed sync/local storage wrapper used where suitable. Persistence is also handled by direct `chrome.storage`/`browser.storage` paths and migration/backup services. | Do not modify lightly.                              |\n| `DataBackupService`                        | Multi-layer backup. Race conditions during unload.                                                                                                                  | Critical for data safety.                           |\n| `GoogleDriveSyncService`                   | OAuth2 cloud sync: folders, prompts, settings, plugins, starred, forks, timeline hierarchy and highlights, scoped per account. Split across `GoogleDrive*.ts`.        | Requires OAuth2 identity.                           |\n| `AccountIsolationService`                  | Hard account isolation for multi-account.                                                                                                                           | Integrates with Drive sync.                         |\n| `features/folder` + `pages/content/folder` | Drag-and-drop + cloud sync UI. DOM manipulation + state sync.                                                                                                       | Watch for infinite loops.                           |\n| `features/export` + `pages/content/export` | JSON/MD/PDF/Image export + Deep Research.                                                                                                                           | Fragile to host UI changes. Multi-browser compat.   |\n| `features/plugins/storage`                | Plugin on/off and settings. The legacy bindings decide which store is authoritative for each plugin, and so what backup and restore keep. | A bound id ignores `gvPluginsState`; keep both backups restorable in either order. |\n\n## Changes to behavior or data\n\nApply these checks when changing logic, serialized formats, account scope, or data handling in these modules. Comments, documentation, and formatting-only changes use the prose/formatting checks in `AGENTS.md`.\n\n1. Read the entire file first — not just the section you plan to change\n2. List all existing features that might be affected\n3. Ensure zero destructiveness to user data\n4. Run the full test suite after changes; reuse that result if a later verification step checks the same unchanged inputs\n\n<!-- File: .claude/rules/i18n.md (Tokens: ~361 | Category: anthropic-skill) -->\n---\nglobs: [\"src/locales/**\", \"src/features/plugins/verbs/*/messages/**\"]\n---\n\n# i18n / Translation Rules\n\n## 10 Locales (ALL must be updated together)\n`en`, `ar`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, `zh_TW`\n\n## When adding a new translation key\n1. Add the key to ALL 10 `src/locales/*/messages.json` files\n2. English (`en`) is the source — write it first, then translate to all others\n3. Each key is a top-level entry in Chrome's `{ \"key\": { \"message\": \"…\", \"description\": \"…\" } }` format; no nested keys\n4. Run `bun run i18n:check`: it fails when any locale is missing a key from `en` or has an extra one\n\n## When removing a translation key\n- Remove from ALL 10 locale files\n\n## Primitive fragments\n- A string used only inside one primitive folder goes in `src/features/plugins/verbs/<name>/messages/<locale>.json` (same format, all 10 locales), not in `src/locales`; it reaches `getTranslationSync` but not `chrome.i18n`. Type its keys by augmenting `PrimitiveMessages` (see `src/utils/translations.ts`).\n- `bun run i18n:check` also requires every fragment locale to match its `en.json` and forbids keys that collide with `src/locales` or another fragment.\n\n## Quality\n- Translations should be natural, not machine-literal\n- Arabic (`ar`) is RTL — ensure UI handles it (see `src/core/utils/rtl.ts`)\n\n<!-- File: .claude/rules/typescript.md (Tokens: ~391 | Category: anthropic-skill) -->\n---\nglobs: [\"src/**/*.ts\", \"src/**/*.tsx\"]\n---\n\n# TypeScript Coding Standards\n\n## DOs\n- Prefer plain objects with interfaces/types for data structures\n- Use `map`, `filter`, `reduce` for immutability\n- Use `private`/`protected` in classes\n- Use `unknown` + narrowing (custom type guards) for type safety\n- Use named exports: `export function X`\n- Functional React: hooks at top level, strictly functional components\n\n## DON'Ts\n- **No `any` type.** Use `unknown` if you must, then narrow it.\n- **No global variables** outside defined Services.\n- **Avoid ad hoc storage in UI components** (`src/components/`, `src/pages/popup/`). Prefer `StorageKeys` plus existing storage helpers/services; direct `chrome.storage` / `browser.storage` is acceptable when matching established popup settings patterns, bulk reads/writes, or storage listeners.\n- **No God Components.** Business logic belongs in `features/*/services/` or custom hooks, not UI files.\n- **No magic strings.** Use constants or enums (StorageKeys, CSS classes).\n- **No `console.log` in production.** Use `LoggerService` for critical info; oxlint only warns, so do not add new ones.\n\n## Testing (Vitest + jsdom)\n- Chrome `chrome` object is globally mocked in `src/tests/setup.ts`\n- Mock specific storage: `vi.mocked(chrome.storage.sync.get).mockResolvedValue({ key: 'val' })`\n- jsdom cannot create trusted events: code that tells a person's input from a script's goes through `isUserEvent` (`src/core/utils/userEvent.ts`), and tests mock it\n- Run: `bun run test`, `bun run test <filename>`, `bun run test:coverage`\n\n<!-- File: src/features/plugins/catalog/marketplace.json (Tokens: ~300 | Category: marketplace) -->\n# Claude Plugin Marketplace Catalog\n\nPath: `src/features/plugins/catalog/marketplace.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/marketplace.json)\n\n<!-- File: src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json (Tokens: ~300 | Category: plugin-manifest) -->\n# chatgpt-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/plugin.json)\n\n<!-- File: src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md (Tokens: ~300 | Category: plugin-manifest) -->\n# chatgpt-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/chatgpt-reading-width/README.md)\n\n<!-- File: src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json (Tokens: ~300 | Category: plugin-manifest) -->\n# claude-cjk-render-fix Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/plugin.json)\n\n<!-- File: src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md (Tokens: ~300 | Category: plugin-manifest) -->\n# claude-cjk-render-fix Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-cjk-render-fix/README.md)\n\n<!-- File: src/features/plugins/catalog/plugins/claude-reading-width/plugin.json (Tokens: ~300 | Category: plugin-manifest) -->\n# claude-reading-width Plugin Manifest\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/plugin.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/plugin.json)\n\n<!-- File: src/features/plugins/catalog/plugins/claude-reading-width/README.md (Tokens: ~300 | Category: plugin-manifest) -->\n# claude-reading-width Documentation\n\nPath: `src/features/plugins/catalog/plugins/claude-reading-width/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/Nagi-ovo/voyager/HEAD/src/features/plugins/catalog/plugins/claude-reading-width/README.md)\n\n<!-- File: src/features/plugins/README.md (Tokens: ~11635 | Category: plugin-manifest) -->\n# Plugin Ecosystem (`src/features/plugins`)\n\nA self-contained subsystem that lets Voyager run **plugins** — units that inject\nstyles / DOM changes into AI chat sites (Gemini, AI Studio, ChatGPT, Claude,\nDeepSeek, …). Voyager's own features can migrate onto this over time; third parties\ncan ship their own plugins against the same contract.\n\n## Two design constraints that shaped everything\n\n1. **Chrome MV3 forbids remotely-hosted code.** You may download _data_ (JSON/CSS)\n   and run it through engine logic that ships **inside** the package; you may not\n   download and execute JS. (`chrome.userScripts` is the only sanctioned escape\n   hatch, and it's gated behind a per-user \"Allow User Scripts\" toggle and is\n   unavailable on Safari.)\n2. **The core is GPL-3.0 with many copyright holders.** It can't be relicensed or\n   closed. Monetizable / proprietary plugins must therefore be **independent works**\n   — and _data read by a GPL engine is not a derivative work of it._\n\nBoth constraints point at the same answer → **declarative-first** plugins.\n\n## Two tiers\n\n| Tier                    | Ships                       | Store-safe                     | Runs on                   | Status                                          |\n| ----------------------- | --------------------------- | ------------------------------ | ------------------------- | ----------------------------------------------- |\n| `declarative` (default) | CSS + JSON (`domOps`)       | ✅ everywhere, remote-loadable | Chrome / Firefox / Safari | **implemented**                                 |\n| `scripted` (advanced)   | JS via `chrome.userScripts` | gated toggle, no Safari        | Chrome / Firefox          | reserved (gated, runtime is a future milestone) |\n\n## Architecture\n\n```\nPluginSource[]  ──►  manifests        SiteRegistry ──► SiteAdapter (current URL)\n   (native builtin,\n    bundled catalog,\n    remote host catalog,\n    local imports)                         ▼\npluginState (storage) ─► enabled?    DeclarativeEngine (interprets contributions)\nEntitlementProvider  ─► entitled?         │  styles + domOps, reversible, idempotent\n        └──────────►  PluginHost.reconcile() ──► engine.mount/unmount\n```\n\n- **`types.ts`** — the whole contract: `PluginManifest`, `PluginContributions`,\n  `DomOperation` (discriminated union — the main extension point), `SiteAdapter`,\n  and the `PluginSource` / `EntitlementProvider` seams.\n- **`sites/`** — `SiteAdapter` per site + a `SiteRegistry` that resolves the\n  current URL. Site-specific selectors live **only** in an adapter, behind the\n  fixed semantic vocabulary in `sites/semanticKeys.ts` (`userTurn`,\n  `assistantTurn`, `thinkingBlock`, `codeBlock`, `composer`, `sidebar`,\n  `sidePanel`, `headerActions`, `scrollContainer`, `composerRoot`,\n  `sendButton`), so a site redesign is a\n  one-file fix. Every adapter is data: `adapters/gemini.ts`, `adapters/aistudio.ts`,\n  `adapters/claude.ts`, `adapters/chatgpt.ts` and `adapters/deepseek.ts` are\n  one-line shells over their `site.json`: edit the JSON, not the TS. Gemini and\n  AI Studio stay native surfaces (`NATIVE_SITE_IDS`, `sites/hostPolicy.ts`); their\n  `site.json` is packaged with the extension and never published remotely.\n  Gemini's `userTurn`/`assistantTurn` are the joined membership lists from\n  `core/gemini/turnSelectors.ts`, bound by `adapters/gemini.parity.test.ts`;\n  timeline, export and highlight keep reading the ordered lists there.\n  `sites/siteAdapterData.ts` holds\n  `validateSiteAdapterData`, the one validator for the bundled `site.json`, the\n  remote override and `catalog:build`.\n- **`runtime/declarativeEngine.ts`** — applies a manifest's `styles` + `domOps`.\n  Reversible (full teardown), idempotent, and uses a `childList`-only\n  MutationObserver so its own mutations can't loop. Pure DOM → all platforms.\n- **`runtime/PluginHost.ts`** — orchestrator. Loads manifests, checks\n  match-URL + enabled + engine-range + entitlement, mounts/unmounts, reacts to\n  state changes. All dependencies injected → fully unit-testable.\n- **`runtime/officialAdmission.ts`** — when each packaged Gemini/AI Studio\n  official plugin may mount: the content script admits it where its native\n  module used to start, and `close` denies whatever is still pending.\n- **`runtime/resourceClaims.ts`, `resourceArbiter.ts`, `reconcileArbitration.ts`,\n  `releaseLedger.ts`** — exclusive page resources (`timeline-rail`, the composer\n  send mode, …): which plugin holds each, why the others are `blocked`, and\n  waiting until a released runtime has really shut down.\n- **`runtime/stateMachine.ts`** — `startMachine`, the runtime for features with\n  interruptible phases: a pure `transition`, one `PluginScope` per status, late\n  callbacks from a left status ignored. Used by export selection, default\n  model, draft restore, reply notifications and both batch deletes; see\n  [the state machine plan](../../../.github/docs/STATE_MACHINE_PLAN.md).\n- **`manifest/validate.ts`** — turns `unknown` into a typed manifest or a list of\n  issues. Remote catalog plugins are untrusted; bundled catalog plugins use\n  the same validator so official data cannot drift from the runtime contract.\n- **`storage/pluginState.ts`** — per-plugin enable state in `chrome.storage.local`.\n- **`sources/` `entitlement/`** — the swap points for native first-party\n  features, bundled official declarative plugins, the per-host remote catalog,\n  the user's locally imported plugins, and a future paid (Stripe/account) store. `sources/defaultSources.ts` also\n  owns `mergePluginRecords`, the rules that pick which copy of a plugin id wins.\n- **`local/`** — user-imported declarative plugins: the `local.*` id namespace,\n  the import gate, storage, the read-only `LocalPluginSource` and the inspect\n  summary the popup shows. See \"Write your own plugin locally\" below.\n- **`remote/`** — the per-host remote catalog channel: a read-only source over a\n  storage cache, plus a background-only fetcher. See below.\n- **`catalog/`** — the bundled official data: one directory per site, each\n  holding that site's `site.json` and its declarative plugins. This\n  keeps official CSS/JSON changes in the same PR, CI run, and release as engine\n  or popup changes.\n\n```\ncatalog/\n  marketplace.json                        list of public catalog plugins (tests, plugin:new)\n  sites/index.ts                          import.meta.glob discovery\n  sites/<site>/site.json                  the site adapter, as data\n  sites/<site>/plugins/<id>/plugin.json   a declarative plugin\n  sites/<site>/plugins/<id>/style.css     its styles\n  sites/<site>/plugins/<id>/README.md     what it fixes and why\n```\n\n`catalog/sites/index.ts` finds every `site.json` and `plugin.json` with\n`import.meta.glob`, so adding a site or a plugin is adding files: there is no\nmapping table. (`scripts/build-plugin-catalog.ts` reads the same tree from disk,\nbecause `import.meta.glob` does not exist under Bun.) Sites today are\n`aistudio`, `chatgpt`, `claude`, `deepseek` and `gemini`. A plugin's `matches`\nmust stay inside its site's `matches` (plan D18) or the build fails, and\n`bun run catalog:build` validates every site and plugin before publishing.\nGemini and AI Studio are validated like the rest but never published: the host\npolicy (`allowsRemoteCatalog`) keeps their four hosts out of the per-host\ncatalog, and their plugins stay out of `marketplace.json`.\n\n`catalog/marketplace.json` is **not** that mapping table, and no page reads it:\nthe site's plugin table is built from the manifests themselves\n(`docs/.vitepress/pluginsPage.ts`, which lists the Gemini and AI Studio\npackaged plugins too). It stays as the list of public catalog plugins that\n`catalog/sites/index.test.ts` keeps in sync with discovery and `plugin:new`\nappends to, so a new plugin still needs an entry whose `source` is the\ncatalog-relative path (`sites/deepseek/plugins/reading-width/plugin.json`).\nPlugins under `sites/gemini/` and `sites/aistudio/` are not listed.\n\n## Authoring a declarative plugin\n\n```jsonc\n{\n  \"id\": \"vendor.my-plugin\",\n  \"name\": \"My Plugin\",\n  \"version\": \"1.0.0\",\n  \"description\": \"...\",\n  \"author\": \"...\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://claude.ai/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"file\": \"style.css\" }],\n    \"domOps\": [{ \"op\": \"addClass\", \"target\": \"body\", \"className\": \"gv-plugin-readable\" }],\n  },\n}\n```\n\nAn official plugin is authored as files under its platform's directory:\n\n1. Pick the site it belongs to. If that site has no `catalog/sites/<site>/`\n   directory yet, write its `site.json` first (`id` equal to the directory name,\n   `label`, `matches`, `selectors` keyed by the semantic vocabulary, `theme`,\n   `brandColor`, `capabilities`, optional `conversationIdPattern` and\n   `turnKeyAttributes`). Nothing registers it: the registry picks it up from the\n   file. `turnKeyAttributes` names the attributes that keep a user message's\n   identity across remounts; send tracking (`sends/trackUserSends.ts`, behind\n   folder Activity and catalog timeline timestamps) runs only on a site that\n   names them, its `composer` and `userTurn`, and a `conversationIdPattern`.\n2. Create `catalog/sites/<site>/plugins/<id>/` with `plugin.json`, `style.css`\n   and a short `README.md`.\n3. Keep the plugin's `matches` inside the site's `matches` (D18).\n4. Add the entry to `catalog/marketplace.json` (`plugin:new` does it for you).\n5. Run the suite: discovery, D18 and the marketplace index are all asserted in\n   `catalog/sites/index.test.ts`.\n\nManifests may keep tiny CSS inline with `{ \"css\": \"...\" }`, but the preferred\nauthoring shape is `{ \"file\": \"style.css\" }` next to `plugin.json`. The bundled\nsource and the published catalog file both resolve that CSS to inline text,\nreject anything that loads, from the page's own origin too (`@import`,\n`image-set()`, `image()`, `cross-fade()`, `src()`, a `url()` other than a\nraster-image `data:` URL or `#fragment`, or any string that starts with an\nexternal URL), and normalize\nit before the runtime sees it. A `data:` URL in `url()` must declare a raster\ntype (`image/png`, `jpeg`, `gif`, `webp`, `avif`, `bmp` or an icon type);\n`data:image/svg+xml` is refused in every encoding, because an SVG used as a\nfilter, mask or clip-path resource can load images of its own. For\nuser settings, `{{settingKey}}` tokens can be used in CSS text or in\n`setAttribute` / `setStyle` DOM op values; a common pattern is for CSS files to\nuse a normal custom property and for a `setStyle` op to set that variable from a\nsetting.\n\nSelect settings render their declared value/label options in the popup. Localize\neach option under `i18n.<locale>.settings.<key>.options.<value>` (engine 1.6.0+).\nA select needs at least one option, unique string values (`\"\"` included) and a\ndefault among them; a stored choice the manifest no longer offers resolves to\nthat default in both the popup and the host (`runtime/resolvePluginSettings.ts`).\n\nSetting fields may declare `enabledBy: \"<booleanKey>\"` to depend on another\nboolean setting in the same plugin. The popup keeps number, select and boolean\ncontrols visible with their values, but disables them unless the controlling\nsetting's resolved value is `true`; stored values remain intact. The key must\nbe non-empty and name another boolean field without its own `enabledBy`\n(one level only). This affects popup controls; page behavior keeps its own\nboolean gate.\nEngine 1.7.0 ignores `enabledBy` and leaves the control enabled, so this field\nrequires no higher engine floor.\n\n`target` is a CSS selector string, or `{ \"kind\": \"semantic\", \"key\": \"userTurn\" }`\nto use the site adapter's stable selector for one of the eleven semantic keys in\n`sites/semanticKeys.ts`; a `site.json` may not invent a key outside that\nvocabulary. Supported ops: `addClass`,\n`setAttribute`, `setStyle`, `hide`, plus `native` (below). All are reversible.\nInline styles are layered per longhand property: a `setStyle` value or a whole\n`style` attribute is expanded into longhands (a trailing `!important` is kept as\npriority), so turning one plugin off restores only the longhands it wrote and never\nanother live plugin's value. A `var()` shorthand written with `setStyle` stays whole.\nClasses must be `gv-` prefixed (content-script rule). `setAttribute` names are an\nexact-match allowlist: `data-*`, `aria-*`, `title`, `role`, `lang`, `dir`,\n`hidden`, `tabindex`, `draggable`, `spellcheck`, `translate` and `style`; URL,\n`on*`, `class` and `id` attributes are refused because a selector can reach any\npage element. `setStyle` values and `style` attributes follow the CSS rule\nabove, and attribute values may not contain an external URL. All of this is\nchecked again after `{{setting}}` substitution (`manifest/sinkGuards.ts`).\n\n## Write your own plugin locally (`local/`)\n\nA user can run a declarative plugin they wrote without publishing it:\n\n1. Write `plugin.json` exactly as above, plus its `style.css` (or inline the CSS\n   as `{ \"css\": \"...\" }`). Any lowercase reverse-dotted `id` works; it is stored\n   as `local.<id>`.\n2. Open the popup and find **Local plugins**: on Claude, ChatGPT or DeepSeek it\n   sits on the plugin page; on Gemini and AI Studio it is a settings card\n   (collapsed until opened, or until a local plugin targets the page, which\n   then also shows its toggle and settings). By default it follows cloud sync\n   and the eight plugin topic cards and comes before folders through general\n   (`NATIVE_POPUP_SECTION_IDS` in `pages/popup/utils/nativeSiteCapabilities.ts`);\n   users can reorder it. Choose **Import files** (pick\n   the manifest together with its `.css` files) or **Paste JSON** (a manifest\n   with its CSS inlined).\n3. A rejected import lists every problem as `path: message` and leaves the\n   installed version untouched. An accepted one is stored **disabled** and its\n   inspect view shows the sites it matches, the CSS size, every page change,\n   each Voyager primitive it calls with its params, and its settings.\n4. Turn it on from the plugin list like any other plugin; it applies live. To\n   update it, edit the files and import again: a re-import also lands disabled,\n   so the user re-inspects the new version (it may add a `native` op) and turns\n   it back on. A plugin with a `native` op that was running when it was\n   re-imported keeps its mounted version until the page reloads, as official\n   ones do (D7). **Export** downloads the stored manifest with CSS inlined;\n   **Remove** deletes it with its enable state and settings.\n\n**Describe a change in one sentence.** The same panel can write the manifest\nwith any AI the user already uses, while Voyager itself stays offline:\n\n- `local/pluginAuthoringPrompt.ts` turns the sentence and a chosen site (the\n  active tab's when supported) into a self-contained prompt: the request, the\n  plugin contract for that site (see \"One contract, four outputs\" below), and\n  \"reply with exactly one ```json block\".\n- The user copies the prompt and sends it themselves. Voyager never sends it,\n  opens or deep-links an AI page, reads one back, or makes a request.\n- `local/pluginReply.ts` takes the pasted reply (bounded, linear fence scan;\n  one ```json or unlabeled block, or bare JSON; zero or several blocks are\n  refused by name) through `readLocalPluginFiles` and the gate without\n  installing it. The popup closes while the user is in their AI tab, so this\n  step works on its own in a fresh popup.\n- `local/pluginPreview.ts` describes the result in plain terms (sites, each\n  change, warnings for hidden content via `hide`, `hidden` or a hiding inline\n  style, raw selectors, a replaced version, a\n  reply that misses the selected site (the active tab's, or the user's pick;\n  recomputed when the picker changes, no prompt needed), and a `theme` the\n  prompt asked the AI to leave out; a primitive is listed as a change, not\n  flagged, because the prompt offers it where the gate accepts it). CSS is not summarized: its full\n  source is shown with a plain warning that it can change anything, hiding\n  included, since reading its effect would be a guess. It builds on `inspectPlugin`, and\n  the same inspect view opens under it on demand. **Import** hands the exact previewed\n  object to `importLocalPlugin`, so it lands disabled and follows the re-import\n  rules above, but only over the install the preview saw: if the same id was\n  installed, changed or removed meanwhile, nothing is written and the preview\n  is refreshed for a new review.\n\n**One contract, four outputs.** `local/authoring/pluginContract.ts`\n(`buildPluginContract(site, { audience })`) is the single description of what a\nlocal plugin may contain: manifest fields, styles, page changes, settings, the\nlocal rules, the site's anchors and notes, the primitives (read only through\n`primitiveReference.ts`), what is not possible, and examples. Four outputs\nrender it:\n\n- the popup prompt above (`audience: 'chat'`: inline CSS, no `theme`, primitives\n  where the site allows them, plus a pointer to the public skill for an AI that\n  can browse; the pasted contract wins where the two differ);\n- the public agent skill `docs/public/skills/write-voyager-plugin/SKILL.md`\n  (`authoringDocs.ts`), committed and served at\n  `https://voyager.nagi.fun/skills/write-voyager-plugin/SKILL.md`. Regenerate it\n  with `bun run skills:build`; `authoringDocs.test.ts` fails while it is stale,\n  so a new primitive, a changed `site.json` or a changed local rule shows up\n  there;\n- the `AGENTS.md` that `create-voyager-plugin init` writes next to a new plugin;\n- the examples, which `pluginContract.test.ts` runs through\n  `validateLocalManifest` (file examples through `readLocalPluginFiles`) for\n  every site and both audiences, so no output can teach what the gate refuses.\n\n`packages/create-voyager-plugin/` bundles the same gate and the bundled site\nadapters into one dependency-free Node file (`bun run cli:build`): `init` writes\na passing `plugin.json`, `style.css` and `AGENTS.md`, and `check` runs\n`readLocalPluginFiles` plus `validateLocalManifest` offline, exit 0 or 1. Its\ntests hold it to parity with the in-process gate over a corpus. The package\nversion follows the extension (`scripts/bump-version.js`).\n\nThe gate (`local/validateLocalManifest.ts`) is the remote catalog's gate plus\nlocal-only rules, never a weaker one: `validateManifest` with the CSS and\nrendered-sink guards, `tier: \"declarative\"` only, `native` ops only for primitives\nthis build ships with params that match their contract and an `engine` floor\nat or above their `sinceEngine` (`manifest/primitiveChecks.ts`, shared with\n`plugin:check`), and `matches` inside a plugin platform's `matches` or a native\nsurface's (`sites/nativeSurfaces.ts`: Gemini, AI Studio), so enabling a local\nplugin can only request host access an official plugin could, or none at all.\nA plugin for Gemini or AI Studio may not declare `theme` (those pages keep\nVoyager's own accent). Its `native` ops follow a capability policy per (site,\nhandler) in `sites/hostPolicy.ts`, derived from the owner table\n`sites/featureOwners.ts`: only primitives explicitly listed in a plugin-owned\nentry's `handlers` open on that site. Fixed-id builtins can own a feature with\nno public primitive: Quote, Highlight, prevent auto-scroll, completion\nnotifications and NanoBanana’s two controls are such builtins. Their owner flips\nkeep other native ops closed. A multi-site manifest must be legal on each site\nits patterns reach. Import, paste and the CLI enforce this policy; re-reading stored plugins\n(`LocalPluginSource`, the popup list, review-and-enable) passes\n`hostPolicy: 'defer'`, which skips only this site policy, so a build that\ncloses a primitive again keeps the user's plugin and reports it\n`blocked: host-policy`.\n`local/localPluginRules.ts` adds three rules that turn a plugin which would\nimport and then do nothing into an actionable error: `addClass` adds one\n`gv-` prefixed class; every semantic key the plugin relies on (op targets,\n`requires.semantic`, a primitive's keys and the keys its `semanticKey` params\nname) is declared by every site its\n`matches` target, or the popup would only show `needs-semantic`; and settings\nare `boolean`, `number` or `select`, the kinds the popup renders a control for.\nStored records are re-validated on every read by\n`local/LocalPluginSource.ts`, and at runtime `PluginHost` and the brand theme\nrefuse a theme or native op by the page's real host\n(`sites/hostPolicy.ts`), whatever the patterns say. By design, a local\nplugin's own CSS can still restyle anything on Gemini, Voyager's UI and its\n`--gv-*` variables included; only `theme` and the closed native ops are refused.\n\n**Gemini and AI Studio.** The packaged official plugins\n(`catalog/sites/{gemini,aistudio}/` and fixed-id entries in `builtin/`) and local\nplugins target them; neither goes through the remote catalog, which never\nserves these hosts and never publishes their manifests. The official ones replace native modules feature by\nfeature. Stage 7 retires migrated native startup callbacks and registrations; the content script\nadmits each official plugin where its native module used to start, and the\nplugin follows the native setting it replaces (`storage/legacySettingBindings.ts`).\nIts row in a popup topic card writes those legacy-bound keys. The owner table has no `legacy` option: rolling a feature back to its\nnative module means reverting both stage 7 retirement commits (startup wiring 9c2161df and\nnative wrappers 0ae260c1) and the commit that removed that option. Permanent page services remain in `nativeFeatures.ts`; their\naccount bridge, history/message caches, selection host, sent-quote styling,\nprevent-auto-scroll bridge, fork first-send finisher and default-model stale-menu cleanup stay\nindependent of optional plugin switches. The last service only removes old menu UI while the sync\nsetting is false; the plugin alone selects models and creates new stars.\nThe page send-key coordinator is the one service started elsewhere:\n`startPageSendKeys` in `pages/content/inputStartup.ts` creates it before `PluginHost`.\nThey resolve semantic keys through the native\n`geminiAdapter` / `aistudioAdapter`, which expose the keys their packaged\n`site.json` declares (the Gemini `sidebar` key also covers Voyager's folder\npanel, so prefer a precise CSS selector there). The manifest already injects the content\nscript and grants these hosts, so enabling one needs no permission prompt and\n`pluginsToOriginPatterns` leaves their origins out of dynamic registration\n(registering them would inject Voyager twice). `PluginHost` mounts them with\nthe same reversible engine as elsewhere, and turning one off removes its\nclasses and stylesheet. AI Studio's master switch (`GV_AISTUDIO_ENABLED`,\n`runtime/surfaceSwitch.ts`) governs plugins too: while Voyager is off on AI\nStudio ordinary plugins do not mount, and flipping it unmounts or remounts them live. Gemini\nhas no page-wide master switch. Its **Show Timeline** switch controls only the\nrail and navigation shortcut lease; saved stars and hierarchy remain, and the\nmessage timestamp labels plugin follows **Show Message Timestamps** independently;\nhistory and turn identity remain page services.\n\nThe packaged owners in [`sites/featureOwners.ts`](sites/featureOwners.ts) and\nthe builtins in [`builtin/index.ts`](builtin/index.ts) cover:\n\n- Gemini: Chat width, edit input width, font size, line height and paragraph spacing, sidebar width,\n  sidebar auto-hide, sidebar full hide, Gems sidebar count, hide input halo; formula copy, Canvas\n  copy, Deep Research export, user LaTeX, code collapse, Mermaid, WaveDrom, ECharts, Markdown fix,\n  watermark download and preview; Vim input, prompt history, input collapse, Ctrl+Enter send, Safari\n  Enter fix, draft save, quote reply; timeline, message timestamps, highlights, prevent auto-scroll,\n  response notifications, batch delete, research pack, default model, fork, usage status, context\n  sync. The Gems sidebar row is greyed out in the popup: its registry entry `requires` an\n  unmet condition with the `gemsSidebarUnavailableHint` note, because Gemini moved Gems into its\n  More menu. The plugin and its code are kept.\n- AI Studio: Formula copy, Enter send, sidebar width, batch delete.\n- ChatGPT: Formula copy, Vim input, timeline, conversation export, temporary chat handoff, folders,\n  response notifications.\n- Claude: Formula copy, Vim input, timeline.\n- Shared: Visual effects on already-injected pages, including authorized Prompt Manager custom\n  sites.\n\nOther sites' declarative plugins live in `catalog/sites/`. Every packaged Gemini / AI Studio\nofficial plugin has a popup row in the\n[`officialPluginRegistry.ts`](../../pages/popup/officialPlugins/officialPluginRegistry.ts).\nFor the next migration, follow the [plan §7.12 recipe](../../../.github/docs/GEMINI_PLUGIN_PLATFORM_PLAN.md#712-剩余小功能迁移2026-10-16)\nand the [popup design §5](../../../.github/docs/POPUP_PLUGIN_LIST_DESIGN.md#5-以后每项迁移的步骤).\n\n**NanoBanana watermark removal.** Two packaged fixed-ID builtins,\n`voyager.gemini-watermark-download` and `voyager.gemini-watermark-preview`, map\none-to-one to the existing popup switches and are admitted at the former native\nstart point. Both derive enablement through `core/utils/watermarkSettings.ts`:\n`gvWatermarkDownloadEnabled` and `gvWatermarkPreviewEnabled` take precedence if\neither holds a boolean, with an unset sibling retaining the historical `true`\ndefault; otherwise `geminiWatermarkRemoverEnabled` controls both, and no saved\npreference means both are off. The popup writes the same keys; none are copied\ninto `gvPluginsState`. Neither builtin opens a public native primitive or adds\npermissions. The shared image worker and download bridge remain packaged\nservices. Safari keeps its static MAIN-world hooks, asynchronous extension-message\nrouting and authenticated page-fetch fallback; full-size browser downloads keep\nusing that path. There is no dedicated watermark-processing Swift channel.\n\nDisabling preview invalidates pending DOM writes. It restores the original\n`src`/`srcset` pair only while `src` still equals Voyager’s blob URL and `srcset`\nis still absent. If the host changed either attribute, the current pair stays\nintact. A blob URL is revoked only once `src` no longer references it; a host\n`srcset` may still fall back to that blob, which is retained until document unload.\nDisabling download removal stops accepting new removal work immediately;\nalready-accepted downloads finish once with their setting at acceptance, including\nacross an off/on cycle. Static MAIN-world hooks cannot be unregistered: their\nremoval path checks the current setting and stays inert when off. Independent\nread-only watermark health inspection may continue while removal is off.\nThe accepted download is a one-shot promise with one response and the existing\ntimeout fallback, independent of either activation’s lease. It does not gain a\n`startMachine`: no intermediate phase is cancelled by the switch, and aborting\na state scope here would lose the response an accepted request still needs.\n\n**Shared Visual effects.** `voyager.visual-effects` is one packaged builtin on every\nalready-injected page, including authorized Prompt Manager custom sites. It is admitted\nbefore normal initialization and excluded from plugin subframes, as its native predecessor\nwas. It remains independent of AI Studio's master switch. Its `<all_urls>` runtime match\nadds no host permission or content-script registration, and its fixed ID never requests an\norigin grant. `gvVisualEffect` is authoritative across sites: any string takes precedence\n(unknown strings render nothing), otherwise `gvSnowEffect === true` selects snow. The trusted\nenum value binding fills `effect`; nothing is copied into `gvPluginsState`. The popup's\nVisual effects row writes those legacy-bound keys on every site: in the Page layout topic\ncard on Gemini and AI Studio, and in a plugin site's plugin card once Voyager can run there\n(a site enabled for Prompt Manager or a plugin). `legacyEnableWrite` selects snow when enabled and `off` when\ndisabled; both clear the legacy boolean. No previous effect is remembered. PluginScope owns\nthe activation's selection while the page holds renderers through a graceful drain; re-enable\nresumes them, and page teardown destroys canvases, renderer frames and workers immediately.\n\nLocal `turnNavigator` on consumer Gemini requires literal `params: { \"dataBackend\": \"shared\" }`\n(engine >=1.9.0). It reads and edits the same native stars, levels and collapse state, waits for\nboth page-owned timestamp caches, and inherits the official rail’s admission. Its own plugin\nswitch controls the rail independently of **Show Timeline**. Both claim `timeline-rail`: the\nofficial setting wins on a cold page, while a mounted holder keeps the rail until disabled.\nTurn off **Show Timeline** before enabling a local replacement; turn the local plugin off before\nreturning to the official rail. Shared manifests stay within `https://gemini.google.com/*` and\ncannot override turn, route or account identity. Business, Enterprise and custom coverage\ndo not admit this native-data rail. The zero-request promise holds: `isEligibleCatalogHost` refuses every native host,\nso a Gemini page has no catalog host, never asks for a check and never reads a catalog cache, and\nthe background refuses a forced check for them too. A minimal Gemini plugin:\n\n```json\n{\n  \"id\": \"me.gemini-compact-turns\",\n  \"name\": \"Compact Gemini turns\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Tighter spacing between my messages\",\n  \"author\": \"me\",\n  \"category\": \"readability\",\n  \"license\": \"MIT\",\n  \"engine\": \">=1.0.0\",\n  \"tier\": \"declarative\",\n  \"matches\": [\"https://gemini.google.com/*\"],\n  \"contributes\": {\n    \"styles\": [{ \"css\": \".gv-plugin-compact-turn{margin-block:4px!important}\" }],\n    \"domOps\": [\n      {\n        \"op\": \"addClass\",\n        \"target\": { \"kind\": \"semantic\", \"key\": \"userTurn\" },\n        \"className\": \"gv-plugin-compact-turn\"\n      }\n    ]\n  }\n}\n```\n\nOwnership and precedence:\n\n- **`local.*` is reserved.** Ids are forced into it on import; `plugin:check`\n  rejects an official plugin in it and `mergePluginRecords` drops any builtin,\n  bundled or remote record that claims one. A local plugin can therefore never\n  replace an official one, and its enable state and settings in\n  `storage/pluginState.ts` never share a key with one.\n- **Merged last, outside the kill switch.** The remote catalog says nothing about\n  a user's own plugin, and an enabled local plugin never makes a page eligible\n  for a catalog check (`remote/hostCatalogPolicy.ts`). A kindless or official\n  source cannot serve a `local.*` id either: only `kind: 'local'` does.\n- **Storage.** Manifests live in `chrome.storage.local` under\n  `StorageKeys.PLUGIN_LOCAL_MANIFESTS` (at most 50; per import at most\n  1,000,000 characters read, 32 style entries and 1,000,000 characters of CSS\n  after file expansion, checked before anything is expanded or scanned). An\n  import writes the manifest and its `enabled: false` in one `storage.set`, so\n  no page ever sees the new version enabled. Mutations reject on a failed read\n  (never write back a map rebuilt from nothing), keep entries this build cannot\n  read, and hold the `gv-local-plugins` Web Lock (`storage/pluginStorageLock.ts`),\n  which every plugin-state writer shares, so neither a second popup nor a late\n  setting toggle or Drive restore can write an old `enabled: true` back over a\n  new version. Content scripts cannot share that lock, so they send setting\n  changes to the background (`requestPluginSetting`). They stay on the device:\n  the enable state rides the plugin-state Drive backup, but the manifests\n  themselves do not, and a restore may switch a local plugin off but never on;\n  export them to keep a copy.\n\n## Primitives (`verbs/`) and the `native` op\n\nShipped primitives (`verbs/<name>/contract.ts`): `formulaCopy` (since 1.3.0), `vimInput` (since 1.4.0,\n`composer` param, defaults to the adapter's `composer` selector) and `turnNavigator` (since 1.4.0,\n`turn` / `conversationIdPattern` / `scrollContainer` / `yieldWhen` / `position`, all optional and\ndefaulting to the adapter, plus the optional `conversationIdAttribute` attribute and `turnItem`\nselector since 1.5.0, and `dataBackend: \"shared\"` on consumer Gemini since 1.9.0) and `userLatex` (since 1.7.0, optional `turn` selector defaulting to the\nadapter's `userTurn`, plus an optional `unit` of `leaf` (default) or `turn` since 1.9.0; renders the LaTeX a user typed with Gemini's renderer and restores the text on\nunmount) and the code-block diagram primitives `mermaid`, `echarts` and `wavedrom` (all since\n1.7.0, optional `turn` / `codeBlock` / `code` / `codeLine` / `language` selectors defaulting to\nthe adapter's `assistantTurn` and `codeBlock`). Each is Gemini's renderer core plus the shared\nengine in `verbs/codeBlockDiagram.ts`, which draws a diagram in a shadow-root panel before the\nhost's untouched code block; a label counts as a language only when it is shaped like an id, so a\nhost's localized \"plain text\" label leaves the block to content detection. New diagram kinds follow\nthe PATTERN note at the top of that file, and each site gets one `diagram-rendering` plugin listing\nits diagram ops: ChatGPT's lists `echarts` and `wavedrom`, since ChatGPT draws Mermaid itself, and\n`mermaid` waits for a site that needs it. `codeCollapse` (since 1.8.0, optional\n`code` / `action` selectors, blocks from the adapter's `codeBlock` inside `assistantTurn`\nand outside `thinkingBlock`) is Gemini's long code block collapse on another site: one engine in\n`pages/content/codeBlockCollapse/engine.ts` drives both, Gemini with its blocks expanded until the\nuser collapses them, the primitive with them collapsed plus expand all / collapse all. The formula-copy,\nVim, Claude-timeline and ChatGPT-timeline builtins are now manifests that invoke these primitives;\nthe shared timeline engine lives in `../timeline/TimelineEngine.ts`. Its catalog adapter\n(`../timeline/adapters/catalog/`) accumulates markers across virtualized windows (`turnMerge.ts`)\nand keeps stars and hierarchy in per-site local keys, while stars remain available in Saved Library. Star ids come from the URL at\neach read or write (`conversationId.ts`); a manifest's `conversationIdPattern` wins over the site\nadapter's. A mounted timeline publishes that namespace (`activeStarNamespace.ts`) so ChatGPT\nexport's starred filter reads the same ids. A site with a `conversationIdPattern` cannot star a\nroute that does not match it. Without a site adapter the timeline stays inert: its stars, outline\nand placement are keyed by the site id. A star is written only for a turn proven to be the current conversation's:\nthe host's own id for it where the site names one (`conversationIdAttribute` on an ancestor, as\nClaude's `data-conv-id`, or inside the turn's `turnItem`; a turn without it is unstarrable), else\nthe URL when the turn entered the page (`turnOwnership.ts`).\n\n`tableCopy` (since 1.8.0, no parameters) finds tables inside the adapter's\n`assistantTurn` and puts a shadow-root toolbar before each reply table that copies it as\nMarkdown or TSV. The Markdown is conversation export's own `extractTable`, and TSV reads the same\n`collectTableRows`, so formulas stay LaTeX and copy never drifts from export.\n\n`collapse` (since 1.8.0) limits the visible height of long content and\nplaces one expand/collapse button after each target. Its optional `target` is a semantic key:\n`userTurn` (default), `assistantTurn` or `thinkingBlock`; raw selectors are not accepted.\n`maxPx` defaults to 320 and accepts finite numbers from 80 to 2000; `startCollapsed` defaults\nto `true`. Content at or below `maxPx + 16` pixels gets no toggle. Streaming content gains a\ntoggle when it grows past that threshold, and an expanded element stays expanded while the\nsame host element survives. Labels are localized, buttons work with the keyboard, and styling\nsupports Voyager's light/dark scheme and RTL layout. Unmount restores the host layout.\n\nBecause `target` is configurable, the contract has no fixed semantic requirements. Authors\nmust declare the chosen key in `requires.semantic`, including `userTurn` when omitting\n`target`; a missing adapter selector leaves the primitive inactive. Targeting `assistantTurn`\nclips the complete reply, including any thinking content inside it. Choose `thinkingBlock`\nseparately when only that content should collapse. The primitive has no host-specific checks:\navoid content that already has native collapse, such as DeepSeek's `.ds-collapsible-text`.\nThere is no expand-all control. A plugin can expose `maxPx` and `startCollapsed` as settings\nusing `\"params\": { \"target\": \"userTurn\", \"maxPx\": \"{{maxHeight}}\", \"startCollapsed\": \"{{startCollapsed}}\" }`.\n\n`sendShortcut` claims one composer send mode per page on every site: a second plugin\nreports a conflict while the current holder keeps running. The optional `mode`\n(since 1.9.0) is `ctrlEnterSends` (default) or `enterSends`. Gemini only accepts\nthe default, AI Studio only the latter, via the packaged page coordinator; Gemini\nEnterprise accepts neither. Composition, Process and keyCode 229 are left to the IME.\nLocal send plugins are open on Gemini and AI Studio. Disable the corresponding\nofficial send switch to hand the page's mode to a local plugin; the Safari fix\nremains paired with Gemini's packaged send owner.\n\n`turnNavigator` likewise claims `timeline-rail` on every site, by design: a second rail on\nChatGPT, Claude or DeepSeek is paused as a conflict while the mounted one keeps running.\n\n`draftSave` (since 1.8.0, no parameters; reads `composer` and\n`sendButton`, and stays inert without `conversationIdPattern`) is Gemini's draft auto-save on a\ncatalog site. It shares `pages/content/draftSave/draftStore.ts` (debounce, empty means remove,\nfive newest per prefix) and the composer reader/writer in `core/services/composer/`. Drafts are\nstored in `chrome.storage.local` as `gvDraft:<siteId>:conv:<id>`, or `gvDraft:<siteId>:new` for\nevery page without a conversation id, so they never meet Gemini's `gvDraft_<path>` entries.\nA draft is restored once per conversation entry and only into an empty composer; a restore\nstill loading is abandoned once the person types (`core/utils/userEvent.ts` `isUserEvent`, so a\nhost filling its composer while mounting does not count). A draft is dropped when the composer\nempties within two seconds of Enter, the send button or a form submit, or when that send moves\nthe new-chat page to its conversation; switching from one conversation to another never counts\nas a send. While a `sendShortcut` plugin holds the composer's Enter\n(`verbs/composerEnterOwner.ts`), Enter is never read as a send: only its button click is. ChatGPT temporary chats save\nnothing. These sites have no account index in their routes, so drafts are per site and\nconversation, not per account. Unmount stops everything and keeps stored drafts.\n\nOn Gemini, the same primitive selects the trusted Gemini policy and shares native\n`gvDraft_<full pathname>` records, including `/u/N` and Gem routes. Official and\nlocal draft plugins can run together; writes and five-record pruning are queued\non this page. A confirmed first send clears its source new-chat draft, while\nnavigation without a send keeps it. Restore checks route, account and input edits\nafter asynchronous waits, and never restores into an old-question edit input.\nUnload makes a best-effort final save; asynchronous storage cannot be guaranteed\nafter browser closure. Changing email within one account slot still shares stored\ndrafts. Gemini Enterprise stays inactive. Drafts and their key format survive\nany rollback of the plugin owner.\n\n`quoteReply` (since 1.8.0, no parameters; reads `assistantTurn` and\n`composer`) is Gemini's quote reply on a catalog site. Selecting text wholly inside one\nassistant turn shows a localized Quote button near the selection end; activating it appends the\ntext to the composer as a Markdown quote (every line prefixed `> `, a blank line after an\nexisting draft) and never sends. Selections touching a user turn or the adapter's\n`thinkingBlock` show nothing. Rendered KaTeX is quoted as its LaTeX source through\n`features/formulaCopy/formulaSource.ts`; line prefixing and Gemini math replacement are shared\nwith Gemini's quote reply in `core/utils/quotedText.ts`. A missing composer shows a toast and\nkeeps the selection. Unmount removes the button, its style and every listener synchronously.\n\n`copyButton` (since 1.8.0; required `target` of `userTurn`, `assistantTurn`, `thinkingBlock`\nor `codeBlock`; optional `format`, which accepts only `text` today) places a small copy button above each\nmatched element. It copies the element's visible text without Voyager's own controls and\nconfirms with a toast; the element itself is left untouched.\n\n`counterBadge` (since 1.8.0; `target` is `composer` or `assistantTurn`,\n`metric` is `chars` (default), `words`, `cjkChars` or `lines`) shows a small read-only count.\nFor `composer` it sits above the adapter's `composerRoot` (or the composer's parent), reads the\ndraft with `readComposer` and hides while the draft is empty; for `assistantTurn` each reply\ngets a badge at its end, recounted from `copyableText` once streaming settles. The label is CSS\ngenerated content, so copying, export and the counts themselves never include it. Shipped\nplugins use only the composer target.\n\nSome behaviour cannot be expressed as CSS or as the reversible DOM operations.\nA **primitive** is that behaviour, written once as first-party TypeScript inside\nthe extension and given a name a manifest can call:\n\n```jsonc\n{\n  \"engine\": \">=1.3.0\",\n  \"requires\": { \"handlers\": [\"formulaCopy\"] },\n  \"contributes\": {\n    \"domOps\": [{ \"op\": \"native\", \"handler\": \"formulaCopy\", \"params\": {} }],\n  },\n}\n```\n\nThe manifest picks a primitive and configures it. It never supplies logic: no\nconditions, no ordering, no code. `params` is configuration, checked by that\nprimitive's own hand-written guard before the primitive sees it (plan C1, D17).\nThat is what keeps a remote manifest data rather than remotely-hosted code, and\nit is why `native` is safe on the same channel as CSS.\n\nNative params may use `{{settingKey}}` in string values, recursively inside\nobjects and arrays. Parameter names and object keys stay literal. A whole-value\ntoken preserves the setting's number, boolean or string type; embedded tokens\nproduce a string. For example, `turnNavigator` can receive\n`\"params\": { \"position\": \"{{side}}\" }` from a select setting with `left` and\n`right` options. Expansion runs once, so tokens inside setting values remain\nliteral. Every referenced key must exist in `contributes.settings`, and the\nsetting type must fit the primitive contract wherever statically knowable.\nManifest loading checks substituted defaults. Each activation substitutes\nresolved stored values and runs the primitive's `validateParams`; an invalid\nresult skips the op through the same diagnostic path as invalid static params.\nSettings changes serialize disposal and reactivation for templated primitive\nops; static params keep the existing in-place update path.\n\nParams substitution requires engine 1.8.0, alongside any higher primitive or\nparameter floor. It has shipped, so plugins that rely only on it declare a concrete\n`\">=1.8.0\"`; `\">=next\"` is for a primitive or parameter that is still unreleased.\nThe capability floor remains 1.8.0 in later releases. Engine 1.7.0 passes tokens\nliterally to primitive guards, so a selector token can be accepted yet match\nnothing; enforcing the floor makes an older build report `needs-engine`.\n\nEach primitive is one folder, `verbs/<name>/`, and adding the folder is all\nthe registration there is:\n\n- **`contract.ts`** — data only: `export const contract` with the primitive's\n  `name` (the folder name), `sinceEngine`, the semantic keys it reads from the\n  adapter, and its parameter spec (`type`, `required`, `description`; a\n  param that names a semantic key the primitive reads is `type: 'semanticKey'`,\n  optionally narrowed by `allowedKeys`). It imports only types and data modules\n  (`contractTypes.ts`, `codeBlockDiagramParams.ts`), so Bun scripts and the CLI\n  can load it without DOM or implementations; `primitiveFolders.test.ts`\n  enforces this.\n- **`index.ts`** — the implementation: a primitive object carrying that\n  contract, a `validateParams` guard, and `activate(scope, params, context)`.\n  Every side effect is registered on the `PluginScope`, so unmount pays them\n  all back, and the primitive reports how many elements it acts on through\n  `context.setTargetCounter`.\n- **`paramsBaseline.json`** — the published params (D9, below).\n\n`verbs/contracts.ts` collects the contracts, sorted by name, through\n`import.meta.glob`; Bun has no glob, so Bun scripts and the CLI build feed the\nsame files from disk (`scripts/lib/primitiveContractsSeam.ts`). `verbs/registry.ts`\nfinds the implementations the same way, and `verifyPrimitiveRegistry()` names\nany folder whose contract and implementation disagree. The primitive enums in\n`docs/public/plugin.schema.json` are generated: run `bun run skills:build`\nafter adding a primitive (it also refreshes the public skill).\n\n`PLUGIN_ENGINE_VERSION` records the released engine version and is never\nhand-bumped by a contributor. A new primitive or optional parameter declares\n`sinceEngine: 'next'`; its dependent catalog plugin uses `\"engine\": \">=next\"`.\nThe shared resolver maps `next` to the released engine plus one minor, patch\nzero (1.8.0 → 1.9.0). While any contract or param is `next`, this tree runs that\neffective engine version, so its bundled unreleased plugins are usable.\n\nA manifest that uses a primitive must set an `engine` range whose **minimum** is\nat least its resolved `sinceEngine`, and `bun run catalog:build` fails the build\notherwise (plan §5, §8). Contributor checks, CLI checks and bundled loading\nresolve the same notation. Catalog publishing writes only concrete versions,\nso a released extension filters an update requiring the next engine without\nmistaking it for a remote kill switch. Local imports require concrete versions;\nthe public authoring skill and CLI scaffold document resolved primitive floors.\n\n`bun run bump` stamps all unpublished contracts, param baselines and dependent\ncatalog manifest ranges, and advances the engine only when `next` exists. A\nrelease gate rejects any remaining `next`; extension-version bumps without\nunpublished engine additions leave the engine version unchanged.\n\nThe primitive engine floor makes an older Voyager report `needs-engine`\n(\"update Voyager\") instead of `needs-handler`, which means a configuration\nmistake. `catalog:build` also rejects a handler\nwith no contract, and a semantic key the plugin's own site does not define.\n\nA param added to a shipped primitive carries its own `sinceEngine` (D9: params\nare only ever added). An older engine rejects a param it does not know and skips\nthe whole op, so a manifest that sets the param needs an `engine` floor at least\nthat high; `plugin:check` and `catalog:build` enforce it alongside the primitive\nfloor, and `builtin.test.ts` applies the same check to the builtins.\n\n`requires` states the same needs declaratively:\n\n- `requires.handlers` lists the primitives the plugin invokes. Every `native` op\n  is implied, so listing it is documentation, not duplication.\n- `requires.semantic` lists semantic keys the plugin depends on beyond the ones\n  its ops target. A primitive's own `semantic` keys come from its contract, and\n  the key a `semanticKey` param names (every option, when the param is a select\n  setting's `{{key}}` token) comes from the op's params.\n\n`runtime/pluginStatus.ts` derives both sets (`requiredHandlers`,\n`requiredSemanticKeys`) and turns them into a status, which `PluginHost` reports\nfor every plugin that targets the page instead of quietly filtering it:\n\n| Kind             | Meaning                                 | Popup                                         |\n| ---------------- | --------------------------------------- | --------------------------------------------- |\n| `needs-engine`   | `engine` range above this build         | toggle disabled, \"needs Voyager ≥ x.y\"        |\n| `needs-handler`  | a primitive or runtime it lacks         | toggle disabled, \"update Voyager\"             |\n| `needs-semantic` | the site adapter lacks a key it needs   | toggle disabled, names the site               |\n| `ready`          | compatible, not mounted yet             | normal toggle                                 |\n| `mounted`        | enabled and running on this page        | normal toggle                                 |\n| `no-effect`      | mounted but found nothing (D12)         | toggle stays on, yellow warning               |\n| `blocked`        | compatible, kept off by `blockedReason` | toggle keeps the user's choice, note says why |\n\n`blockedReason` (with `blockedBy` naming the other plugin where there is one):\n\n| Reason            | Meaning                                                                                       | Clears                              |\n| ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------- |\n| `host-policy`     | this build refuses its `theme` or a `native` op on this site                                  | in a build that allows it           |\n| `duplicate-claim` | the manifest starts one page-wide runtime twice                                               | when the manifest is fixed          |\n| `conflict`        | `blockedBy` holds or reserves a page-wide runtime it needs                                    | when the user turns one of them off |\n| `releasing`       | next in line, waiting for `blockedBy` to finish shutting the runtime down                     | by itself                           |\n| `pending`         | `blockedBy\n</yakaai_skills>"}