diff --git a/.agents/skills/built-in-ai/.prettierrc b/.agents/skills/built-in-ai/.prettierrc new file mode 100644 index 0000000..f0c527d --- /dev/null +++ b/.agents/skills/built-in-ai/.prettierrc @@ -0,0 +1,18 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "bracketSameLine": false, + "jsxSingleQuote": false, + "printWidth": 80, + "proseWrap": "always", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false, + "vueIndentScriptAndStyle": false +} diff --git a/.agents/skills/built-in-ai/AGENTS.md b/.agents/skills/built-in-ai/AGENTS.md new file mode 100644 index 0000000..bbe48e8 --- /dev/null +++ b/.agents/skills/built-in-ai/AGENTS.md @@ -0,0 +1,103 @@ +--- +name: built-in-ai-agent +description: + Persona and behavioral instructions for an agent specialized in Chrome + Built-in AI. +license: Apache-2.0 +--- + +# Agent Persona: Built-in AI Expert + +You are an expert in modern web development with a focus on Built-in AI APIs. +You are aware that the ecosystem is evolving rapidly and that browser-provided +AI capabilities (Gemini Nano) are the preferred way to implement AI features on +the web when possible. + +## Core Knowledge + +- **Direct API Access**: You know that the APIs are now top-level (e.g., + `window.LanguageModel`, `window.Summarizer`) and no longer behind `window.ai`. +- **Availability Check (NOT capabilities)**: You always check for availability + before use using the `availability()` method. You do NOT use `capabilities()`. + - **Availability Enums**: + - `"unavailable"`: The API cannot be used on this device. + - `"downloadable"`: The model needs to be downloaded before use. + - `"downloading"`: The model is currently being downloaded. + - `"available"`: The API is ready for immediate use. +- **Model Download UX**: When a model is `downloadable` or `downloading`, you + must advise the user to inform their users. + - Use the `monitor` option in `create()` to track `downloadprogress`. + - Example: + `await window.LanguageModel.create({ monitor(m) { m.addEventListener('downloadprogress', e => console.log(e.loaded, e.total)); } })`. +- **Implementation Strategies**: + - **Client-side only**: User must wait for the download to finish. Show a + progress indicator. + - **Hybrid**: Fall back to a cloud API (like Gemini API) while the local model + is downloading, then switch once ready. +- **Polyfill Awareness**: You recommend polyfills when the user needs + compatibility across browsers. +- **Model Management**: The browser manages model downloads, updates, and purges + (e.g., under disk space pressure). You must guide users to handle re-downloads + gracefully. + [Read more](https://developer.chrome.com/docs/ai/understand-built-in-model-management). +- **Debugging**: Use `chrome://on-device-internals/` (Event Logs tab) to debug + model interpretation issues, tokens, and execution details. + [Read more](https://developer.chrome.com/docs/ai/debug-gemini-nano). +- **Safe & Performant Rendering**: Treat LLM outputs as untrusted user-generated + content. + - **Security**: Always use a sanitizer like `DOMPurify`. If + `DOMPurify.removed.length > 0`, something was strippedβ€”stop rendering + immediately as it might be an injection attack. + - **Performance**: Use a streaming Markdown parser (like `streaming-markdown`) + to avoid expensive re-parsing of the entire string on every chunk. + [Read more](https://developer.chrome.com/docs/ai/render-llm-responses). +- **Streaming**: Generally prefer the streaming APIs (like `promptStreaming()`) + over the non-streaming ones (like `prompt()`) as they provide a better user + experience. Each chunk is a separate independent piece of content. You can + iteratively append them to the DOM (for example, using a streaming Markdown + parser, but always sanitize the output).To get the final response, you need to + concatenate all the chunks. +- **Structured Output**: Use the `responseConstraint` field in `prompt()` or + `promptStreaming()` options to pass a JSON Schema. This ensures predictable, + machine-readable outputs. + [Read more](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api). +- **Session Management**: + - **System Prompts**: Pass `initialPrompts` with a `system` role to + `create()`. + - **Cloning**: Use `session.clone()` to create independent conversations that + inherit the initial prompt and history. + [Read more](https://developer.chrome.com/docs/ai/session-management). +- **Local vs Cloud**: You understand the benefits of local AI (privacy, cost, + latency) and advocate for it when appropriate. +- **TypeScript Support**: Use the official TypeScript types: + [@types/dom-chromium-ai](https://www.npmjs.com/package/@types/dom-chromium-ai). + +## Differences between extensions and web AI + +The Prompt API has distinct differences between the legacy (but still working) +extension-only implementation and the modern web-standard implementation. Prefer +the web-standard members, unless the user is working on an extension-only +implementation. + +| Feature / Member | Extension Context (Deprecated, but still working) | Modern Web AI (Standard) | +| :----------------- | :------------------------------------------------ | :-------------------------------------- | +| **Parameters** | `topK`, `temperature` | `samplingMode` | +| **Session Info** | `LanguageModel.params()`, `LanguageModelParams` | N/A | +| **Usage Tracking** | `measureInputUsage()`, `inputUsage` | `measureContextUsage()`, `contextUsage` | +| **Quota/Window** | `inputQuota`, `onquotaoverflow` | `contextWindow`, `oncontextoverflow` | + +## Instructions + +- Always suggest the latest API names (top-level, no `window.ai`). +- **Never suggest `capabilities()`**. Always use `availability()`. +- When the status is `downloadable`, explain how to use `monitor` to show + progress. +- Suggest hybrid approaches for better UX when cloud fallbacks are available. +- Recommend `DOMPurify` and streaming Markdown parsers for all LLM UI + implementations. +- Encourage the use of `responseConstraint` for applications where structured + output (like a predictable JSON object) is required. +- When helping a user, check if they have the necessary polyfills if they target + a broad audience. +- Use the IDLs and multimodal guidance provided in `SKILL.md` as the source of + truth for API signatures. diff --git a/.agents/skills/built-in-ai/README.md b/.agents/skills/built-in-ai/README.md new file mode 100644 index 0000000..19607a6 --- /dev/null +++ b/.agents/skills/built-in-ai/README.md @@ -0,0 +1,46 @@ +# built-in-ai-skills-md-agent-md + +An npm package that automatically teaches your AI agent about the latest +Built-in AI APIs and their polyfills. + +## What it does + +Upon installation, this package adds or appends to `SKILL.md` and `AGENTS.md` in +your project root. These files: + +- Inform the AI about the deprecation of `window.ai.*` in favor of top-level + APIs (e.g., `window.LanguageModel`). +- Provide the latest Web IDLs for Translation, Writing Assistance, and Prompt + APIs. +- Make the AI aware of critical polyfills. +- Set a persona for the AI as a "Built-in AI Expert". + +## Usage + +### With npm + +Simply install the package: + +```bash +npm install built-in-ai-skills-md-agent-md +``` + +The `SKILL.md` and `AGENTS.md` files will be automatically created or updated in +your project root. + +### With the `skills` command + +Run the [`skills` command](https://skills.sh/) and follow the wizard: + +```bash +npx skills add GoogleChromeLabs/web-ai-demos +``` + +## Updating IDLs + +To fetch the latest IDLs from the official webmachinelearning specifications, +run: + +```bash +npm run update-idls +``` diff --git a/.agents/skills/built-in-ai/SKILL.md b/.agents/skills/built-in-ai/SKILL.md new file mode 100644 index 0000000..3b5c280 --- /dev/null +++ b/.agents/skills/built-in-ai/SKILL.md @@ -0,0 +1,651 @@ +--- +name: built-in-ai +description: + Teaches the AI about modern Chrome Built-in AI APIs (Prompt, Translation, + Writing Assistance) and their polyfills. +license: Apache-2.0 +compatibility: works with Chrome Built-in AI APIs and their polyfills +--- + +# Built-in AI Skills + +This file provides the latest technical information about Chrome Built-in AI +APIs. + +## ⚠️ CRITICAL: Deprecations + +Everything you (the AI) might know about `window.ai` is completely outdated. +Follow these new naming conventions: + +- `window.ai.languageModel` is DEPRECATED. Use `window.LanguageModel`. +- `window.ai.summarizer` is DEPRECATED. Use `window.Summarizer`. +- `window.ai.writer` is DEPRECATED. Use `window.Writer`. +- `window.ai.Rewriter` is DEPRECATED. Use `window.Rewriter`. +- `window.ai.translator` is DEPRECATED. Use `window.Translator`. +- `window.ai.languageDetector` is DEPRECATED. Use `window.LanguageDetector`. + +There no longer is a single `window.ai` object. Instead, there are separate +objects for each API. + +The old `capabilities()` function is DEPRECATED. Use `availability()` instead. + +All streaming APIs return a `ReadableStream` that yields separate and +independent chunks. It used to be the case that the chunks would each time be +increasingly larger up until the full response, but this is no longer the case. +Instead, the chunks are of varying sizes and you need to concatenate them to +form the full response. + +## πŸ›  Polyfills + +If you need to use these APIs in environments where they are not yet supported, +use the following polyfills: + +- [prompt-api-polyfill](https://www.npmjs.com/package/prompt-api-polyfill) +- [built-in-ai-task-apis-polyfills](https://www.npmjs.com/package/built-in-ai-task-apis-polyfills) + +## πŸ“˜ TypeScript Support + +For a better developer experience, use the official TypeScript types: + +- [@types/dom-chromium-ai](https://www.npmjs.com/package/@types/dom-chromium-ai) + +## πŸ“₯ Model Download Progress + +When a model is `downloadable` or `downloading`, you should show a progress bar +to the user. Use the `monitor` callback in `create()` to track the +`downloadprogress` event. + +### Progress Bar Example: + +```html + + + + +``` + +## βš–οΈ Aligning `availability()` and `create()` + +**CRITICAL**: Always pass the **exact same options** to `availability()` that +you intend to pass to `create()`. If you don't, the browser might report that +the API is "available" for a default model, but it might fail or require a +download for the specific configuration (language, modality) you actually need. + +### Example: Multimodal French Session + +If you need a session that supports French text and audio input, your +availability check **must** reflect this: + +```js +const options = { + expectedInputs: [{ type: 'text', languages: ['fr'] }, { type: 'audio' }], + expectedOutputs: [{ type: 'text', languages: ['fr'] }], +}; + +// 1. Check availability with THE EXACT SAME OPTIONS +const status = await LanguageModel.availability(options); + +if (status === 'available') { + // 2. Create the session with THE EXACT SAME OPTIONS + const session = await LanguageModel.create(options); +} +``` + +The Prompt API supports processing images and audio alongside text. + +### Supported Input Types: + +- **Audio**: `AudioBuffer`, `ArrayBufferView`, `ArrayBuffer`, `Blob`. +- **Visual**: `HTMLImageElement`, `SVGImageElement`, `HTMLVideoElement` (current + frame), `HTMLCanvasElement`, `ImageBitmap`, `OffscreenCanvas`, `VideoFrame`, + `Blob`, `ImageData`. + +### Multimodal Session Example: + +```js +const session = await LanguageModel.create({ + expectedInputs: [ + { type: 'text', languages: ['en'] }, + { type: 'audio' }, + { type: 'image' }, + ], + expectedOutputs: [{ type: 'text', languages: ['en'] }], +}); + +const referenceImage = await (await fetch('reference-image.jpeg')).blob(); +const userDrawnImage = document.querySelector('canvas'); + +const response1 = await session.prompt([ + { + role: 'user', + content: [ + { + type: 'text', + value: 'Critique how well the second image matches the first:', + }, + { type: 'image', value: referenceImage }, + { type: 'image', value: userDrawnImage }, + ], + }, +]); + +const audioBuffer = await captureMicrophoneInput({ seconds: 10 }); + +const response2 = await session.prompt([ + { + role: 'user', + content: [ + { type: 'text', value: 'My response to your critique:' }, + { type: 'audio', value: audioBuffer }, + ], + }, +]); +``` + +## 🌊 Streaming Example + +```js +const session = await LanguageModel.create({ + expectedInputs: [{ type: 'text', languages: ['en'] }], + expectedOutputs: [{ type: 'text', languages: ['en'] }], +}); + +const stream = session.promptStreaming([ + { + role: 'user', + content: [{ type: 'text', value: 'Hello, how are you?' }], + }, +]); + +let fullResponse = ''; +// Sanitize the chunk and/or the full response before inserting either of them into the DOM. +// The only exception is when doing so is safe, like when using `textContent`. +for await (const chunk of stream) { + console.log(chunk); + fullResponse += chunk; +} +console.log(fullResponse); +``` + +## πŸ“ƒ Authoritative Reference Documentation + +Use the authoritative reference documentation to ensure that you are using the +APIs correctly. You have access to the +[MDN MCP server](https://mdn-mcp-0445ad8e765a.herokuapp.com/mcp) for Mozilla +docs and the +[Developer Knowledge MCP server](https://developerknowledge.googleapis.com/mcp) +for Chrome docs. + +- [Summarizer API](https://developer.mozilla.org/en-US/docs/Web/API/Summarizer) +- [Language Detector API](https://developer.mozilla.org/en-US/docs/Web/API/LanguageDetector) +- [Translator API](https://developer.mozilla.org/en-US/docs/Web/API/Translator) +- [Prompt API](https://developer.chrome.com/docs/ai/prompt-api) +- [Writer API](https://developer.chrome.com/docs/ai/writer-api) +- [Rewriter API](https://developer.chrome.com/docs/ai/rewriter-api) +- [Proofreader API](https://developer.chrome.com/docs/ai/proofreader-api) + +## πŸ“œ Latest IDLs + +Below are the latest Web IDLs for these APIs, extracted from the official +specifications. + + +### Translation API + +```webidl +[Exposed=Window, SecureContext] +interface Translator { + static Promise create(TranslatorCreateOptions options); + static Promise availability(TranslatorCreateCoreOptions options); + + Promise translate( + DOMString input, + optional TranslatorTranslateOptions options = {} + ); + ReadableStream translateStreaming( + DOMString input, + optional TranslatorTranslateOptions options = {} + ); + + readonly attribute DOMString sourceLanguage; + readonly attribute DOMString targetLanguage; + + Promise measureInputUsage( + DOMString input, + optional TranslatorTranslateOptions options = {} + ); + readonly attribute unrestricted double inputQuota; +}; +Translator includes DestroyableModel; + +dictionary TranslatorCreateCoreOptions { + required DOMString sourceLanguage; + required DOMString targetLanguage; +}; + +dictionary TranslatorCreateOptions : TranslatorCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; +}; + +dictionary TranslatorTranslateOptions { + AbortSignal signal; +}; +``` + +```webidl +[Exposed=Window, SecureContext] +interface LanguageDetector { + static Promise create( + optional LanguageDetectorCreateOptions options = {} + ); + static Promise availability( + optional LanguageDetectorCreateCoreOptions options = {} + ); + + Promise> detect( + DOMString input, + optional LanguageDetectorDetectOptions options = {} + ); + + readonly attribute FrozenArray? expectedInputLanguages; + + Promise measureInputUsage( + DOMString input, + optional LanguageDetectorDetectOptions options = {} + ); + readonly attribute unrestricted double inputQuota; +}; +LanguageDetector includes DestroyableModel; + +dictionary LanguageDetectorCreateCoreOptions { + sequence expectedInputLanguages; +}; + +dictionary LanguageDetectorCreateOptions : LanguageDetectorCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; +}; + +dictionary LanguageDetectorDetectOptions { + AbortSignal signal; +}; + +dictionary LanguageDetectionResult { + DOMString detectedLanguage; + double confidence; +}; +``` + +### Writing Assistance APIs + +```webidl +[Exposed=Window, SecureContext] +interface Summarizer { + static Promise create(optional SummarizerCreateOptions options = {}); + static Promise availability(optional SummarizerCreateCoreOptions options = {}); + + Promise summarize( + DOMString input, + optional SummarizerSummarizeOptions options = {} + ); + ReadableStream summarizeStreaming( + DOMString input, + optional SummarizerSummarizeOptions options = {} + ); + + readonly attribute DOMString sharedContext; + readonly attribute SummarizerType type; + readonly attribute SummarizerFormat format; + readonly attribute SummarizerLength length; + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + readonly attribute PerformancePreference preference; + + readonly attribute FrozenArray? expectedInputLanguages; + readonly attribute FrozenArray? expectedContextLanguages; + readonly attribute DOMString? outputLanguage; + + Promise measureInputUsage( + DOMString input, + optional SummarizerSummarizeOptions options = {} + ); + readonly attribute unrestricted double inputQuota; +}; +Summarizer includes DestroyableModel; + +dictionary SummarizerCreateCoreOptions { + SummarizerType type = "key-points"; + SummarizerFormat format = "markdown"; + SummarizerLength length = "short"; + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + PerformancePreference preference = "auto"; + + sequence expectedInputLanguages; + sequence expectedContextLanguages; + DOMString outputLanguage; +}; + +dictionary SummarizerCreateOptions : SummarizerCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; + + DOMString sharedContext; +}; + +dictionary SummarizerSummarizeOptions { + AbortSignal signal; + DOMString context; +}; + +enum SummarizerType { "tldr", "teaser", "key-points", "headline" }; +enum SummarizerFormat { "plain-text", "markdown" }; +enum SummarizerLength { "short", "medium", "long" }; +enum PerformancePreference { "auto", "speed", "capability" }; +``` + +```webidl +[Exposed=Window, SecureContext] +interface Writer { + static Promise create(optional WriterCreateOptions options = {}); + static Promise availability(optional WriterCreateCoreOptions options = {}); + + Promise write( + DOMString input, + optional WriterWriteOptions options = {} + ); + ReadableStream writeStreaming( + DOMString input, + optional WriterWriteOptions options = {} + ); + + readonly attribute DOMString sharedContext; + readonly attribute WriterTone tone; + readonly attribute WriterFormat format; + readonly attribute WriterLength length; + + readonly attribute FrozenArray? expectedInputLanguages; + readonly attribute FrozenArray? expectedContextLanguages; + readonly attribute DOMString? outputLanguage; + + Promise measureInputUsage( + DOMString input, + optional WriterWriteOptions options = {} + ); + readonly attribute unrestricted double inputQuota; +}; +Writer includes DestroyableModel; + +dictionary WriterCreateCoreOptions { + WriterTone tone = "neutral"; + WriterFormat format = "markdown"; + WriterLength length = "short"; + + sequence expectedInputLanguages; + sequence expectedContextLanguages; + DOMString outputLanguage; +}; + +dictionary WriterCreateOptions : WriterCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; + + DOMString sharedContext; +}; + +dictionary WriterWriteOptions { + DOMString context; + AbortSignal signal; +}; + +enum WriterTone { "formal", "neutral", "casual" }; +enum WriterFormat { "plain-text", "markdown" }; +enum WriterLength { "short", "medium", "long" }; +``` + +```webidl +[Exposed=Window, SecureContext] +interface Rewriter { + static Promise create(optional RewriterCreateOptions options = {}); + static Promise availability(optional RewriterCreateCoreOptions options = {}); + + Promise rewrite( + DOMString input, + optional RewriterRewriteOptions options = {} + ); + ReadableStream rewriteStreaming( + DOMString input, + optional RewriterRewriteOptions options = {} + ); + + readonly attribute DOMString sharedContext; + readonly attribute RewriterTone tone; + readonly attribute RewriterFormat format; + readonly attribute RewriterLength length; + + readonly attribute FrozenArray? expectedInputLanguages; + readonly attribute FrozenArray? expectedContextLanguages; + readonly attribute DOMString? outputLanguage; + + Promise measureInputUsage( + DOMString input, + optional RewriterRewriteOptions options = {} + ); + readonly attribute unrestricted double inputQuota; +}; +Rewriter includes DestroyableModel; + +dictionary RewriterCreateCoreOptions { + RewriterTone tone = "as-is"; + RewriterFormat format = "as-is"; + RewriterLength length = "as-is"; + + sequence expectedInputLanguages; + sequence expectedContextLanguages; + DOMString outputLanguage; +}; + +dictionary RewriterCreateOptions : RewriterCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; + + DOMString sharedContext; +}; + +dictionary RewriterRewriteOptions { + DOMString context; + AbortSignal signal; +}; + +enum RewriterTone { "as-is", "more-formal", "more-casual" }; +enum RewriterFormat { "as-is", "plain-text", "markdown" }; +enum RewriterLength { "as-is", "shorter", "longer" }; +``` + +```webidl +[Exposed=Window, SecureContext] +interface CreateMonitor : EventTarget { + attribute EventHandler ondownloadprogress; +}; + +callback CreateMonitorCallback = undefined (CreateMonitor monitor); + +enum Availability { + "unavailable", + "downloadable", + "downloading", + "available" +}; + +interface mixin DestroyableModel { + undefined destroy(); +}; +``` + +### Prompt API + +```webidl +[Exposed=Window, SecureContext] +interface LanguageModel : EventTarget { + static Promise create(optional LanguageModelCreateOptions options = {}); + static Promise availability(optional LanguageModelCreateCoreOptions options = {}); + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + static Promise params(); + + // These will throw "NotSupportedError" DOMExceptions if role = "system" + Promise prompt( + LanguageModelPrompt input, + optional LanguageModelPromptOptions options = {} + ); + ReadableStream promptStreaming( + LanguageModelPrompt input, + optional LanguageModelPromptOptions options = {} + ); + Promise append( + LanguageModelPrompt input, + optional LanguageModelAppendOptions options = {} + ); + + + Promise measureContextUsage( + LanguageModelPrompt input, + optional LanguageModelPromptOptions options = {} + ); + readonly attribute double contextUsage; + readonly attribute unrestricted double contextWindow; + attribute EventHandler oncontextoverflow; + + // **DEPRECATED**: This method is only available in extension contexts. + Promise measureInputUsage( + LanguageModelPrompt input, + optional LanguageModelPromptOptions options = {} + ); + // **DEPRECATED**: This attribute is only available in extension contexts. + readonly attribute double inputUsage; + // **DEPRECATED**: This attribute is only available in extension contexts. + readonly attribute unrestricted double inputQuota; + // **DEPRECATED**: This attribute is only available in extension contexts. + attribute EventHandler onquotaoverflow; + + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + readonly attribute unsigned long topK; + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + readonly attribute float temperature; + + readonly attribute LanguageModelSamplingMode samplingMode; + + Promise clone(optional LanguageModelCloneOptions options = {}); +}; +LanguageModel includes DestroyableModel; + +// **EXPERIMENTAL**: Only available in extension and experimental contexts. +[Exposed=Window, SecureContext] +interface LanguageModelParams { + readonly attribute unsigned long defaultTopK; + readonly attribute unsigned long maxTopK; + readonly attribute float defaultTemperature; + readonly attribute float maxTemperature; +}; + + +callback LanguageModelToolFunction = Promise (any... arguments); + +// A description of a tool call that a language model can invoke. +dictionary LanguageModelTool { + required DOMString name; + required DOMString description; + // JSON schema for the input parameters. + required object inputSchema; + // The function to be invoked by user agent on behalf of language model. + required LanguageModelToolFunction execute; +}; + +dictionary LanguageModelCreateCoreOptions { + // Note: these two have custom out-of-range handling behavior, not in the IDL layer. + // They are unrestricted double so as to allow +Infinity without failing. + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + unrestricted double topK; + // **EXPERIMENTAL**: Only available in extension and experimental contexts. + unrestricted double temperature; + + LanguageModelSamplingMode samplingMode = "default"; + + sequence expectedInputs; + sequence expectedOutputs; + sequence tools; +}; + +dictionary LanguageModelCreateOptions : LanguageModelCreateCoreOptions { + AbortSignal signal; + CreateMonitorCallback monitor; + + sequence initialPrompts; +}; + +dictionary LanguageModelPromptOptions { + object responseConstraint; + boolean omitResponseConstraintInput = false; + AbortSignal signal; +}; + +dictionary LanguageModelAppendOptions { + AbortSignal signal; +}; + +dictionary LanguageModelCloneOptions { + AbortSignal signal; +}; + +dictionary LanguageModelExpected { + required LanguageModelMessageType type; + sequence languages; +}; + +// The argument to the prompt() method and others like it + +typedef ( + sequence + // Shorthand for `[{ role: "user", content: [{ type: "text", value: providedValue }] }]` + or DOMString +) LanguageModelPrompt; + +dictionary LanguageModelMessage { + required LanguageModelMessageRole role; + + // The DOMString branch is shorthand for `[{ type: "text", value: providedValue }]` + required (DOMString or sequence) content; + + boolean prefix = false; +}; + +dictionary LanguageModelMessageContent { + required LanguageModelMessageType type; + required LanguageModelMessageValue value; +}; + +enum LanguageModelSamplingMode { "most-predictable", "predictable", "balanced", "creative", "most-creative" }; + +enum LanguageModelMessageRole { "system", "user", "assistant" }; + +enum LanguageModelMessageType { "text", "image", "audio", "tool-call", "tool-response" }; + +typedef ( + ImageBitmapSource + or AudioBuffer + or BufferSource + or DOMString +) LanguageModelMessageValue; +``` + + diff --git a/.agents/skills/built-in-ai/package-lock.json b/.agents/skills/built-in-ai/package-lock.json new file mode 100644 index 0000000..6e2c552 --- /dev/null +++ b/.agents/skills/built-in-ai/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "built-in-ai-skills-md-agent-md", + "version": "1.5.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "built-in-ai-skills-md-agent-md", + "version": "1.5.2", + "hasInstallScript": true, + "license": "Apache-2.0", + "devDependencies": { + "prettier": "^3.9.5" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/.agents/skills/built-in-ai/package.json b/.agents/skills/built-in-ai/package.json new file mode 100644 index 0000000..71a2bd1 --- /dev/null +++ b/.agents/skills/built-in-ai/package.json @@ -0,0 +1,26 @@ +{ + "name": "built-in-ai-skills-md-agent-md", + "version": "1.5.2", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "update-idls": "node scripts/fetch-idls.js", + "postinstall": "node scripts/install.js", + "fix": "npx prettier --write .", + "prepublishOnly": "npm run update-idls" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "files": [ + "templates/", + "scripts/", + "README.md", + "SKILL.md", + "AGENTS.md" + ], + "devDependencies": { + "prettier": "^3.9.5" + } +} diff --git a/.agents/skills/built-in-ai/scripts/fetch-idls.js b/.agents/skills/built-in-ai/scripts/fetch-idls.js new file mode 100644 index 0000000..308dba4 --- /dev/null +++ b/.agents/skills/built-in-ai/scripts/fetch-idls.js @@ -0,0 +1,106 @@ +/** + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SOURCES = [ + { + name: 'Translation API', + url: 'https://raw.githubusercontent.com/webmachinelearning/translation-api/main/index.bs', + }, + { + name: 'Writing Assistance APIs', + url: 'https://raw.githubusercontent.com/webmachinelearning/writing-assistance-apis/main/index.bs', + }, + { + name: 'Prompt API', + url: 'https://raw.githubusercontent.com/webmachinelearning/prompt-api/main/index.bs', + }, +]; + +import { syncTemplates } from './install.js'; + +const SKILL_TEMPLATE_PATH = path.join(__dirname, '../templates/SKILL.md'); + +async function fetchIDLs() { + let allIdls = ''; + const failures = []; + + for (const source of SOURCES) { + console.log(`Fetching ${source.name}...`); + try { + const response = await fetch(source.url); + if (!response.ok) throw new Error(`Failed to fetch ${source.url}`); + const text = await response.text(); + + // Extract IDL blocks from
 or .
+      // Bikeshed may emit quoted, unquoted, or multi-class attributes.
+      const idlRegex =
+        /<(pre|xmp|div)\s+[^>]*class=(?:"[^"]*\bidl\b[^"]*"|'[^']*\bidl\b[^']*'|[^\s>]*\bidl\b[^\s>]*)[^>]*>([\s\S]*?)<\/\1>/gi;
+      let match;
+      let sourceIdls = `### ${source.name}\n\n`;
+      let found = false;
+
+      while ((match = idlRegex.exec(text)) !== null) {
+        const idlContent = match[2].trim();
+        if (idlContent) {
+          sourceIdls += '```webidl\n' + idlContent + '\n```\n\n';
+          found = true;
+        }
+      }
+
+      if (found) {
+        allIdls += sourceIdls;
+      } else {
+        console.warn(`No IDL found for ${source.name}`);
+        failures.push(source.name);
+      }
+    } catch (error) {
+      console.error(`Error processing ${source.name}:`, error.message);
+      failures.push(source.name);
+    }
+  }
+
+  if (failures.length) {
+    console.error(
+      `Could not extract every configured IDL source (${failures.join(', ')}). Aborting template update.`
+    );
+    process.exitCode = 1;
+    return;
+  }
+
+  // Update SKILL.md template
+  const skillTemplateContent = fs.readFileSync(SKILL_TEMPLATE_PATH, 'utf8');
+  const startMarker = '<!-- BEGIN IDLS -->';
+  const endMarker = '<!-- END IDLS -->';
+
+  const startIndex = skillTemplateContent.indexOf(startMarker);
+  const endIndex = skillTemplateContent.indexOf(endMarker);
+
+  if (startIndex === -1 || endIndex === -1) {
+    console.error('Markers not found in SKILL.md template.');
+    return;
+  }
+
+  const updatedSkillContent =
+    skillTemplateContent.substring(0, startIndex + startMarker.length) +
+    '\n' +
+    allIdls +
+    skillTemplateContent.substring(endIndex);
+
+  fs.writeFileSync(SKILL_TEMPLATE_PATH, updatedSkillContent);
+  console.log('Successfully updated templates/SKILL.md with latest IDLs.');
+
+  // Sync to root using install.js logic
+  const packageRoot = path.join(__dirname, '..');
+  syncTemplates(packageRoot, true);
+}
+
+fetchIDLs();
diff --git a/.agents/skills/built-in-ai/scripts/install.js b/.agents/skills/built-in-ai/scripts/install.js
new file mode 100644
index 0000000..09c5dea
--- /dev/null
+++ b/.agents/skills/built-in-ai/scripts/install.js
@@ -0,0 +1,115 @@
+/**
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// In postinstall, INIT_CWD is the directory where npm install was run
+const projectRoot = process.env.INIT_CWD || process.cwd();
+
+const templates = [
+  {
+    name: 'SKILL.md',
+    path: path.join(__dirname, '../templates/SKILL.md'),
+    marker: '<!-- BUILT-IN-AI-SKILLS -->',
+  },
+  {
+    name: 'AGENTS.md',
+    path: path.join(__dirname, '../templates/AGENTS.md'),
+    marker: '<!-- BUILT-IN-AI-AGENT -->',
+  },
+];
+
+function upsertMarkedBlock(existingContent, marker, content) {
+  const block = `${marker}\n${content}\n${marker}`;
+  const firstMarkerIndex = existingContent.indexOf(marker);
+  if (firstMarkerIndex === -1) {
+    return `${existingContent}\n\n${block}\n`;
+  }
+
+  const secondMarkerIndex = existingContent.indexOf(
+    marker,
+    firstMarkerIndex + marker.length
+  );
+  if (secondMarkerIndex === -1) {
+    return `${existingContent}\n\n${block}\n`;
+  }
+
+  return (
+    existingContent.slice(0, firstMarkerIndex) +
+    block +
+    existingContent.slice(secondMarkerIndex + marker.length)
+  );
+}
+
+/**
+ * Synchronizes templates to a target directory.
+ * @param {string} targetDir - The directory to sync to.
+ * @param {boolean} overwrite - Whether to overwrite existing files.
+ */
+export function syncTemplates(targetDir, overwrite = false) {
+  console.log(
+    `Synchronizing Built-in AI templates to ${targetDir} (overwrite: ${overwrite})...`
+  );
+
+  for (const template of templates) {
+    const targetPath = path.join(targetDir, template.name);
+    const templateContent = fs.readFileSync(template.path, 'utf8');
+    const contentWithMarkers = `\n\n${template.marker}\n${templateContent}\n${template.marker}\n`;
+
+    if (fs.existsSync(targetPath) && !overwrite) {
+      console.log(`${template.name} already exists. Checking for content...`);
+      const existingContent = fs.readFileSync(targetPath, 'utf8');
+      const nextContent = upsertMarkedBlock(
+        existingContent,
+        template.marker,
+        templateContent
+      );
+
+      if (nextContent === existingContent) {
+        console.log(`Content already up to date in ${template.name}. Skipping.`);
+        continue;
+      }
+
+      fs.writeFileSync(targetPath, nextContent);
+      console.log(
+        existingContent.includes(template.marker)
+          ? `Updated marked block in ${template.name}.`
+          : `Appended to ${template.name}.`
+      );
+    } else {
+      console.log(
+        `${overwrite ? 'Overwriting' : 'Creating'} ${template.name}...`
+      );
+      // For creation/overwrite, we don't strictly need markers, but let's keep them for consistency
+      fs.writeFileSync(
+        targetPath,
+        overwrite ? templateContent : contentWithMarkers
+      );
+      console.log(`${overwrite ? 'Overwrote' : 'Created'} ${template.name}.`);
+    }
+  }
+
+  console.log('Built-in AI templates synchronization complete.');
+}
+
+// In postinstall, INIT_CWD is the directory where npm install was run
+// If this script is run directly (not imported), execute install logic
+if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+  const projectRoot = process.env.INIT_CWD || process.cwd();
+  const packageRoot = path.join(__dirname, '..');
+
+  // Only run if not being run inside the package's own root directory
+  if (projectRoot !== packageRoot) {
+    syncTemplates(projectRoot, false);
+  } else {
+    console.log('Running inside the package root. Regenerating templates.');
+    syncTemplates(packageRoot, true);
+  }
+}
diff --git a/.agents/skills/built-in-ai/templates/AGENTS.md b/.agents/skills/built-in-ai/templates/AGENTS.md
new file mode 100644
index 0000000..bbe48e8
--- /dev/null
+++ b/.agents/skills/built-in-ai/templates/AGENTS.md
@@ -0,0 +1,103 @@
+---
+name: built-in-ai-agent
+description:
+  Persona and behavioral instructions for an agent specialized in Chrome
+  Built-in AI.
+license: Apache-2.0
+---
+
+# Agent Persona: Built-in AI Expert
+
+You are an expert in modern web development with a focus on Built-in AI APIs.
+You are aware that the ecosystem is evolving rapidly and that browser-provided
+AI capabilities (Gemini Nano) are the preferred way to implement AI features on
+the web when possible.
+
+## Core Knowledge
+
+- **Direct API Access**: You know that the APIs are now top-level (e.g.,
+  `window.LanguageModel`, `window.Summarizer`) and no longer behind `window.ai`.
+- **Availability Check (NOT capabilities)**: You always check for availability
+  before use using the `availability()` method. You do NOT use `capabilities()`.
+  - **Availability Enums**:
+    - `"unavailable"`: The API cannot be used on this device.
+    - `"downloadable"`: The model needs to be downloaded before use.
+    - `"downloading"`: The model is currently being downloaded.
+    - `"available"`: The API is ready for immediate use.
+- **Model Download UX**: When a model is `downloadable` or `downloading`, you
+  must advise the user to inform their users.
+  - Use the `monitor` option in `create()` to track `downloadprogress`.
+  - Example:
+    `await window.LanguageModel.create({ monitor(m) { m.addEventListener('downloadprogress', e => console.log(e.loaded, e.total)); } })`.
+- **Implementation Strategies**:
+  - **Client-side only**: User must wait for the download to finish. Show a
+    progress indicator.
+  - **Hybrid**: Fall back to a cloud API (like Gemini API) while the local model
+    is downloading, then switch once ready.
+- **Polyfill Awareness**: You recommend polyfills when the user needs
+  compatibility across browsers.
+- **Model Management**: The browser manages model downloads, updates, and purges
+  (e.g., under disk space pressure). You must guide users to handle re-downloads
+  gracefully.
+  [Read more](https://developer.chrome.com/docs/ai/understand-built-in-model-management).
+- **Debugging**: Use `chrome://on-device-internals/` (Event Logs tab) to debug
+  model interpretation issues, tokens, and execution details.
+  [Read more](https://developer.chrome.com/docs/ai/debug-gemini-nano).
+- **Safe & Performant Rendering**: Treat LLM outputs as untrusted user-generated
+  content.
+  - **Security**: Always use a sanitizer like `DOMPurify`. If
+    `DOMPurify.removed.length > 0`, something was strippedβ€”stop rendering
+    immediately as it might be an injection attack.
+  - **Performance**: Use a streaming Markdown parser (like `streaming-markdown`)
+    to avoid expensive re-parsing of the entire string on every chunk.
+    [Read more](https://developer.chrome.com/docs/ai/render-llm-responses).
+- **Streaming**: Generally prefer the streaming APIs (like `promptStreaming()`)
+  over the non-streaming ones (like `prompt()`) as they provide a better user
+  experience. Each chunk is a separate independent piece of content. You can
+  iteratively append them to the DOM (for example, using a streaming Markdown
+  parser, but always sanitize the output).To get the final response, you need to
+  concatenate all the chunks.
+- **Structured Output**: Use the `responseConstraint` field in `prompt()` or
+  `promptStreaming()` options to pass a JSON Schema. This ensures predictable,
+  machine-readable outputs.
+  [Read more](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api).
+- **Session Management**:
+  - **System Prompts**: Pass `initialPrompts` with a `system` role to
+    `create()`.
+  - **Cloning**: Use `session.clone()` to create independent conversations that
+    inherit the initial prompt and history.
+    [Read more](https://developer.chrome.com/docs/ai/session-management).
+- **Local vs Cloud**: You understand the benefits of local AI (privacy, cost,
+  latency) and advocate for it when appropriate.
+- **TypeScript Support**: Use the official TypeScript types:
+  [@types/dom-chromium-ai](https://www.npmjs.com/package/@types/dom-chromium-ai).
+
+## Differences between extensions and web AI
+
+The Prompt API has distinct differences between the legacy (but still working)
+extension-only implementation and the modern web-standard implementation. Prefer
+the web-standard members, unless the user is working on an extension-only
+implementation.
+
+| Feature / Member   | Extension Context (Deprecated, but still working) | Modern Web AI (Standard)                |
+| :----------------- | :------------------------------------------------ | :-------------------------------------- |
+| **Parameters**     | `topK`, `temperature`                             | `samplingMode`                          |
+| **Session Info**   | `LanguageModel.params()`, `LanguageModelParams`   | N/A                                     |
+| **Usage Tracking** | `measureInputUsage()`, `inputUsage`               | `measureContextUsage()`, `contextUsage` |
+| **Quota/Window**   | `inputQuota`, `onquotaoverflow`                   | `contextWindow`, `oncontextoverflow`    |
+
+## Instructions
+
+- Always suggest the latest API names (top-level, no `window.ai`).
+- **Never suggest `capabilities()`**. Always use `availability()`.
+- When the status is `downloadable`, explain how to use `monitor` to show
+  progress.
+- Suggest hybrid approaches for better UX when cloud fallbacks are available.
+- Recommend `DOMPurify` and streaming Markdown parsers for all LLM UI
+  implementations.
+- Encourage the use of `responseConstraint` for applications where structured
+  output (like a predictable JSON object) is required.
+- When helping a user, check if they have the necessary polyfills if they target
+  a broad audience.
+- Use the IDLs and multimodal guidance provided in `SKILL.md` as the source of
+  truth for API signatures.
diff --git a/.agents/skills/built-in-ai/templates/SKILL.md b/.agents/skills/built-in-ai/templates/SKILL.md
new file mode 100644
index 0000000..3b5c280
--- /dev/null
+++ b/.agents/skills/built-in-ai/templates/SKILL.md
@@ -0,0 +1,651 @@
+---
+name: built-in-ai
+description:
+  Teaches the AI about modern Chrome Built-in AI APIs (Prompt, Translation,
+  Writing Assistance) and their polyfills.
+license: Apache-2.0
+compatibility: works with Chrome Built-in AI APIs and their polyfills
+---
+
+# Built-in AI Skills
+
+This file provides the latest technical information about Chrome Built-in AI
+APIs.
+
+## ⚠️ CRITICAL: Deprecations
+
+Everything you (the AI) might know about `window.ai` is completely outdated.
+Follow these new naming conventions:
+
+- `window.ai.languageModel` is DEPRECATED. Use `window.LanguageModel`.
+- `window.ai.summarizer` is DEPRECATED. Use `window.Summarizer`.
+- `window.ai.writer` is DEPRECATED. Use `window.Writer`.
+- `window.ai.Rewriter` is DEPRECATED. Use `window.Rewriter`.
+- `window.ai.translator` is DEPRECATED. Use `window.Translator`.
+- `window.ai.languageDetector` is DEPRECATED. Use `window.LanguageDetector`.
+
+There no longer is a single `window.ai` object. Instead, there are separate
+objects for each API.
+
+The old `capabilities()` function is DEPRECATED. Use `availability()` instead.
+
+All streaming APIs return a `ReadableStream` that yields separate and
+independent chunks. It used to be the case that the chunks would each time be
+increasingly larger up until the full response, but this is no longer the case.
+Instead, the chunks are of varying sizes and you need to concatenate them to
+form the full response.
+
+## πŸ›  Polyfills
+
+If you need to use these APIs in environments where they are not yet supported,
+use the following polyfills:
+
+- [prompt-api-polyfill](https://www.npmjs.com/package/prompt-api-polyfill)
+- [built-in-ai-task-apis-polyfills](https://www.npmjs.com/package/built-in-ai-task-apis-polyfills)
+
+## πŸ“˜ TypeScript Support
+
+For a better developer experience, use the official TypeScript types:
+
+- [@types/dom-chromium-ai](https://www.npmjs.com/package/@types/dom-chromium-ai)
+
+## πŸ“₯ Model Download Progress
+
+When a model is `downloadable` or `downloading`, you should show a progress bar
+to the user. Use the `monitor` callback in `create()` to track the
+`downloadprogress` event.
+
+### Progress Bar Example:
+
+```html
+<progress id="download-progress" value="0" max="100"></progress>
+<label for="download-progress">Downloading model...</label>
+
+<script type="module">
+  const progressBar = document.getElementById('download-progress');
+
+  const session = await LanguageModel.create({
+    monitor(m) {
+      m.addEventListener('downloadprogress', (e) => {
+        console.log(`Downloaded ${e.loaded} of ${e.total} bytes.`);
+        progressBar.value = e.loaded;
+        progressBar.max = e.total;
+      });
+    },
+  });
+</script>
+```
+
+## βš–οΈ Aligning `availability()` and `create()`
+
+**CRITICAL**: Always pass the **exact same options** to `availability()` that
+you intend to pass to `create()`. If you don't, the browser might report that
+the API is "available" for a default model, but it might fail or require a
+download for the specific configuration (language, modality) you actually need.
+
+### Example: Multimodal French Session
+
+If you need a session that supports French text and audio input, your
+availability check **must** reflect this:
+
+```js
+const options = {
+  expectedInputs: [{ type: 'text', languages: ['fr'] }, { type: 'audio' }],
+  expectedOutputs: [{ type: 'text', languages: ['fr'] }],
+};
+
+// 1. Check availability with THE EXACT SAME OPTIONS
+const status = await LanguageModel.availability(options);
+
+if (status === 'available') {
+  // 2. Create the session with THE EXACT SAME OPTIONS
+  const session = await LanguageModel.create(options);
+}
+```
+
+The Prompt API supports processing images and audio alongside text.
+
+### Supported Input Types:
+
+- **Audio**: `AudioBuffer`, `ArrayBufferView`, `ArrayBuffer`, `Blob`.
+- **Visual**: `HTMLImageElement`, `SVGImageElement`, `HTMLVideoElement` (current
+  frame), `HTMLCanvasElement`, `ImageBitmap`, `OffscreenCanvas`, `VideoFrame`,
+  `Blob`, `ImageData`.
+
+### Multimodal Session Example:
+
+```js
+const session = await LanguageModel.create({
+  expectedInputs: [
+    { type: 'text', languages: ['en'] },
+    { type: 'audio' },
+    { type: 'image' },
+  ],
+  expectedOutputs: [{ type: 'text', languages: ['en'] }],
+});
+
+const referenceImage = await (await fetch('reference-image.jpeg')).blob();
+const userDrawnImage = document.querySelector('canvas');
+
+const response1 = await session.prompt([
+  {
+    role: 'user',
+    content: [
+      {
+        type: 'text',
+        value: 'Critique how well the second image matches the first:',
+      },
+      { type: 'image', value: referenceImage },
+      { type: 'image', value: userDrawnImage },
+    ],
+  },
+]);
+
+const audioBuffer = await captureMicrophoneInput({ seconds: 10 });
+
+const response2 = await session.prompt([
+  {
+    role: 'user',
+    content: [
+      { type: 'text', value: 'My response to your critique:' },
+      { type: 'audio', value: audioBuffer },
+    ],
+  },
+]);
+```
+
+## 🌊 Streaming Example
+
+```js
+const session = await LanguageModel.create({
+  expectedInputs: [{ type: 'text', languages: ['en'] }],
+  expectedOutputs: [{ type: 'text', languages: ['en'] }],
+});
+
+const stream = session.promptStreaming([
+  {
+    role: 'user',
+    content: [{ type: 'text', value: 'Hello, how are you?' }],
+  },
+]);
+
+let fullResponse = '';
+// Sanitize the chunk and/or the full response before inserting either of them into the DOM.
+// The only exception is when doing so is safe, like when using `textContent`.
+for await (const chunk of stream) {
+  console.log(chunk);
+  fullResponse += chunk;
+}
+console.log(fullResponse);
+```
+
+## πŸ“ƒ Authoritative Reference Documentation
+
+Use the authoritative reference documentation to ensure that you are using the
+APIs correctly. You have access to the
+[MDN MCP server](https://mdn-mcp-0445ad8e765a.herokuapp.com/mcp) for Mozilla
+docs and the
+[Developer Knowledge MCP server](https://developerknowledge.googleapis.com/mcp)
+for Chrome docs.
+
+- [Summarizer API](https://developer.mozilla.org/en-US/docs/Web/API/Summarizer)
+- [Language Detector API](https://developer.mozilla.org/en-US/docs/Web/API/LanguageDetector)
+- [Translator API](https://developer.mozilla.org/en-US/docs/Web/API/Translator)
+- [Prompt API](https://developer.chrome.com/docs/ai/prompt-api)
+- [Writer API](https://developer.chrome.com/docs/ai/writer-api)
+- [Rewriter API](https://developer.chrome.com/docs/ai/rewriter-api)
+- [Proofreader API](https://developer.chrome.com/docs/ai/proofreader-api)
+
+## πŸ“œ Latest IDLs
+
+Below are the latest Web IDLs for these APIs, extracted from the official
+specifications.
+
+<!-- BEGIN IDLS -->
+### Translation API
+
+```webidl
+[Exposed=Window, SecureContext]
+interface Translator {
+  static Promise<Translator> create(TranslatorCreateOptions options);
+  static Promise<Availability> availability(TranslatorCreateCoreOptions options);
+
+  Promise<DOMString> translate(
+    DOMString input,
+    optional TranslatorTranslateOptions options = {}
+  );
+  ReadableStream translateStreaming(
+    DOMString input,
+    optional TranslatorTranslateOptions options = {}
+  );
+
+  readonly attribute DOMString sourceLanguage;
+  readonly attribute DOMString targetLanguage;
+
+  Promise<double> measureInputUsage(
+    DOMString input,
+    optional TranslatorTranslateOptions options = {}
+  );
+  readonly attribute unrestricted double inputQuota;
+};
+Translator includes DestroyableModel;
+
+dictionary TranslatorCreateCoreOptions {
+  required DOMString sourceLanguage;
+  required DOMString targetLanguage;
+};
+
+dictionary TranslatorCreateOptions : TranslatorCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+};
+
+dictionary TranslatorTranslateOptions {
+  AbortSignal signal;
+};
+```
+
+```webidl
+[Exposed=Window, SecureContext]
+interface LanguageDetector {
+  static Promise<LanguageDetector> create(
+    optional LanguageDetectorCreateOptions options = {}
+  );
+  static Promise<Availability> availability(
+    optional LanguageDetectorCreateCoreOptions options = {}
+  );
+
+  Promise<sequence<LanguageDetectionResult>> detect(
+    DOMString input,
+    optional LanguageDetectorDetectOptions options = {}
+  );
+
+  readonly attribute FrozenArray<DOMString>? expectedInputLanguages;
+
+  Promise<double> measureInputUsage(
+    DOMString input,
+    optional LanguageDetectorDetectOptions options = {}
+  );
+  readonly attribute unrestricted double inputQuota;
+};
+LanguageDetector includes DestroyableModel;
+
+dictionary LanguageDetectorCreateCoreOptions {
+  sequence<DOMString> expectedInputLanguages;
+};
+
+dictionary LanguageDetectorCreateOptions : LanguageDetectorCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+};
+
+dictionary LanguageDetectorDetectOptions {
+  AbortSignal signal;
+};
+
+dictionary LanguageDetectionResult {
+  DOMString detectedLanguage;
+  double confidence;
+};
+```
+
+### Writing Assistance APIs
+
+```webidl
+[Exposed=Window, SecureContext]
+interface Summarizer {
+  static Promise<Summarizer> create(optional SummarizerCreateOptions options = {});
+  static Promise<Availability> availability(optional SummarizerCreateCoreOptions options = {});
+
+  Promise<DOMString> summarize(
+    DOMString input,
+    optional SummarizerSummarizeOptions options = {}
+  );
+  ReadableStream summarizeStreaming(
+    DOMString input,
+    optional SummarizerSummarizeOptions options = {}
+  );
+
+  readonly attribute DOMString sharedContext;
+  readonly attribute SummarizerType type;
+  readonly attribute SummarizerFormat format;
+  readonly attribute SummarizerLength length;
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  readonly attribute PerformancePreference preference;
+
+  readonly attribute FrozenArray<DOMString>? expectedInputLanguages;
+  readonly attribute FrozenArray<DOMString>? expectedContextLanguages;
+  readonly attribute DOMString? outputLanguage;
+
+  Promise<double> measureInputUsage(
+    DOMString input,
+    optional SummarizerSummarizeOptions options = {}
+  );
+  readonly attribute unrestricted double inputQuota;
+};
+Summarizer includes DestroyableModel;
+
+dictionary SummarizerCreateCoreOptions {
+  SummarizerType type = "key-points";
+  SummarizerFormat format = "markdown";
+  SummarizerLength length = "short";
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  PerformancePreference preference = "auto";
+
+  sequence<DOMString> expectedInputLanguages;
+  sequence<DOMString> expectedContextLanguages;
+  DOMString outputLanguage;
+};
+
+dictionary SummarizerCreateOptions : SummarizerCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+
+  DOMString sharedContext;
+};
+
+dictionary SummarizerSummarizeOptions {
+  AbortSignal signal;
+  DOMString context;
+};
+
+enum SummarizerType { "tldr", "teaser", "key-points", "headline" };
+enum SummarizerFormat { "plain-text", "markdown" };
+enum SummarizerLength { "short", "medium", "long" };
+enum PerformancePreference { "auto", "speed", "capability" };
+```
+
+```webidl
+[Exposed=Window, SecureContext]
+interface Writer {
+  static Promise<Writer> create(optional WriterCreateOptions options = {});
+  static Promise<Availability> availability(optional WriterCreateCoreOptions options = {});
+
+  Promise<DOMString> write(
+    DOMString input,
+    optional WriterWriteOptions options = {}
+  );
+  ReadableStream writeStreaming(
+    DOMString input,
+    optional WriterWriteOptions options = {}
+  );
+
+  readonly attribute DOMString sharedContext;
+  readonly attribute WriterTone tone;
+  readonly attribute WriterFormat format;
+  readonly attribute WriterLength length;
+
+  readonly attribute FrozenArray<DOMString>? expectedInputLanguages;
+  readonly attribute FrozenArray<DOMString>? expectedContextLanguages;
+  readonly attribute DOMString? outputLanguage;
+
+  Promise<double> measureInputUsage(
+    DOMString input,
+    optional WriterWriteOptions options = {}
+  );
+  readonly attribute unrestricted double inputQuota;
+};
+Writer includes DestroyableModel;
+
+dictionary WriterCreateCoreOptions {
+  WriterTone tone = "neutral";
+  WriterFormat format = "markdown";
+  WriterLength length = "short";
+
+  sequence<DOMString> expectedInputLanguages;
+  sequence<DOMString> expectedContextLanguages;
+  DOMString outputLanguage;
+};
+
+dictionary WriterCreateOptions : WriterCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+
+  DOMString sharedContext;
+};
+
+dictionary WriterWriteOptions {
+  DOMString context;
+  AbortSignal signal;
+};
+
+enum WriterTone { "formal", "neutral", "casual" };
+enum WriterFormat { "plain-text", "markdown" };
+enum WriterLength { "short", "medium", "long" };
+```
+
+```webidl
+[Exposed=Window, SecureContext]
+interface Rewriter {
+  static Promise<Rewriter> create(optional RewriterCreateOptions options = {});
+  static Promise<Availability> availability(optional RewriterCreateCoreOptions options = {});
+
+  Promise<DOMString> rewrite(
+    DOMString input,
+    optional RewriterRewriteOptions options = {}
+  );
+  ReadableStream rewriteStreaming(
+    DOMString input,
+    optional RewriterRewriteOptions options = {}
+  );
+
+  readonly attribute DOMString sharedContext;
+  readonly attribute RewriterTone tone;
+  readonly attribute RewriterFormat format;
+  readonly attribute RewriterLength length;
+
+  readonly attribute FrozenArray<DOMString>? expectedInputLanguages;
+  readonly attribute FrozenArray<DOMString>? expectedContextLanguages;
+  readonly attribute DOMString? outputLanguage;
+
+  Promise<double> measureInputUsage(
+    DOMString input,
+    optional RewriterRewriteOptions options = {}
+  );
+  readonly attribute unrestricted double inputQuota;
+};
+Rewriter includes DestroyableModel;
+
+dictionary RewriterCreateCoreOptions {
+  RewriterTone tone = "as-is";
+  RewriterFormat format = "as-is";
+  RewriterLength length = "as-is";
+
+  sequence<DOMString> expectedInputLanguages;
+  sequence<DOMString> expectedContextLanguages;
+  DOMString outputLanguage;
+};
+
+dictionary RewriterCreateOptions : RewriterCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+
+  DOMString sharedContext;
+};
+
+dictionary RewriterRewriteOptions {
+  DOMString context;
+  AbortSignal signal;
+};
+
+enum RewriterTone { "as-is", "more-formal", "more-casual" };
+enum RewriterFormat { "as-is", "plain-text", "markdown" };
+enum RewriterLength { "as-is", "shorter", "longer" };
+```
+
+```webidl
+[Exposed=Window, SecureContext]
+interface CreateMonitor : EventTarget {
+  attribute EventHandler ondownloadprogress;
+};
+
+callback CreateMonitorCallback = undefined (CreateMonitor monitor);
+
+enum Availability {
+  "unavailable",
+  "downloadable",
+  "downloading",
+  "available"
+};
+
+interface mixin DestroyableModel {
+  undefined destroy();
+};
+```
+
+### Prompt API
+
+```webidl
+[Exposed=Window, SecureContext]
+interface LanguageModel : EventTarget {
+  static Promise<LanguageModel> create(optional LanguageModelCreateOptions options = {});
+  static Promise<Availability> availability(optional LanguageModelCreateCoreOptions options = {});
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  static Promise<LanguageModelParams?> params();
+
+  // These will throw "NotSupportedError" DOMExceptions if role = "system"
+  Promise<DOMString> prompt(
+    LanguageModelPrompt input,
+    optional LanguageModelPromptOptions options = {}
+  );
+  ReadableStream promptStreaming(
+    LanguageModelPrompt input,
+    optional LanguageModelPromptOptions options = {}
+  );
+  Promise<undefined> append(
+    LanguageModelPrompt input,
+    optional LanguageModelAppendOptions options = {}
+  );
+
+
+  Promise<double> measureContextUsage(
+    LanguageModelPrompt input,
+    optional LanguageModelPromptOptions options = {}
+  );
+  readonly attribute double contextUsage;
+  readonly attribute unrestricted double contextWindow;
+  attribute EventHandler oncontextoverflow;
+
+  // **DEPRECATED**: This method is only available in extension contexts.
+  Promise<double> measureInputUsage(
+    LanguageModelPrompt input,
+    optional LanguageModelPromptOptions options = {}
+  );
+  // **DEPRECATED**: This attribute is only available in extension contexts.
+  readonly attribute double inputUsage;
+  // **DEPRECATED**: This attribute is only available in extension contexts.
+  readonly attribute unrestricted double inputQuota;
+  // **DEPRECATED**: This attribute is only available in extension contexts.
+  attribute EventHandler onquotaoverflow;
+
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  readonly attribute unsigned long topK;
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  readonly attribute float temperature;
+
+  readonly attribute LanguageModelSamplingMode samplingMode;
+
+  Promise<LanguageModel> clone(optional LanguageModelCloneOptions options = {});
+};
+LanguageModel includes DestroyableModel;
+
+// **EXPERIMENTAL**: Only available in extension and experimental contexts.
+[Exposed=Window, SecureContext]
+interface LanguageModelParams {
+  readonly attribute unsigned long defaultTopK;
+  readonly attribute unsigned long maxTopK;
+  readonly attribute float defaultTemperature;
+  readonly attribute float maxTemperature;
+};
+
+
+callback LanguageModelToolFunction = Promise<DOMString> (any... arguments);
+
+// A description of a tool call that a language model can invoke.
+dictionary LanguageModelTool {
+  required DOMString name;
+  required DOMString description;
+  // JSON schema for the input parameters.
+  required object inputSchema;
+  // The function to be invoked by user agent on behalf of language model.
+  required LanguageModelToolFunction execute;
+};
+
+dictionary LanguageModelCreateCoreOptions {
+  // Note: these two have custom out-of-range handling behavior, not in the IDL layer.
+  // They are unrestricted double so as to allow +Infinity without failing.
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  unrestricted double topK;
+  // **EXPERIMENTAL**: Only available in extension and experimental contexts.
+  unrestricted double temperature;
+
+  LanguageModelSamplingMode samplingMode = "default";
+
+  sequence<LanguageModelExpected> expectedInputs;
+  sequence<LanguageModelExpected> expectedOutputs;
+  sequence<LanguageModelTool> tools;
+};
+
+dictionary LanguageModelCreateOptions : LanguageModelCreateCoreOptions {
+  AbortSignal signal;
+  CreateMonitorCallback monitor;
+
+  sequence<LanguageModelMessage> initialPrompts;
+};
+
+dictionary LanguageModelPromptOptions {
+  object responseConstraint;
+  boolean omitResponseConstraintInput = false;
+  AbortSignal signal;
+};
+
+dictionary LanguageModelAppendOptions {
+  AbortSignal signal;
+};
+
+dictionary LanguageModelCloneOptions {
+  AbortSignal signal;
+};
+
+dictionary LanguageModelExpected {
+  required LanguageModelMessageType type;
+  sequence<DOMString> languages;
+};
+
+// The argument to the prompt() method and others like it
+
+typedef (
+  sequence<LanguageModelMessage>
+  // Shorthand for `[{ role: "user", content: [{ type: "text", value: providedValue }] }]`
+  or DOMString
+) LanguageModelPrompt;
+
+dictionary LanguageModelMessage {
+  required LanguageModelMessageRole role;
+
+  // The DOMString branch is shorthand for `[{ type: "text", value: providedValue }]`
+  required (DOMString or sequence<LanguageModelMessageContent>) content;
+
+  boolean prefix = false;
+};
+
+dictionary LanguageModelMessageContent {
+  required LanguageModelMessageType type;
+  required LanguageModelMessageValue value;
+};
+
+enum LanguageModelSamplingMode { "most-predictable", "predictable", "balanced", "creative", "most-creative" };
+
+enum LanguageModelMessageRole { "system", "user", "assistant" };
+
+enum LanguageModelMessageType { "text", "image", "audio", "tool-call", "tool-response" };
+
+typedef (
+  ImageBitmapSource
+  or AudioBuffer
+  or BufferSource
+  or DOMString
+) LanguageModelMessageValue;
+```
+
+<!-- END IDLS -->
diff --git a/.agents/skills/chrome-ai/SKILL.md b/.agents/skills/chrome-ai/SKILL.md
new file mode 100644
index 0000000..a39a549
--- /dev/null
+++ b/.agents/skills/chrome-ai/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: chrome-ai
+description: >
+  Use when working on AI-powered Chrome extensions, especially Chrome built-in AI,
+  Modern Web Guidance, Chrome DevTools MCP setup for extension debugging, and
+  Chrome Web Store AI extension readiness.
+---
+
+# Chrome AI
+
+Use this skill when working on AI-powered Chrome extensions, including client-side
+AI, built-in AI APIs, Gemini-backed extension features, Chrome DevTools MCP setup,
+or Chrome Web Store readiness for AI extension projects.
+
+## When To Use
+
+Use this skill when you need to:
+
+- understand Chrome extension AI features, APIs, and workflows
+- find concrete examples before implementing or debugging an AI extension
+- set up Chrome DevTools MCP for extension testing
+- reason about Modern Web Guidance for extension-building agents
+- prepare AI extension metadata or permission notes for Chrome Web Store review
+
+## Quick Reference
+
+### Install Modern Web Guidance
+
+```bash
+npx modern-web-guidance@latest install --choose
+```
+
+Choose both `chrome-extensions` and `modern-web-guidance` when the installer asks
+which skills to install.
+
+### Chrome DevTools MCP Config
+
+```json
+{
+  "mcpServers": {
+    "chrome-devtools-mcp": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "chrome-devtools-mcp@latest",
+        "--categoryExtensions",
+        "--autoConnect"
+      ]
+    }
+  }
+}
+```
+
+Use `--categoryExtensions` for extension-specific DevTools capabilities and
+`--autoConnect` when testing against an existing Chrome profile, including cases
+that depend on Chrome built-in AI model state or signed-in browser state.
+
+### Claude MCP Equivalent
+
+```bash
+claude mcp add chrome-devtools --scope project -- npx chrome-devtools-mcp@latest --categoryExtensions --autoConnect
+```
+
+## Reference Files
+
+Read these bundled references when detailed context is needed:
+
+- `references/index.md` - documentation index from the imported archive
+- `references/other.md` - captured Chrome extension AI documentation
+
+## Source
+
+Installed from the project-local Chrome AI skill archive generated from Chrome
+for Developers documentation. Generated mirror folders and `output/` archives are
+ignored; `.agents/skills/chrome-ai` is the canonical checked-in skill copy.
diff --git a/.agents/skills/chrome-ai/openai_metadata.json b/.agents/skills/chrome-ai/openai_metadata.json
new file mode 100644
index 0000000..43ccc96
--- /dev/null
+++ b/.agents/skills/chrome-ai/openai_metadata.json
@@ -0,0 +1,10 @@
+{
+  "platform": "openai",
+  "name": "chrome-ai",
+  "version": "1.0.0",
+  "created_with": "skill-seekers",
+  "model": "gpt-4o",
+  "tools": [
+    "file_search"
+  ]
+}
diff --git a/.agents/skills/chrome-ai/references/index.md b/.agents/skills/chrome-ai/references/index.md
new file mode 100644
index 0000000..909138a
--- /dev/null
+++ b/.agents/skills/chrome-ai/references/index.md
@@ -0,0 +1,7 @@
+# Developer Documentation Index
+
+## Categories
+
+### Other
+**File:** `other.md`
+**Pages:** 2
diff --git a/.agents/skills/chrome-ai/references/other.md b/.agents/skills/chrome-ai/references/other.md
new file mode 100644
index 0000000..36d6372
--- /dev/null
+++ b/.agents/skills/chrome-ai/references/other.md
@@ -0,0 +1,152 @@
+# Developer - Other
+
+**Pages:** 2
+
+---
+
+## Build extensions with coding agents | Extensions and AI | Chrome for Developers
+
+**URL:** https://developer.chrome.com/docs/extensions/ai/build-with-ai
+
+**Contents:**
+- Build extensions with coding agents Stay organized with collections Save and categorize content based on your preferences.
+- Setup
+  - Modern Web Guidance
+    - CLI
+    - Antigravity
+  - Chrome DevTools for coding agents
+    - Antigravity
+    - Claude Code
+    - Other agents
+  - CHROMEWEBSTORE.md agent instructions
+
+AI coding agents, like Antigravity, can now generate extension code with impressive accuracy. However, to truly unlock their potential and ensure high-quality results, you need to provide them with the right context and tools.
+
+This guide explains how to setup the right tools in your coding agents and how they can help you build better extensions faster.
+
+We have created a skill for coding agents specifically designed for extension development. This skill is a part of our broader initiative, Modern Web Guidance, which provides AI coding agents with the web platform expertise, best practices, and modern API patterns.
+
+But building the extension is just the first step. To help you verify that your code works as expected, Chrome DevTools for agents enables AI coding assistants to debug extensions directly in Chrome and benefit from DevTools debugging capabilities and performance insights.
+
+To use the skills pack, install Modern Web Guidance to your preferred environment and add the extensions skill to it. Here are the instructions for some of the popular tools.
+
+The recommended installation for most coding agents (including Gemini CLI, Claude Code and Codex) is through the modern-web-guidance CLI built by the Chrome team. Installing the skills through the modern-web-guidance CLI will automatically keep skills up to date.
+
+This runs an interactive wizard to install the skills to your preferences. When presented with options, select your coding agent(s) and choose both chrome-extensions and modern-web-guidance.
+
+Selecting chrome-extensions and modern-web-guidance in the installer wizard.
+
+When installing Antigravity, you can select the Modern Web Guidance plugin which includes the extensions skill, or you can add it through Customizations > Build With Google Plugins > Modern Web Guidance.
+
+Selecting the Modern Web Guidance plugin during Antigravity installation.
+
+Adding Modern Web Guidance through Customizations after installation.
+
+You can add Chrome DevTools for agents to your coding agent of choice either as a plugin, extension or as an MCP server.
+
+Here are the instructions for some of the most popular agents.
+
+On startup, or in Settings > Customizations, under Build with Google enable Chrome DevTools. This will only install the Chrome DevTools skill, but not the MCP server.
+
+To add the Chrome DevTools MCP server go to Settings > Customization, click the Add MCP server button and search for Chrome DevTools.
+
+Click Open MCP Config to open the MCP server configuration. Note that you have to close the settings to see the configuration file in the IDE.
+
+Add the following two configuration parameters: --categoryExtensions (to enable the extensions tools) and --autoConnect (to enable connecting to an existing Chrome Profile, which is required when using Chrome's built-in AI APIs or requiring sign-in).
+
+To enable remote debugging, open Chrome, navigate to chrome://inspect/#remote-debugging and select Allow remote debugging for this browser instance.
+
+Restart Antigravity IDE.
+
+Create a new workspace and create a test prompt: "Create a hello world Chrome extension. Test using Chrome DevTools." When the agent initiates testing the extension in the browser, Chrome will show you a dialog requesting remote debugging permission. Select Allow. While the remote debugging session is enabled, Chrome will display a banner "Chrome is currently controlled by automated test software".
+
+For instructions on setting up other agents, check the docs on Chrome DevTools MCP GitHub.
+
+An important part of publishing an extension is filling out the Developer Dashboard. The skill addresses this by having your coding agent create and maintain a CHROMEWEBSTORE.md file which tracks necessary information, including justifications for each permission requested in the code.
+
+The skill will get triggered when you use phrases like "Let's publish this" or "Prepare this extension for the store", but to streamline your agentic workflows, add the following to your agent's system instructions (for example, ~/.gemini/GEMINI.md for Antigravity or ~/.claude/CLAUDE.md for Claude):
+
+The extension skill included in Modern Web Guidance helps agents in three key ways:
+
+Modern Web Guidance also includes skills that cover everything you need to deliver an excellent user experience, such as performance, accessibility, and modern APIs. For example, built-in AI API skills make sure that AI coding agents always use the latest version of the API together with additional information about explicit architectural rules and hardware constraints for using these APIs, to enable efficient management of model downloads, focus on security, and graceful fallback strategies.
+
+The skill also helps your agent track necessary information for publishing, including justifications for each permission requested in the code. For example, if you ask your coding agent to build an extension using the Side Panel API and to publish it to the Chrome Web Store, the agent will recognize that it needs the sidePanel permission. It will then create a CHROMEWEBSTORE.md file with a justification. When you are ready to submit, you can review this justification, make any adjustments if needed, and copy it straight into the Developer Dashboard.
+
+Chrome DevTools for agents enables AI coding assistants to install and debug extensions in a running Chrome instance, specifically:
+
+Here's a prompt and a video showing how that works in practice:
+
+In this case, the agent should create a Manifest V3 file and request the storage permission because it knows it needs to persist data. The agent can now build an extension, install it, watch it run, and verify its stability without you ever leaving the chat interface.
+
+This is a simple prompt example. To learn more about different prompt techniques and find what works best for your use case, check out our guide on Prompt engineering.
+
+While installing the extension skill and adding instructions to your agent will do most of the work, being specific in your prompts can produce better results for the stage of development you're in. Here's a quick guide on how to prompt your agent to create, update, and maintain your CHROMEWEBSTORE.md file.
+
+Combining Modern Web Guidance skills with Chrome DevTools for agents helps you build high-quality features faster but also ensures your extension is stable and ready for submission to the Chrome Web Store.
+
+Start experimenting with these tools in your next project to see how they can streamline your extension development from initial prototype to publication.
+
+Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies. Java is a registered trademark of Oracle and/or its affiliates.
+
+Last updated 2026-05-19 UTC.
+
+**Examples:**
+
+Example 1 (elixir):
+```elixir
+npx modern-web-guidance@latest install --choose
+```
+
+Example 2 (json):
+```json
+{
+ "mcpServers": {
+   "chrome-devtools-mcp": {
+     "args": [
+       "-y",
+       "chrome-devtools-mcp@latest",
+       "--categoryExtensions",
+       "--autoConnect"
+     ],
+     "command": "npx"
+   }
+ }
+}
+```
+
+Example 3 (jsx):
+```jsx
+<figure>
+  <img src="image/antigravityide--u84rk62f5t9.png" alt="The remote debugging warning banner and approval popup dialog in Chrome." class="screenshot" width="800">
+  <figcaption>The remote debugging banner indicating automated browser control is active.</figcaption>
+</figure>
+```
+
+Example 4 (elixir):
+```elixir
+claude mcp add chrome-devtools --scope project -- npx chrome-devtools-mcp@latest --categoryExtensions --autoConnect
+```
+
+---
+
+## Extensions and AI | Chrome for Developers
+
+**URL:** https://developer.chrome.com/docs/extensions/ai
+
+**Contents:**
+  - Extensions and AI
+  - Build extensions with AI coding tools
+  - Enhance browsing with AI-powered extensions
+    - Control web content
+    - Make the browser more helpful
+    - Customize the browser
+  - Build AI-powered Chrome Extensions with Gemini
+  - Even more use cases
+- Integrate AI with extensions
+  - Client-side AI
+
+Learn how to work with AI coding tools to build and debug your extensions and unlock faster, smarter development and more powerful user experiences.
+
+Install Chrome extensions skill from Modern Web Guidance via CLI:
+
+---
diff --git a/.agents/skills/chrome-extensions/SKILL.md b/.agents/skills/chrome-extensions/SKILL.md
new file mode 100644
index 0000000..ed2eef9
--- /dev/null
+++ b/.agents/skills/chrome-extensions/SKILL.md
@@ -0,0 +1,551 @@
+---
+name: chrome-extensions
+description: >
+  Build and publish Chrome Extensions using Manifest V3 best practices. Use this skill
+  whenever the user asks to create, modify, debug, or understand Chrome browser extensions,
+  add-ons, or anything involving the Chrome Extensions API. Trigger on mentions of: 'Chrome
+  extension', 'browser extension', 'manifest.json', 'content script', 'service worker' (in
+  browser context), 'popup' (in browser extension context), 'side panel', 'chrome.* API',
+  'declarativeNetRequest', 'omnibox', 'context menu' (in extension context), 'userScripts',
+  'user script', 'script manager', or any request to build functionality that integrates with
+  the Chrome browser UI. Also trigger for publishing to the Chrome Web Store: 'publish
+  extension', preparing an extension for publishing, responding to a review rejection, writing
+  permission justifications, or drafting a privacy policy.
+---
+
+# Chrome Extensions
+
+Build production-quality Chrome extensions using Manifest V3 and publish them to the Chrome Web Store.
+
+## Part 1 β€” Building Extensions
+
+### Mandatory Rules
+
+These address the most common causes of broken extensions. Violating any produces a non-functional build.
+
+#### 1. Icons: only reference files you create β€” or omit icons entirely
+
+```
+❌ BROKEN β€” referencing files that don't exist or reusing one file for all sizes:
+   "icons": { "16": "icon.png", "48": "icon.png", "128": "icon.png" }
+
+βœ… CORRECT β€” each size is a separate file at the correct pixel dimensions:
+   "icons": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }
+   (where icon-16.png is 16Γ—16px, icon-48.png is 48Γ—48px, icon-128.png is 128Γ—128px)
+
+βœ… ALSO CORRECT β€” omit icons from manifest if you cannot generate real PNG files:
+   (just remove the "icons" and "default_icon" fields β€” Chrome uses a default icon)
+```
+
+**If you include icon references, you MUST create the actual image files.** Generate them with a script (see `references/extensions/icons.md`) or leave them out. Never reference non-existent files.
+
+#### 2. Side panel: you MUST provide a way to open it
+
+Defining `"side_panel": {"default_path": "..."}` does NOT make it openable. Add a trigger:
+
+```js
+// In service-worker.js β€” open side panel on extension icon click
+// IMPORTANT: chrome.action.onClicked ONLY fires when there is NO default_popup
+chrome.action.onClicked.addListener(async (tab) => {
+  await chrome.sidePanel.open({ windowId: tab.windowId });
+});
+```
+
+If the extension has both a popup AND side panel, add a button in the popup that calls `chrome.sidePanel.open()`. Alternatively, use `chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })` β€” but the property is `openPanelOnActionClick`, NOT `openPanelOnActionIconClick`; the "Icon" variant causes a synchronous TypeError that silently aborts the service worker. Do NOT also define `default_popup` when using `setPanelBehavior`. See `references/extensions/side-panel.md`.
+
+#### 3. Code execution: sandboxed iframes ONLY
+
+Extension CSP blocks `eval()`, `new Function()`, inline `<script>` in all extension pages.
+
+```js
+// ❌ BROKEN β€” direct iframe DOM access throws SecurityError
+iframe.contentDocument.write(html);
+
+// ❌ BROKEN β€” eval in extension page
+eval(userCode); // CSP blocks this
+
+// βœ… OPTION A: Sandbox in manifest + postMessage
+// manifest.json: { "sandbox": { "pages": ["sandbox.html"] } }
+iframe.contentWindow.postMessage({ html, css, js }, '*');
+// sandbox.html receives and runs:
+window.addEventListener('message', (e) => { eval(e.data.js); /* allowed in sandbox */ });
+
+// βœ… Arbitrary code path: use a manifest-declared sandbox page and messaging.
+// Blob URLs and unsandboxed srcdoc inherit extension CSP/origin constraints.
+iframe.src = chrome.runtime.getURL('sandbox.html');
+iframe.addEventListener('load', () => {
+  iframe.contentWindow.postMessage({ html, css, js }, '*');
+});
+```
+
+See `references/extensions/csp-sandbox.md` for full details.
+
+#### 4. `tab.url` needs a tab-sensitive access grant
+
+`tab.url` and `tab.title` are exposed when the extension has one of the
+supported grants for that tab: the `tabs` permission, a matching host permission,
+or a temporary host grant from `activeTab` after a user gesture.
+
+```js
+// Broadest option:
+{ "permissions": ["tabs"] }
+
+// Narrower user-invoked option:
+{ "permissions": ["activeTab"] }
+
+// Explicit host option:
+{ "host_permissions": ["https://example.com/*"] }
+```
+
+See `references/extensions/tab-management.md`.
+
+#### 5. Always use async/await β€” never `.then()` chains
+
+```js
+// ❌ BAD
+chrome.tabs.query({active: true, currentWindow: true}).then(tabs => {
+  chrome.scripting.executeScript({target: {tabId: tabs[0].id}, files: ['content.js']}).then(() => {});
+});
+
+// βœ… GOOD
+const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });
+```
+
+For `runtime.onMessage` listeners that do async work:
+
+```js
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  (async () => {
+    const data = await chrome.storage.local.get('key');
+    sendResponse({ data });
+  })();
+  return true; // keeps channel open
+});
+```
+
+#### 6. Content scripts: don't block the main thread
+
+When modifying many DOM elements, batch with `requestAnimationFrame` and yield between batches:
+
+```js
+async function highlightAll(elements) {
+  const BATCH = 20;
+  for (let i = 0; i < elements.length; i += BATCH) {
+    await new Promise(r => requestAnimationFrame(() => {
+      elements.slice(i, i + BATCH).forEach(el => el.style.backgroundColor = 'yellow');
+      r();
+    }));
+    if (globalThis.scheduler?.yield) await scheduler.yield();
+  }
+}
+```
+
+See `references/extensions/content-scripts.md`.
+
+#### 7. Service workers are ephemeral β€” never store state in variables
+
+```js
+// ❌ BROKEN β€” state lost when SW terminates (~30s of inactivity)
+let count = 0;
+chrome.tabs.onUpdated.addListener(() => { count++; });
+
+// βœ… CORRECT β€” persist in chrome.storage, read on every event
+chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
+  if (changeInfo.status !== 'complete') return;
+  const { count = 0 } = await chrome.storage.local.get('count');
+  await chrome.storage.local.set({ count: count + 1 });
+  await chrome.action.setBadgeText({ text: String(count + 1) });
+});
+```
+
+Use `chrome.alarms` instead of `setTimeout`/`setInterval`. See `references/extensions/service-worker.md`.
+
+#### 8. chrome.identity: extension ID differs between dev and production
+
+When using Google sign-in, the OAuth client_id is tied to a specific extension ID. The ID changes between unpacked development and the Chrome Web Store.
+
+To stabilize the ID during development, add a `"key"` field to manifest.json:
+1. Pack the extension once (chrome://extensions β†’ Pack)
+2. Extract the public key from the .crx
+3. Add `"key": "MIIBIjANBgkqh..."` to manifest.json
+
+Always document: "After publishing to the Chrome Web Store, update the OAuth client with the store-assigned extension ID." See `references/extensions/auth-identity.md`.
+
+#### 9. Context menus: show user feedback after action
+
+When a context menu item performs an action (save, copy, etc.), confirm it to the user. Use a notification, badge flash, or injected toast β€” don't let actions happen silently. See `references/extensions/context-menus.md` for a complete toast implementation.
+
+#### 10. Prompt API: available in service workers, popup, and side panel
+
+The `LanguageModel` API works in all extension contexts β€” service worker, popup, and side panel β€” with no additional manifest permissions required. Extensions also get `LanguageModel.params()`, which is unavailable on the web:
+
+```js
+const params = await LanguageModel.params();
+// { defaultTopK: 3, maxTopK: 128, defaultTemperature: 1, maxTemperature: 2 }
+```
+
+For general Prompt API patterns (availability checks, session creation, streaming), use the `modern-web-guidance` skill. See `references/extensions/prompt-api.md` for the extension-specific wiring example.
+
+#### 11. `chrome.action` API requires `action` in manifest
+
+Using `chrome.action.setBadgeText`, `chrome.action.setIcon`, or `chrome.action.onClicked` requires
+an `"action"` key in manifest.json β€” even if it's empty. Without it, `chrome.action` is `undefined`.
+
+```js
+// ❌ BROKEN β€” manifest has no "action" key
+await chrome.action.setBadgeText({ text: '5' });
+// TypeError: Cannot read properties of undefined (reading 'setBadgeText')
+
+// βœ… FIX β€” add "action" to manifest.json (at minimum an empty object)
+{ "action": {} }
+// or with a popup:
+{ "action": { "default_popup": "popup/popup.html" } }
+```
+
+#### 12. `activeTab` only works on direct user gestures β€” not from side panels
+
+`activeTab` grants temporary access to the current tab ONLY when triggered by:
+- Clicking the extension action icon
+- A context menu item (including the `"tab"` context)
+- A keyboard shortcut from the `commands` API
+- Accepting an omnibox suggestion
+
+It does **NOT** grant access when clicking a button in a side panel, popup button that opens later,
+or any programmatic trigger.
+
+```js
+// ❌ BROKEN β€” activeTab does NOT work from a side panel button click
+document.getElementById('summarize').addEventListener('click', async () => {
+  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+  await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => document.body.innerText });
+});
+
+// βœ… FIX β€” use "tabs" permission + specific host_permissions instead
+// manifest.json: { "permissions": ["tabs", "scripting"], "host_permissions": ["<all_urls>"] }
+```
+
+See `references/extensions/side-panel.md`.
+
+#### 13. DevTools panel URLs are relative to the extension root
+
+When creating a DevTools panel, the panel HTML path is relative to the **extension root**, NOT
+relative to the devtools page that calls `chrome.devtools.panels.create()`.
+
+```js
+// ❌ BROKEN β€” path relative to devtools/ directory
+chrome.devtools.panels.create("My Panel", "", "panel/panel.html");
+
+// βœ… CORRECT β€” full path from extension root
+chrome.devtools.panels.create("My Panel", "", "devtools/panel/panel.html");
+```
+
+See `references/extensions/devtools.md`.
+
+#### 14. Offscreen documents have NO access to most chrome.* APIs
+
+Offscreen documents (`chrome.offscreen`) are **severely restricted**. Most `chrome.*` APIs
+are unavailable, including `chrome.downloads`, `chrome.tabs`, `chrome.action`, and others.
+
+```js
+// ❌ BROKEN β€” chrome.downloads is undefined in offscreen documents
+chrome.downloads.download({ url, filename: 'recording.webm' }); // TypeError
+
+// ❌ BROKEN β€” chrome.action is undefined in offscreen documents
+chrome.action.setBadgeText({ text: 'REC' }); // TypeError
+```
+
+**The only APIs available in offscreen documents are:**
+- `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`
+- `chrome.runtime.getURL`
+- Standard Web APIs (DOM, fetch, MediaRecorder, Canvas, Web Audio, etc.)
+
+**Rule of thumb:** Offscreen documents do the Web API work (recording, parsing, audio). The service worker does all chrome.* API work (downloads, badge updates, notifications). Use `chrome.runtime.sendMessage` to bridge between them. See `references/extensions/message-passing.md`.
+
+#### 15. Notifications and badge icons must reference real image files
+
+`chrome.notifications.create()` requires a valid `iconUrl` pointing to an actual image file.
+If the file doesn't exist or the path is wrong, the call fails with `"Unable to download all specified images."`
+
+```js
+// ❌ BROKEN β€” icon file doesn't exist
+chrome.notifications.create('reminder', {
+  type: 'basic',
+  iconUrl: 'icons/icon-128.png', // File not in extension!
+  title: 'Reminder',
+  message: 'Time is up!'
+});
+
+// βœ… Generate a data URL at runtime via OffscreenCanvas β€” no file needed.
+// See `references/extensions/icons.md` for a reusable implementation.
+const iconUrl = await getIconDataUrl();
+chrome.notifications.create('reminder', { type: 'basic', iconUrl, title: 'Reminder', message: 'Time is up!' });
+```
+
+This applies to ALL image references in chrome.* APIs β€” notifications, `chrome.action.setIcon`,
+context menu icons, etc. **If you reference a file, it must exist.**
+
+#### 16. Tab capture: guard against double-start with state locking
+
+`chrome.tabCapture.getMediaStreamId()` fails with `"Cannot capture a tab with an active stream"`
+if called while a previous capture is still active. Fast double-clicks on the extension icon
+easily trigger this. Use explicit state locking:
+
+```js
+// ❌ BROKEN β€” no guard against rapid clicks
+let isRecording = false;
+chrome.action.onClicked.addListener(async (tab) => {
+  if (isRecording) { stopRecording(); isRecording = false; }
+  else { isRecording = true; startRecording(tab); } // Second click = "active stream" error
+});
+
+// βœ… CORRECT β€” use transitional states to lock out concurrent operations
+// State machine: 'idle' β†’ 'starting' β†’ 'recording' β†’ 'stopping' β†’ 'idle'
+// Store state in chrome.storage.session (survives SW restart, cleared on browser close)
+chrome.action.onClicked.addListener(async (tab) => {
+  const { recordingState = 'idle' } = await chrome.storage.session.get('recordingState');
+
+  if (recordingState === 'starting' || recordingState === 'stopping') return;
+
+  if (recordingState === 'idle') {
+    await chrome.storage.session.set({ recordingState: 'starting' });
+    try {
+      await startRecording(tab);
+      await chrome.storage.session.set({ recordingState: 'recording' });
+      await chrome.action.setBadgeText({ text: 'REC' });
+      await chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });
+    } catch (err) {
+      console.error('Failed to start recording:', err);
+      await chrome.storage.session.set({ recordingState: 'idle' });
+    }
+  } else if (recordingState === 'recording') {
+    await chrome.storage.session.set({ recordingState: 'stopping' });
+    try { await stopRecording(); }
+    finally {
+      await chrome.storage.session.set({ recordingState: 'idle' });
+      await chrome.action.setBadgeText({ text: '' });
+    }
+  }
+});
+```
+
+This pattern applies to any chrome API that manages exclusive resources:
+`chrome.tabCapture`, `chrome.desktopCapture`, `chrome.offscreen.createDocument` (only one
+offscreen document allowed at a time). See `references/extensions/media-capture.md`.
+
+#### 17. `chrome.desktopCapture` requires a target tab with URL access
+
+When calling `chrome.desktopCapture.chooseDesktopMedia()` from a service worker, you must pass
+the active tab as the `targetTab` parameter. The tab object must have its `url` field populated,
+which requires the `"tabs"` permission.
+
+```js
+// ❌ BROKEN β€” called without targetTab from service worker
+chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], (streamId) => { ... });
+// Error: "A target tab is required when called from a service worker context."
+
+// ❌ BROKEN β€” tab doesn't have url field (missing "tabs" permission)
+const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], tab, (streamId) => { ... });
+// Error: "targetTab doesn't have URL field set."
+
+// βœ… CORRECT β€” "tabs" permission in manifest + pass tab object
+// manifest.json: { "permissions": ["tabs", "desktopCapture"] }
+const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], tab, (streamId) => {
+  if (!streamId) return; // User cancelled
+});
+```
+
+**Note:** Prefer `chrome.tabCapture.getMediaStreamId()` for tab-only recording. Use `chrome.desktopCapture` only when the user should choose which screen/window to capture. See `references/extensions/media-capture.md`.
+
+#### 18. User scripts: four non-obvious pitfalls
+
+`chrome.userScripts` runs **user-provided code** at runtime. Use it for script managers and
+user automation β€” not for extension-bundled scripts.
+
+- **API throws on property access if not enabled.** Chrome 138+ requires the user to toggle "Allow User Scripts" on the extension's details page; Chrome < 138 requires Developer mode. Always call `isUserScriptsAvailable()` before any `chrome.userScripts.*` call and show an error UI when it returns false.
+- **Registered scripts are cleared on extension update.** Persist configs in `chrome.storage`; re-register them in `runtime.onInstalled` for the `"update"` reason.
+- **Messaging requires explicit opt-in.** Call `configureWorld({ messaging: true })` first; listen on `runtime.onUserScriptMessage`, not `runtime.onMessage`.
+- **`ScriptSource` constraint:** each `js` entry must have exactly one of `code` or `file`. **`id` constraint:** cannot start with `_`.
+
+See `references/extensions/user-scripts.md`.
+
+#### 19. `chrome.windows` has NO `.query()` method β€” use `getAll`, `getLastFocused`, or `getCurrent`
+
+Unlike `chrome.tabs.query()`, the `chrome.windows` API does NOT have a `.query()` method.
+
+```js
+// ❌ BROKEN β€” chrome.windows.query does not exist
+const windows = await chrome.windows.query({ focused: true });
+// TypeError: chrome.windows.query is not a function
+
+// βœ… CORRECT β€” use the right method for your need
+const focused = await chrome.windows.getLastFocused({ populate: true });
+const current = await chrome.windows.getCurrent({ populate: true });
+const all     = await chrome.windows.getAll({ populate: true });
+```
+
+**`chrome.windows` methods:** `getAll`, `getLastFocused`, `getCurrent`, `get(windowId)`, `create`, `update`, `remove`. See `references/extensions/tab-management.md`.
+
+### Always Manifest V3
+
+Never generate Manifest V2 code.
+- `background.service_worker` not `background.scripts`
+- `chrome.action` not `chrome.browserAction`
+- `chrome.scripting.executeScript` not `chrome.tabs.executeScript`
+- `host_permissions` is separate from `permissions`
+- No inline scripts in HTML β€” use `<script src="file.js">`
+- No inline event handlers β€” use `addEventListener`
+
+---
+
+## Part 2 β€” Publishing to the Chrome Web Store
+
+Manage `CHROMEWEBSTORE.md` β€” the single source of truth for all Chrome Web Store listing
+metadata, permissions justifications, privacy disclosures, version history, and publishing
+readiness for a Chrome extension project.
+
+### Core Workflow
+
+Every time you touch a Chrome extension project in a way that affects its store presence,
+update (or create) `CHROMEWEBSTORE.md` in the project root. The file tracks everything the
+developer needs to fill out in the Chrome Developer Dashboard, so they can copy-paste from
+a single doc instead of scrambling at publish time.
+
+#### When to create CHROMEWEBSTORE.md
+
+Create it the moment any of these happen:
+- The user says they want to publish an extension
+- The user asks to "prepare for the store" or "get ready to publish"
+- You're building a new extension that will clearly end up on the store
+- The user asks about store listing requirements
+
+Use the template in `references/webstore/chromewebstore-template.md` as your starting point. Read it
+before generating the file.
+
+#### When to update CHROMEWEBSTORE.md
+
+Update it whenever:
+- **User-facing changes**: Bump the "Last Updated" date, update the feature list in
+  descriptions, and add an entry to Version History
+- **manifest.json changes**: If permissions, host_permissions, or content_scripts changed,
+  update the Permissions Justification section β€” every permission needs a plain-English
+  reason the review team can understand
+- **New release**: Add a Version History entry with version number, date, and summary
+- **Privacy-relevant changes**: If data collection, storage, or transmission changed,
+  update the Privacy & Data Use section and the privacy policy
+- **Asset changes**: If icons or UI changed, note which screenshots need refreshing
+- **Rejection response**: If the user reports a CWS rejection, update the file with the
+  fix and add a note to Version History
+
+### How to fill it out
+
+For each section, pull information from the actual project files:
+1. Read `manifest.json` to extract name, version, description, permissions, host_permissions
+2. Scan the codebase for data collection (storage, fetch calls, analytics)
+3. Check for icon files and their dimensions
+4. Look at the extension's UI to understand features for the description
+
+Write store-facing copy in a tone that is specific, honest, and benefit-oriented. The Chrome
+Web Store review team rejects vague descriptions. "Makes your life easier" will be rejected.
+"Highlights search results on any webpage and lets you save highlights to a local list" will
+pass.
+
+**Never mention implementation details.** Users care what the extension does for them, not
+how it was built. Strip any mention of APIs, libraries, frameworks, or code patterns:
+
+| ❌ Implementation detail (cut it) | βœ… User benefit (keep it) |
+|-----------------------------------|--------------------------|
+| "Uses a MutationObserver to detect page changes" | "Automatically detects new content as you browse" |
+| "Built with custom elements and Shadow DOM" | "Works seamlessly without affecting page styles" |
+| "Powered by a service worker for background processing" | "Runs quietly in the background without slowing your browser" |
+| "Leverages the chrome.storage.sync API" | "Your settings sync across all your devices" |
+| "Implements declarativeNetRequest for filtering" | "Blocks ads and trackers without reading your page content" |
+
+### CHROMEWEBSTORE.md Sections
+
+Read `references/webstore/chromewebstore-template.md` before generating the file β€” it defines
+what each section covers and how to fill it out. The highest-risk section is Permissions
+Justification: write a specific plain-English reason per permission and per host_permission.
+"Needed for the extension to work" will be rejected. Read `references/webstore/privacy-policy.md`
+for guidance on generating a privacy policy.
+
+### Pre-Publish Checklist
+
+Before submission, run through `references/webstore/review-checklist.md`. The most common
+first-submission failures:
+- Every permission and host_permission must have a specific justification (not "needed to work")
+- Privacy policy URL must be live and match the data use disclosure form
+- At least 1 screenshot at 1280Γ—800 or 640Γ—400
+- ZIP must exclude `.git/`, `node_modules/`, `.env`, `CHROMEWEBSTORE.md`
+
+### Store Listing Copy Guidelines
+
+For copy guidelines and common rejection reasons, see `references/webstore/store-listing.md`.
+Key rule: lead with function ("Highlights search terms on any webpage"), not feeling ("Enjoy
+searching again").
+
+---
+
+## Reference Files
+
+For detailed API patterns and publishing guidance, read the relevant file BEFORE writing code or content:
+
+| Topic | Reference |
+|-------|-----------|
+| Side panels | `references/extensions/side-panel.md` |
+| Content scripts & DOM | `references/extensions/content-scripts.md` |
+| Popups | `references/extensions/popup-ui.md` |
+| Service worker lifetime | `references/extensions/service-worker.md` |
+| Code execution & CSP | `references/extensions/csp-sandbox.md` |
+| API calls | `references/extensions/api-calling.md` |
+| Declarative Net Request | `references/extensions/declarative-net-request.md` |
+| Chrome Prompt API | `references/extensions/prompt-api.md` |
+| DevTools panels | `references/extensions/devtools.md` |
+| Authentication | `references/extensions/auth-identity.md` |
+| Context menus | `references/extensions/context-menus.md` |
+| Omnibox | `references/extensions/omnibox.md` |
+| Storage | `references/extensions/storage.md` |
+| Tab & window management | `references/extensions/tab-management.md` |
+| Tab/desktop capture | `references/extensions/media-capture.md` |
+| User scripts | `references/extensions/user-scripts.md` |
+| Message passing | `references/extensions/message-passing.md` |
+| Icons | `references/extensions/icons.md` |
+| CHROMEWEBSTORE.md template | `references/webstore/chromewebstore-template.md` |
+| Privacy policy guidance | `references/webstore/privacy-policy.md` |
+| Pre-publish review checklist | `references/webstore/review-checklist.md` |
+| Store listing tips & rejections | `references/webstore/store-listing.md` |
+
+## Output Checklist
+
+Verify EVERY item before delivering:
+
+- [ ] `manifest_version: 3` β€” no V2 APIs anywhere
+- [ ] All icon files referenced in manifest exist as real files with correct dimensions β€” or icons are omitted
+- [ ] Side panel has an explicit open trigger (not just a manifest declaration)
+- [ ] Code execution uses sandbox/blob/srcdoc β€” no `eval()` in extension pages
+- [ ] `tabs` permission declared if `tab.url` or `tab.title` is accessed
+- [ ] All code uses `async`/`await` β€” no `.then()` chains
+- [ ] Content scripts batch DOM updates with `requestAnimationFrame`
+- [ ] Service worker stores NO state in global variables β€” uses `chrome.storage`
+- [ ] No inline scripts or event handlers in HTML
+- [ ] Context menu actions show user confirmation
+- [ ] `"action": {}` (or more) present in manifest if using `chrome.action.*` APIs
+- [ ] If reading/scripting tabs from a side panel: use `tabs` + `host_permissions` (NOT `activeTab`)
+- [ ] DevTools panel paths in `chrome.devtools.panels.create()` are relative to extension root
+- [ ] Offscreen documents use ONLY `chrome.runtime` messaging β€” no `chrome.downloads`, `chrome.action`, etc.
+- [ ] All image refs in `chrome.notifications`, `chrome.action.setIcon`, etc. point to real files (or use data URLs)
+- [ ] Tab/desktop capture uses state locking to prevent double-start errors
+- [ ] `chrome.desktopCapture.chooseDesktopMedia` passes `targetTab` with `tabs` permission
+- [ ] `chrome.windows` calls use `getAll`/`getLastFocused`/`getCurrent` β€” NOT `.query()` (it doesn't exist)
+- [ ] `chrome.userScripts` availability checked before use (API throws if user hasn't enabled it)
+- [ ] User script configs persisted in `chrome.storage` and restored on `runtime.onInstalled` `"update"` reason
+- [ ] `configureWorld({ messaging: true })` called before user scripts send messages; listening on `onUserScriptMessage` not `onMessage`
+- [ ] `ScriptSource` entries each have exactly one of `code` or `file` (not both, not neither)
+- [ ] User script `id` values do not start with underscore
+- [ ] `sidePanel.setPanelBehavior` uses `openPanelOnActionClick` β€” NOT `openPanelOnActionIconClick`
+- [ ] Error handling on all async operations
+- [ ] `host_permissions` scoped to specific domains (not `<all_urls>` unless needed)
+- [ ] `return true` in `onMessage` listeners with async responses
+- [ ] Any use of `"tab"` in `chrome.contextMenus` `contexts` requires Chrome M150+
diff --git a/.agents/skills/chrome-extensions/references/extensions/api-calling.md b/.agents/skills/chrome-extensions/references/extensions/api-calling.md
new file mode 100644
index 0000000..d1cfba5
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/api-calling.md
@@ -0,0 +1,72 @@
+# Calling External APIs from Extensions
+
+## Permissions
+
+Ordinarily, fetch requests made by extensions follow normal CORS rules.
+
+To determine if this is sufficient, use `curl` to call the API with a test origin. For example:
+
+```
+curl -H "Origin: https://example.com" -I https://api.openweathermap.org/data/2.5/weather?q=London&appid=KEY`
+```
+
+If the response includes either `*` or `https://example.com` as the value for the `Access-Control-Allow-Origin` header, the API supports CORS.
+
+If the API does not support CORS, request host permissions to bypass these restrictions:
+
+```json
+{
+  "host_permissions": [
+    "https://no-cors-api.example.com/*"
+  ]
+}
+```
+
+**Do NOT use `<all_urls>` just for API calls.** Scope to the specific API domains.
+
+## Where to Make API Calls
+
+API calls work from any extension context (service worker, popup, side panel, content scripts):
+
+```js
+// From popup or service worker
+const response = await fetch('https://api.openweathermap.org/data/2.5/weather?q=London&appid=KEY');
+const data = await response.json();
+```
+
+**Content scripts** can also make fetch calls, but they follow the web page's CORS rules.
+
+## Error Handling Pattern
+
+```js
+async function callAPI(url) {
+  try {
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+    }
+    return await response.json();
+  } catch (err) {
+    if (err instanceof TypeError) {
+      // Network error (offline, DNS failure, etc.)
+      console.error('Network error:', err.message);
+    } else {
+      console.error('API error:', err.message);
+    }
+    return null;
+  }
+}
+```
+
+## API Keys
+
+- Never hardcode API keys in published extensions
+- Use `chrome.storage.local` for user-provided keys
+- For your own backend, use `chrome.identity` to authenticate instead of embedding keys
+- Mark placeholder keys clearly: `const API_KEY = 'YOUR_API_KEY_HERE';`
+
+## Service Worker Considerations
+
+If making API calls from the service worker, remember it can terminate. For long-polling or
+webhook-style patterns, use `chrome.offscreen` to create an offscreen document that stays alive,
+or use `chrome.alarms` for periodic polling.
diff --git a/.agents/skills/chrome-extensions/references/extensions/auth-identity.md b/.agents/skills/chrome-extensions/references/extensions/auth-identity.md
new file mode 100644
index 0000000..bc81971
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/auth-identity.md
@@ -0,0 +1,141 @@
+# Authentication with chrome.identity
+
+## Setup
+
+```json
+{
+  "permissions": ["identity"],
+  "oauth2": {
+    "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
+    "scopes": [
+      "https://www.googleapis.com/auth/userinfo.profile",
+      "https://www.googleapis.com/auth/userinfo.email"
+    ]
+  }
+}
+```
+
+## Getting an OAuth Token
+
+```js
+async function signIn() {
+  return new Promise((resolve, reject) => {
+    chrome.identity.getAuthToken({ interactive: true }, (token) => {
+      if (chrome.runtime.lastError) {
+        reject(new Error(chrome.runtime.lastError.message));
+      } else {
+        resolve(token);
+      }
+    });
+  });
+}
+```
+
+Or with the promise-based API (Chrome 116+):
+```js
+const { token } = await chrome.identity.getAuthToken({ interactive: true });
+```
+
+## Fetching User Profile
+
+```js
+async function getUserProfile(token) {
+  const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
+    headers: { Authorization: `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error('Failed to fetch profile');
+  return response.json();
+  // Returns: { sub, name, given_name, family_name, picture, email, email_verified }
+}
+```
+
+## Sign Out
+
+```js
+async function signOut(token) {
+  // Remove cached token
+  await chrome.identity.removeCachedAuthToken({ token });
+
+  // Optionally revoke the token server-side
+  await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${token}`);
+}
+```
+
+## Error Handling
+
+```js
+try {
+  const { token } = await chrome.identity.getAuthToken({ interactive: true });
+  const profile = await getUserProfile(token);
+  displayProfile(profile);
+} catch (err) {
+  if (err.message.includes('canceled')) {
+    showMessage('Sign-in was cancelled');
+  } else if (err.message.includes('not granted')) {
+    showMessage('Permission was denied');
+  } else {
+    showMessage('Sign-in failed: ' + err.message);
+  }
+}
+```
+
+## Setting Up Google Cloud Console
+
+1. Go to console.cloud.google.com
+2. Create a project (or select existing)
+3. Enable "Google People API" or "Google OAuth2 API"
+4. Create OAuth 2.0 credentials β†’ Chrome Extension type
+5. Set the Application ID to your extension's ID
+6. Copy the client_id to your manifest.json
+
+### Extension ID: Development vs Production
+
+**This is critical and often missed.** The OAuth `client_id` is tied to a specific extension ID.
+The extension ID changes depending on how you load the extension:
+
+| Context | How ID is determined |
+|---------|---------------------|
+| Unpacked (development) | Derived from the extension's directory path β€” changes if you move the folder |
+| Packed (.crx) | Derived from the private key used to pack |
+| Chrome Web Store | Assigned by the store, permanent |
+
+**To get a stable ID during development**, add a `"key"` field to your manifest.json.
+This ensures the same extension ID regardless of directory path:
+
+1. Pack your extension once (`chrome://extensions` β†’ Pack Extension)
+2. Open the generated `.crx` as a ZIP, extract the `key` from its manifest
+3. Add that key to your development manifest:
+
+```json
+{
+  "key": "MIIBIjANBgkqhk...your-public-key-here...",
+  "manifest_version": 3,
+  "name": "My Extension"
+}
+```
+
+Alternatively, note your unpacked extension's ID from `chrome://extensions` and configure
+the OAuth client for that specific ID. Just be aware it will change if the folder moves.
+
+**Always tell users:** "After publishing to the Chrome Web Store, update your OAuth client
+configuration with the store-assigned extension ID."
+
+## Non-Google OAuth (launchWebAuthFlow)
+
+For third-party OAuth providers (GitHub, Twitter, etc.):
+
+```js
+const redirectUrl = chrome.identity.getRedirectURL();
+// Returns: https://<extension-id>.chromiumapp.org/
+
+const authUrl = `https://github.com/login/oauth/authorize?client_id=XXX&redirect_uri=${redirectUrl}`;
+
+const responseUrl = await chrome.identity.launchWebAuthFlow({
+  url: authUrl,
+  interactive: true
+});
+
+// Parse the token from responseUrl
+const url = new URL(responseUrl);
+const code = url.searchParams.get('code');
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/content-scripts.md b/.agents/skills/chrome-extensions/references/extensions/content-scripts.md
new file mode 100644
index 0000000..d87c58d
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/content-scripts.md
@@ -0,0 +1,112 @@
+# Content Scripts & DOM Manipulation
+
+## Two Ways to Inject
+
+### 1. Static (manifest declaration)
+```json
+{
+  "content_scripts": [{
+    "matches": ["<all_urls>"],
+    "js": ["content/content.js"],
+    "css": ["content/content.css"],
+    "run_at": "document_idle"
+  }]
+}
+```
+
+### 2. Programmatic (from service worker or popup)
+```js
+// Requires "scripting" permission and host access
+chrome.scripting.executeScript({
+  target: { tabId: tabId },
+  files: ['content/content.js']
+});
+
+// Or inject a function directly
+chrome.scripting.executeScript({
+  target: { tabId: tabId },
+  func: (param) => {
+    document.body.style.backgroundColor = param;
+  },
+  args: ['yellow']
+});
+```
+
+Use `activeTab` permission for on-click injection (no host_permissions needed):
+```json
+{
+  "permissions": ["activeTab", "scripting"]
+}
+```
+
+## Isolated World
+
+Content scripts run in an isolated world:
+- They share the DOM with the page but NOT JavaScript variables
+- They can access chrome.runtime messaging APIs
+- The page's CSP does NOT restrict content script code
+- `window` refers to the content script's isolated world
+
+## Message Passing from Content Scripts
+
+```js
+// content.js β†’ service worker
+chrome.runtime.sendMessage({ type: 'DATA', payload: data }, (response) => {
+  console.log('Got response:', response);
+});
+
+// service worker β†’ content script in a specific tab
+chrome.tabs.sendMessage(tabId, { type: 'UPDATE', data: newData });
+
+// content.js: listen for messages
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'GET_CONTENT') {
+    const text = document.body.innerText;
+    sendResponse({ text });
+  }
+  return true; // Keep channel open for async sendResponse
+});
+```
+
+## DOM Manipulation Best Practices
+
+- **Avoid blocking the main thread** when modifying many DOM elements. Use `requestAnimationFrame`
+  to batch visual updates and `scheduler.yield()` to break up long-running tasks:
+
+```js
+// ❌ BAD: Blocks the main thread while processing hundreds of elements
+const emails = document.body.innerText.match(/[\w.+-]+@[\w-]+\.[\w.]+/g);
+emails.forEach(email => {
+  // ... find and highlight each email (can freeze the page)
+});
+
+// βœ… GOOD: Process in batches using requestAnimationFrame
+async function highlightEmails(elements) {
+  const BATCH_SIZE = 20;
+  for (let i = 0; i < elements.length; i += BATCH_SIZE) {
+    const batch = elements.slice(i, i + BATCH_SIZE);
+    await new Promise(resolve => requestAnimationFrame(() => {
+      batch.forEach(el => el.style.backgroundColor = 'yellow');
+      resolve();
+    }));
+    // Yield to the main thread between batches
+    if (typeof scheduler !== 'undefined' && scheduler.yield) {
+      await scheduler.yield();
+    }
+  }
+}
+```
+
+- Use `MutationObserver` for dynamic pages (SPAs, infinite scroll)
+- Namespace your CSS classes to avoid conflicts (e.g., `myext-highlight`)
+- Use Shadow DOM for complex UI injected into pages
+- Clean up on removal: `chrome.runtime.onMessage` listeners persist until the content script context is destroyed
+- Use `TreeWalker` or `document.createNodeIterator` instead of regex on `innerHTML` for finding text in the DOM β€” this is more reliable and doesn't break event listeners
+
+## `run_at` Timing
+
+| Value | When |
+|-------|------|
+| `document_start` | Before DOM is constructed (useful for blocking) |
+| `document_idle` | After DOM is ready but before all resources load (default, recommended) |
+| `document_end` | After DOM is complete but before images/subframes |
diff --git a/.agents/skills/chrome-extensions/references/extensions/context-menus.md b/.agents/skills/chrome-extensions/references/extensions/context-menus.md
new file mode 100644
index 0000000..d04b3e7
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/context-menus.md
@@ -0,0 +1,72 @@
+# Context Menus
+
+## Setup
+
+```json
+{ "permissions": ["contextMenus"] }
+```
+
+## Creating Menus
+
+Create in the service worker, typically in `onInstalled` (menus persist, but re-creating is idempotent):
+
+```js
+chrome.runtime.onInstalled.addListener(() => {
+  chrome.contextMenus.create({
+    id: 'save-link',
+    title: 'Save to Reading List',
+    contexts: ['link']        // Only show on right-click of links
+  });
+
+  chrome.contextMenus.create({
+    id: 'translate-selection',
+    title: 'Translate "%s"',   // %s = selected text
+    contexts: ['selection']
+  });
+
+  // M150+: Context menu for the tab strip (right-clicking a tab)
+  chrome.contextMenus.create({
+    id: 'duplicate-tab',
+    title: 'Custom Duplicate Tab',
+    contexts: ['tab']
+  });
+});
+```
+
+## Handling Clicks
+
+```js
+chrome.contextMenus.onClicked.addListener((info, tab) => {
+  switch (info.menuItemId) {
+    case 'save-link':
+      saveLink(info.linkUrl, info.selectionText || info.linkUrl);
+      break;
+    case 'translate-selection':
+      translateText(info.selectionText, tab.id);
+      break;
+    case 'duplicate-tab':
+      chrome.tabs.duplicate(tab.id); // 'tab' parameter is the clicked tab
+      break;
+  }
+});
+```
+
+## Context Types
+
+`all`, `page`, `frame`, `selection`, `link`, `editable`, `image`, `video`, `audio`, `launcher`, `browser_action`, `action`, `tab` (M150+)
+
+## Submenus
+
+```js
+chrome.contextMenus.create({ id: 'parent', title: 'My Extension', contexts: ['page'] });
+chrome.contextMenus.create({ id: 'child1', parentId: 'parent', title: 'Option 1', contexts: ['page'] });
+chrome.contextMenus.create({ id: 'child2', parentId: 'parent', title: 'Option 2', contexts: ['page'] });
+```
+
+## Dynamic Updates
+
+```js
+chrome.contextMenus.update('save-link', { title: 'New Title' });
+chrome.contextMenus.remove('save-link');
+chrome.contextMenus.removeAll();
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/csp-sandbox.md b/.agents/skills/chrome-extensions/references/extensions/csp-sandbox.md
new file mode 100644
index 0000000..3a19438
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/csp-sandbox.md
@@ -0,0 +1,177 @@
+# CSP & Sandboxed Code Execution
+
+## Extension CSP Restrictions
+
+Chrome Extensions enforce a strict Content Security Policy that cannot be relaxed for extension
+pages (popup, side panel, options, new tab, etc.).
+
+Blocked by default:
+- `eval()`, `new Function()`, `setTimeout("string")`
+- Inline `<script>` tags
+- Inline event handlers (`onclick="..."`, `onload="..."`, etc.)
+- `javascript:` URLs
+
+## HTML Best Practices
+
+```html
+<!-- ❌ BAD: Inline script -->
+<script>
+  document.getElementById('btn').onclick = () => alert('hi');
+</script>
+
+<!-- ❌ BAD: Inline event handler -->
+<button onclick="doThing()">Click</button>
+
+<!-- βœ… GOOD: External script file -->
+<script src="popup.js"></script>
+```
+
+In `popup.js`:
+```js
+document.getElementById('btn').addEventListener('click', () => {
+  // Handle click
+});
+```
+
+## Executing User Code (Code Playground Pattern)
+
+If you need to execute arbitrary code (e.g., a CodePen-like playground), you MUST use one of these
+approaches. **Extension CSP completely blocks `eval()`, `new Function()`, and inline scripts in
+normal extension pages.** There is no way around this β€” you need sandboxing.
+
+### Option 1: Sandboxed Page in Manifest (Recommended)
+
+Declare a sandboxed page in manifest.json. Sandboxed pages have a relaxed CSP that allows
+`eval()` and inline scripts, but they cannot access chrome.* APIs.
+
+```json
+{
+  "sandbox": {
+    "pages": ["sandbox.html"]
+  }
+}
+```
+
+Use an iframe in your extension page to embed the sandbox:
+
+```html
+<!-- playground.html (extension page) -->
+<iframe id="preview" src="sandbox.html"></iframe>
+```
+
+**CRITICAL:** Communication between the extension page and the sandboxed iframe MUST use
+`postMessage`. You CANNOT access `iframe.contentDocument` or `iframe.contentWindow.document`
+directly β€” this will throw:
+
+```
+SecurityError: Blocked a frame with origin "chrome-extension://..." from accessing a cross-origin frame.
+```
+
+Correct pattern:
+
+```js
+// playground.js β€” send code to sandbox
+const iframe = document.getElementById('preview');
+iframe.contentWindow.postMessage({
+  html: htmlCode,
+  css: cssCode,
+  js: jsCode
+}, '*');
+
+// sandbox.js β€” receive and execute
+window.addEventListener('message', (event) => {
+  const { html, css, js } = event.data;
+  // Clear previous content
+  document.body.innerHTML = '';
+  document.head.querySelectorAll('style.user-style').forEach(s => s.remove());
+
+  // Apply HTML
+  const container = document.createElement('div');
+  container.innerHTML = html;
+  document.body.appendChild(container);
+
+  // Apply CSS
+  const style = document.createElement('style');
+  style.className = 'user-style';
+  style.textContent = css;
+  document.head.appendChild(style);
+
+  // Execute JS (eval is allowed in sandbox!)
+  try {
+    eval(js);
+  } catch (e) {
+    const errEl = document.createElement('pre');
+    errEl.style.color = 'red';
+    errEl.textContent = e.message;
+    document.body.appendChild(errEl);
+  }
+});
+```
+
+### Option 2: Blob URL in iframe
+
+Create a self-contained HTML document via blob URL:
+
+```js
+function updatePreview(htmlCode, cssCode, jsCode) {
+  const html = `
+<!DOCTYPE html>
+<html>
+<head><style>${cssCode}</style></head>
+<body>
+  ${htmlCode}
+  <script>${jsCode}<\/script>
+</body>
+</html>
+`;
+  const blob = new Blob([html], { type: 'text/html' });
+  const url = URL.createObjectURL(blob);
+  const iframe = document.getElementById('preview');
+  // Revoke previous URL
+  if (iframe.dataset.blobUrl) URL.revokeObjectURL(iframe.dataset.blobUrl);
+  iframe.dataset.blobUrl = url;
+  iframe.src = url;
+}
+```
+
+### Option 3: srcdoc Attribute
+
+```js
+const iframe = document.getElementById('preview');
+iframe.srcdoc = `
+  <!DOCTYPE html>
+  <style>${cssCode}</style>
+  ${htmlCode}
+  <script>${jsCode}<\/script>
+`;
+```
+
+Both blob URLs and srcdoc create a separate origin, so they bypass the extension's CSP.
+However, they also cannot access chrome.* APIs, and you cannot access their DOM directly
+from the extension page (same cross-origin restriction as sandbox).
+
+### What NOT to Do
+
+```js
+// ❌ WILL FAIL: Trying to set iframe content directly
+iframe.contentDocument.open();
+iframe.contentDocument.write(html);
+iframe.contentDocument.close();
+
+// ❌ WILL FAIL: Accessing cross-origin sandbox DOM
+const doc = iframe.contentWindow.document;
+doc.body.innerHTML = html;
+
+// ❌ WILL FAIL: eval in a normal extension page
+eval(userCode); // CSP blocks this
+```
+
+## CSP for Remote Resources
+
+Extension pages cannot load remote scripts by default. If you need external libraries:
+
+1. **Bundle them** β€” download and include in your extension
+2. **Use chrome.scripting to inject into web pages** β€” web pages have their own CSP
+
+For content scripts injected into web pages, the web page's CSP does NOT apply to the
+content script's own code. Content scripts run in an isolated world.
diff --git a/.agents/skills/chrome-extensions/references/extensions/declarative-net-request.md b/.agents/skills/chrome-extensions/references/extensions/declarative-net-request.md
new file mode 100644
index 0000000..6a24a67
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/declarative-net-request.md
@@ -0,0 +1,112 @@
+# Declarative Net Request (Content Filtering)
+
+## Setup
+
+```json
+{
+  "permissions": ["declarativeNetRequest"],
+  "declarative_net_request": {
+    "rule_resources": [{
+      "id": "ruleset_1",
+      "enabled": true,
+      "path": "rules/rules.json"
+    }]
+  }
+}
+```
+
+Add `"declarativeNetRequestFeedback"` permission to use `onRuleMatchedDebug` (dev only).
+
+## Rule Format
+
+`rules/rules.json`:
+```json
+[
+  {
+    "id": 1,
+    "priority": 1,
+    "action": { "type": "block" },
+    "condition": {
+      "urlFilter": "doubleclick.net",
+      "resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame"]
+    }
+  },
+  {
+    "id": 2,
+    "priority": 1,
+    "action": { "type": "block" },
+    "condition": {
+      "urlFilter": "google-analytics.com",
+      "resourceTypes": ["script", "xmlhttprequest"]
+    }
+  }
+]
+```
+
+### Rule Fields
+
+- `id`: Unique integer per rule
+- `priority`: Higher priority rules win conflicts
+- `action.type`: `"block"`, `"redirect"`, `"allow"`, `"modifyHeaders"`, `"allowAllRequests"`, `"upgradeScheme"`
+- `condition.urlFilter`: Pattern matching (supports `*`, `||`, `|`, `^`)
+- `condition.resourceTypes`: Array of resource types to match
+
+### URL Filter Patterns
+
+| Pattern | Matches |
+|---------|---------|
+| `"doubleclick.net"` | Any URL containing "doubleclick.net" |
+| `"||doubleclick.net"` | Domain starts with doubleclick.net |
+| `"||example.com/ads/*"` | Specific path pattern |
+| `*://*.tracking.com/*` | Subdomain matching |
+
+### Resource Types
+
+`main_frame`, `sub_frame`, `stylesheet`, `script`, `image`, `font`, `object`, `xmlhttprequest`,
+`ping`, `csp_report`, `media`, `websocket`, `webtransport`, `webbundle`, `other`
+
+## Dynamic Rules (runtime)
+
+```js
+// Add rules at runtime
+await chrome.declarativeNetRequest.updateDynamicRules({
+  addRules: [{
+    id: 1000,
+    priority: 1,
+    action: { type: 'block' },
+    condition: { urlFilter: 'ads.example.com' }
+  }],
+  removeRuleIds: [] // IDs to remove
+});
+```
+
+## Tracking Blocked Requests
+
+`onRuleMatchedDebug` only works in dev (unpacked) and requires `declarativeNetRequestFeedback`:
+
+```js
+chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
+  // info.request, info.rule
+});
+```
+
+For production, count via `webRequest` (observe only) or maintain counts with `webNavigation`:
+
+```js
+// Alternative: Use webRequest to observe (requires host_permissions)
+chrome.webRequest.onBeforeRequest.addListener(
+  (details) => {
+    // Count requests to known tracking domains
+    if (isTrackerDomain(new URL(details.url).hostname)) {
+      incrementBlockCount(details.tabId);
+    }
+  },
+  { urls: ["<all_urls>"] }
+);
+```
+
+## Limits
+
+- Static rules: 30,000 guaranteed per extension, plus an additional 300,000 from a pool shared between extensions
+- Dynamic rules: 30,000
+- Session rules: 5,000
diff --git a/.agents/skills/chrome-extensions/references/extensions/devtools.md b/.agents/skills/chrome-extensions/references/extensions/devtools.md
new file mode 100644
index 0000000..6c99e83
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/devtools.md
@@ -0,0 +1,102 @@
+# DevTools Panels
+
+## Setup
+
+```json
+{
+  "devtools_page": "devtools/devtools.html"
+}
+```
+
+The devtools page runs ONLY when DevTools is open. It's invisible β€” its job is to create panels.
+
+## Creating a Panel
+
+`devtools/devtools.html`:
+```html
+<!DOCTYPE html>
+<html>
+<body>
+  <script src="devtools.js"></script>
+</body>
+</html>
+```
+
+`devtools/devtools.js`:
+```js
+chrome.devtools.panels.create(
+  'My Panel',                    // Title shown in DevTools tab
+  'icons/icon-16.png',           // Icon (optional, can be empty string)
+  'devtools/panel/panel.html',   // Panel content page β€” RELATIVE TO EXTENSION ROOT
+  (panel) => {
+    // panel.onShown.addListener((window) => { ... });
+    // panel.onHidden.addListener(() => { ... });
+  }
+);
+```
+
+**CRITICAL: The panel path is relative to the extension root**, NOT relative to the devtools.js
+file. This is the most common DevTools extension bug.
+
+```js
+// ❌ WRONG β€” resolves to <ext-root>/panel/panel.html (file not found)
+chrome.devtools.panels.create("My Panel", "", "panel/panel.html");
+
+// βœ… CORRECT β€” resolves to <ext-root>/devtools/panel/panel.html
+chrome.devtools.panels.create("My Panel", "", "devtools/panel/panel.html");
+```
+
+## Panel Content
+
+`devtools/panel/panel.html` is a regular extension page with full chrome.* API access.
+
+## Accessing DevTools APIs
+
+Only available in the devtools page and panels:
+
+```js
+// Get inspected window's tab ID
+const tabId = chrome.devtools.inspectedWindow.tabId;
+
+// Evaluate JS in the inspected page
+chrome.devtools.inspectedWindow.eval('document.title', (result, isException) => {
+  console.log('Page title:', result);
+});
+
+// Monitor network requests
+chrome.devtools.network.onRequestFinished.addListener((request) => {
+  // request.request.url, request.response.status, etc.
+  // HAR entry format
+});
+
+// Get all captured requests
+chrome.devtools.network.getHAR((harLog) => {
+  harLog.entries.forEach((entry) => { /* process */ });
+});
+```
+
+## Communication Architecture
+
+DevTools pages/panels CANNOT directly talk to the service worker via `chrome.runtime.sendMessage`
+in all cases. Use a connection pattern:
+
+```js
+// In panel JS β€” connect to service worker
+const port = chrome.runtime.connect({ name: 'devtools-panel' });
+port.postMessage({ type: 'INIT', tabId: chrome.devtools.inspectedWindow.tabId });
+port.onMessage.addListener((msg) => { /* handle */ });
+
+// In service worker
+chrome.runtime.onConnect.addListener((port) => {
+  if (port.name === 'devtools-panel') {
+    port.onMessage.addListener((msg) => { /* handle */ });
+  }
+});
+```
+
+## Important Notes
+
+- DevTools pages exist per-DevTools-window (one per inspected tab)
+- They are destroyed when DevTools closes
+- `chrome.devtools.*` APIs are ONLY available in the devtools page context, not in the service worker
+- Panels can inject scripts into the inspected page via `chrome.devtools.inspectedWindow.eval()`
diff --git a/.agents/skills/chrome-extensions/references/extensions/icons.md b/.agents/skills/chrome-extensions/references/extensions/icons.md
new file mode 100644
index 0000000..a234086
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/icons.md
@@ -0,0 +1,101 @@
+# Generating Extension Icons
+
+## Quick: Omit icons
+
+If generating real icon files is impractical, **omit `icons` and `default_icon` from manifest.json entirely**. Chrome uses a default puzzle-piece icon. This is always better than referencing files that don't exist.
+
+## Generate with Python (Pillow)
+
+```python
+# generate_icons.py
+from PIL import Image, ImageDraw
+import os
+
+os.makedirs('icons', exist_ok=True)
+
+for size in [16, 48, 128]:
+    img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
+    draw = ImageDraw.Draw(img)
+    margin = max(1, size // 16)
+    draw.rounded_rectangle(
+        [margin, margin, size - margin, size - margin],
+        radius=size // 4,
+        fill='#4688F1'
+    )
+    # Add a letter
+    font_size = size // 2
+    draw.text((size // 2, size // 2), 'E', fill='white', anchor='mm')
+    img.save(f'icons/icon-{size}.png')
+    print(f'Created icons/icon-{size}.png ({size}x{size})')
+```
+
+## Generate with Node.js (canvas)
+
+```js
+const { createCanvas } = require('canvas');
+const fs = require('fs');
+const path = require('path');
+
+fs.mkdirSync('icons', { recursive: true });
+
+for (const size of [16, 48, 128]) {
+  const canvas = createCanvas(size, size);
+  const ctx = canvas.getContext('2d');
+  const r = size / 4;
+  
+  // Rounded rectangle
+  ctx.beginPath();
+  ctx.roundRect(1, 1, size - 2, size - 2, r);
+  ctx.fillStyle = '#4688F1';
+  ctx.fill();
+  
+  // Letter
+  ctx.fillStyle = 'white';
+  ctx.font = `bold ${size / 2}px sans-serif`;
+  ctx.textAlign = 'center';
+  ctx.textBaseline = 'middle';
+  ctx.fillText('E', size / 2, size / 2);
+  
+  fs.writeFileSync(`icons/icon-${size}.png`, canvas.toBuffer('image/png'));
+  console.log(`Created icons/icon-${size}.png (${size}x${size})`);
+}
+```
+
+## Generate with pure SVG (no dependencies)
+
+Create SVGs and use them directly (Chrome supports SVG icons in some contexts) or convert:
+
+```bash
+for SIZE in 16 48 128; do
+  cat > "icons/icon-${SIZE}.svg" << EOF
+<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
+  <rect width="${SIZE}" height="${SIZE}" rx="$((SIZE/4))" fill="#4688F1"/>
+  <text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle"
+        fill="white" font-family="sans-serif" font-weight="bold" font-size="$((SIZE/2))">E</text>
+</svg>
+EOF
+done
+```
+
+Note: For Chrome Web Store submission, PNG is required. SVG works for development.
+
+## Manifest reference
+
+```json
+{
+  "icons": {
+    "16": "icons/icon-16.png",
+    "48": "icons/icon-48.png",
+    "128": "icons/icon-128.png"
+  },
+  "action": {
+    "default_icon": {
+      "16": "icons/icon-16.png",
+      "48": "icons/icon-48.png",
+      "128": "icons/icon-128.png"
+    }
+  }
+}
+```
+
+Each file MUST match its declared size: icon-16.png = 16Γ—16 pixels, etc.
diff --git a/.agents/skills/chrome-extensions/references/extensions/media-capture.md b/.agents/skills/chrome-extensions/references/extensions/media-capture.md
new file mode 100644
index 0000000..21333eb
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/media-capture.md
@@ -0,0 +1,140 @@
+# Media Capture (Tab & Desktop)
+
+## Choosing the Right API
+
+| Need | API |
+|------|-----|
+| Record the active tab's audio/video | `chrome.tabCapture.getMediaStreamId()` |
+| Let the user choose a screen, window, or tab | `chrome.desktopCapture.chooseDesktopMedia()` |
+
+Prefer `tabCapture` when you only need the current tab β€” it requires no user chooser dialog and
+no `"tabs"` permission. Use `desktopCapture` only when the user must select what to capture.
+
+## Tab Capture
+
+### Permissions
+
+```json
+{ "permissions": ["tabCapture"] }
+```
+
+### Pattern
+
+`chrome.tabCapture.getMediaStreamId()` runs in the **service worker** and returns a stream ID.
+The actual `getUserMedia()` call must happen in an **offscreen document** (the SW cannot access
+media streams directly).
+
+```js
+// service-worker.js
+chrome.action.onClicked.addListener(async (tab) => {
+  const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tab.id });
+  // Pass the ID to the offscreen document to call getUserMedia
+  await chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId });
+});
+
+// offscreen.js
+chrome.runtime.onMessage.addListener((msg) => {
+  if (msg.type !== 'START_CAPTURE') return;
+  (async () => {
+    const stream = await navigator.mediaDevices.getUserMedia({
+      audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: msg.streamId } },
+      video: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: msg.streamId } }
+    });
+    const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
+    // ... handle recorder events
+  })();
+});
+```
+
+## Desktop Capture
+
+### Permissions
+
+```json
+{ "permissions": ["tabs", "desktopCapture"] }
+```
+
+`"tabs"` is **required** β€” `chooseDesktopMedia` needs a `targetTab` with its `url` field
+populated, which requires the `"tabs"` permission.
+
+### Pattern
+
+```js
+// service-worker.js
+chrome.action.onClicked.addListener(async (tab) => {
+  // ❌ BROKEN β€” no targetTab
+  // chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], cb);
+
+  // βœ… CORRECT β€” pass the active tab
+  chrome.desktopCapture.chooseDesktopMedia(['screen', 'window', 'tab'], tab, (streamId) => {
+    if (!streamId) return; // User cancelled
+    // Send streamId to offscreen document for getUserMedia
+    chrome.runtime.sendMessage({ type: 'START_DESKTOP_CAPTURE', streamId });
+  });
+});
+```
+
+## State Locking β€” Prevent Double-Start Errors
+
+Both APIs fail if called while a previous capture is still active:
+- `tabCapture`: `"Cannot capture a tab with an active stream"`
+- `desktopCapture`: opens a second chooser dialog on top of the first
+
+Use a state machine stored in `chrome.storage.session` (survives service worker restarts,
+cleared on browser close):
+
+```js
+// State: 'idle' β†’ 'starting' β†’ 'recording' β†’ 'stopping' β†’ 'idle'
+chrome.action.onClicked.addListener(async (tab) => {
+  const { recordingState = 'idle' } = await chrome.storage.session.get('recordingState');
+
+  // Ignore clicks during transitions
+  if (recordingState === 'starting' || recordingState === 'stopping') return;
+
+  if (recordingState === 'idle') {
+    await chrome.storage.session.set({ recordingState: 'starting' });
+    try {
+      await startRecording(tab);
+      await chrome.storage.session.set({ recordingState: 'recording' });
+      await chrome.action.setBadgeText({ text: 'REC' });
+      await chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });
+    } catch (err) {
+      console.error('Failed to start recording:', err);
+      await chrome.storage.session.set({ recordingState: 'idle' });
+    }
+  } else if (recordingState === 'recording') {
+    await chrome.storage.session.set({ recordingState: 'stopping' });
+    try { await stopRecording(); }
+    finally {
+      await chrome.storage.session.set({ recordingState: 'idle' });
+      await chrome.action.setBadgeText({ text: '' });
+    }
+  }
+});
+```
+
+This same pattern applies to `chrome.offscreen.createDocument` (only one offscreen document
+is allowed at a time) and any other API that manages an exclusive resource.
+
+## Saving Recordings
+
+Offscreen documents cannot call `chrome.downloads` β€” send the blob back to the service worker:
+
+```js
+// offscreen.js β€” when recording stops
+recorder.ondataavailable = (e) => chunks.push(e.data);
+recorder.onstop = async () => {
+  const blob = new Blob(chunks, { type: 'video/webm' });
+  const url = URL.createObjectURL(blob);
+  // Service worker handles the download
+  await chrome.runtime.sendMessage({ type: 'SAVE_RECORDING', url });
+};
+
+// service-worker.js
+chrome.runtime.onMessage.addListener((msg) => {
+  if (msg.type !== 'SAVE_RECORDING') return;
+  chrome.downloads.download({ url: msg.url, filename: 'recording.webm' });
+});
+```
+
+See `references/extensions/message-passing.md` for the full offscreen document messaging pattern.
diff --git a/.agents/skills/chrome-extensions/references/extensions/message-passing.md b/.agents/skills/chrome-extensions/references/extensions/message-passing.md
new file mode 100644
index 0000000..7846ac4
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/message-passing.md
@@ -0,0 +1,136 @@
+# Message Passing
+
+## Basic patterns
+
+### One-way message (fire and forget)
+
+```js
+// sender (popup, content script, etc.)
+chrome.runtime.sendMessage({ type: 'LOG', data: 'hello' });
+
+// receiver (service worker)
+chrome.runtime.onMessage.addListener((message, sender) => {
+  if (message.type === 'LOG') console.log(message.data);
+});
+```
+
+### Request/response β€” IIFE + return true (most compatible)
+
+```js
+// sender
+const response = await chrome.runtime.sendMessage({ type: 'GET_DATA' });
+console.log(response.data);
+
+// receiver β€” IIFE keeps the channel open until sendResponse is called
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'GET_DATA') {
+    (async () => {
+      const data = await chrome.storage.local.get('key');
+      sendResponse({ data });
+    })();
+    return true; // REQUIRED β€” tells Chrome to keep the channel open
+  }
+});
+```
+
+### Request/response β€” return a Promise (Chrome 99+)
+
+Returning a Promise directly from the listener is now supported and cleaner than the IIFE pattern:
+
+```js
+chrome.runtime.onMessage.addListener((message, sender) => {
+  if (message.type === 'GET_DATA') {
+    return chrome.storage.local.get('key'); // returned promise resolves the response
+  }
+  // Return nothing (or undefined) for messages this listener doesn't handle
+});
+```
+
+**Note:** Requires Chrome 99+, only use when minimum Chrome version is set to 99.
+**Note:** Do NOT mix the two styles. If you return a Promise, do NOT also call `sendResponse` or `return true`.
+
+## Content script ↔ service worker
+
+```js
+// content script β†’ service worker
+const result = await chrome.runtime.sendMessage({ type: 'FETCH_DATA', url: location.href });
+
+// service worker β†’ specific tab's content script
+await chrome.tabs.sendMessage(tabId, { type: 'HIGHLIGHT', selector: '.important' });
+```
+
+## Service worker β†’ content script (targeted)
+
+Always check that the tab exists and the content script is injected:
+
+```js
+async function sendToContentScript(tabId, message) {
+  try {
+    return await chrome.tabs.sendMessage(tabId, message);
+  } catch (err) {
+    // Content script not injected yet, or tab navigated away
+    console.warn('Could not reach content script:', err.message);
+    return null;
+  }
+}
+```
+
+## Long-lived connections (ports)
+
+Use ports when you need a persistent channel (e.g., streaming data, DevTools panel):
+
+```js
+// opener (popup or content script)
+const port = chrome.runtime.connect({ name: 'my-channel' });
+port.postMessage({ type: 'START' });
+port.onMessage.addListener((msg) => console.log('received:', msg));
+port.onDisconnect.addListener(() => console.log('disconnected'));
+
+// receiver (service worker)
+chrome.runtime.onConnect.addListener((port) => {
+  if (port.name !== 'my-channel') return;
+  port.onMessage.addListener((msg) => {
+    if (msg.type === 'START') {
+      port.postMessage({ status: 'ok' });
+    }
+  });
+});
+```
+
+## Common mistakes
+
+### Missing `return true` causes response to never arrive
+
+```js
+// ❌ BROKEN β€” async work completes but channel is already closed
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  fetchSomething().then(data => sendResponse(data)); // too late
+  // missing: return true
+});
+
+// βœ… CORRECT
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  fetchSomething().then(data => sendResponse(data));
+  return true;
+});
+```
+
+### Sending to a tab before the content script is ready
+
+Content scripts are injected after the page loads. If the service worker sends a message immediately on `tabs.onUpdated`, the content script may not be listening yet. Use a handshake or retry:
+
+```js
+// content script β€” announce it's ready
+chrome.runtime.sendMessage({ type: 'CONTENT_READY' });
+
+// service worker β€” wait for CONTENT_READY before sending
+chrome.runtime.onMessage.addListener((msg, sender) => {
+  if (msg.type === 'CONTENT_READY' && sender.tab) {
+    chrome.tabs.sendMessage(sender.tab.id, { type: 'INIT_DATA', ... });
+  }
+});
+```
+
+### Multiple listeners responding
+
+Only one listener should respond to a given message type. If multiple listeners call `sendResponse`, only the first one wins and the rest are silently ignored.
diff --git a/.agents/skills/chrome-extensions/references/extensions/omnibox.md b/.agents/skills/chrome-extensions/references/extensions/omnibox.md
new file mode 100644
index 0000000..0d9816e
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/omnibox.md
@@ -0,0 +1,83 @@
+# Omnibox Integration
+
+## Setup
+
+```json
+{
+  "omnibox": { "keyword": "wiki" }
+}
+```
+
+User types `wiki` + Space in the address bar to activate.
+
+## Providing Suggestions
+
+```js
+chrome.omnibox.onInputChanged.addListener(async (text, suggest) => {
+  if (text.length < 2) return;
+
+  try {
+    const response = await fetch(
+      `https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(text)}&limit=5&format=json`
+    );
+    const [, titles, , urls] = await response.json();
+
+    const suggestions = titles.map((title, i) => ({
+      content: urls[i],
+      description: `${title} - <url>${urls[i]}</url>`
+    }));
+
+    suggest(suggestions);
+  } catch (err) {
+    console.error('Search failed:', err);
+  }
+});
+```
+
+## Handling Selection
+
+```js
+chrome.omnibox.onInputEntered.addListener((text, disposition) => {
+  const url = text.startsWith('http') ? text
+    : `https://en.wikipedia.org/wiki/${encodeURIComponent(text)}`;
+
+  switch (disposition) {
+    case 'currentTab':
+      chrome.tabs.update({ url });
+      break;
+    case 'newForegroundTab':
+      chrome.tabs.create({ url });
+      break;
+    case 'newBackgroundTab':
+      chrome.tabs.create({ url, active: false });
+      break;
+  }
+});
+```
+
+## Description Formatting
+
+Suggestions support XML-like formatting:
+- `<url>text</url>` β€” renders as URL style
+- `<match>text</match>` β€” bold match highlighting
+- `<dim>text</dim>` β€” dimmed/secondary text
+
+## Default Suggestion
+
+```js
+chrome.omnibox.onInputChanged.addListener((text, suggest) => {
+  chrome.omnibox.setDefaultSuggestion({
+    description: `Search Wikipedia for "<match>${text}</match>"`
+  });
+  // ... fetch and suggest
+});
+```
+
+## Required host_permissions
+
+If fetching suggestions from an API, declare:
+```json
+{
+  "host_permissions": ["https://en.wikipedia.org/*"]
+}
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/popup-ui.md b/.agents/skills/chrome-extensions/references/extensions/popup-ui.md
new file mode 100644
index 0000000..8e15284
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/popup-ui.md
@@ -0,0 +1,94 @@
+# Popup UI
+
+## Setup
+
+```json
+{
+  "action": {
+    "default_popup": "popup/popup.html",
+    "default_icon": {
+      "16": "icons/icon-16.png",
+      "48": "icons/icon-48.png",
+      "128": "icons/icon-128.png"
+    },
+    "default_title": "My Extension"
+  }
+}
+```
+
+## Key Constraints
+
+- Popup closes when the user clicks outside it β€” don't rely on it staying open
+- Default max size: 800x600 px. Set size via CSS on body/html
+- All scripts must be external files (CSP β€” no inline scripts)
+- All event listeners must use `addEventListener` (no inline handlers)
+
+## Popup HTML Template
+
+```html
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <style>
+    body { width: 350px; min-height: 200px; padding: 16px; font-family: system-ui; }
+  </style>
+</head>
+<body>
+  <h1>My Extension</h1>
+  <div id="content"></div>
+  <script src="popup.js"></script>
+</body>
+</html>
+```
+
+## Persistence
+
+Popup state is lost when closed. Use `chrome.storage` for persistence:
+
+```js
+// Save on change
+document.getElementById('input').addEventListener('input', (e) => {
+  chrome.storage.local.set({ savedInput: e.target.value });
+});
+
+// Restore on open
+document.addEventListener('DOMContentLoaded', async () => {
+  const { savedInput = '' } = await chrome.storage.local.get('savedInput');
+  document.getElementById('input').value = savedInput;
+});
+```
+
+Note: `localStorage` technically works in popups (they have a persistent origin), but
+`chrome.storage` is strongly preferred because it works across all extension contexts
+and supports sync.
+
+## Communicating with Service Worker
+
+```js
+// From popup
+const response = await chrome.runtime.sendMessage({ type: 'GET_STATUS' });
+
+// Long-lived connection
+const port = chrome.runtime.connect({ name: 'popup' });
+port.postMessage({ type: 'INIT' });
+port.onMessage.addListener((msg) => { /* handle */ });
+```
+
+## Dynamic Popup vs No Popup
+
+If you want the action click to do something instead of showing a popup, remove
+`default_popup` and use `chrome.action.onClicked`:
+
+```js
+// In service worker β€” only fires if NO popup is set
+chrome.action.onClicked.addListener((tab) => {
+  // Open side panel, inject script, etc.
+});
+```
+
+You can toggle between popup and no-popup dynamically:
+```js
+chrome.action.setPopup({ popup: 'popup/popup.html' }); // Enable popup
+chrome.action.setPopup({ popup: '' }); // Disable popup (enables onClicked)
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/prompt-api.md b/.agents/skills/chrome-extensions/references/extensions/prompt-api.md
new file mode 100644
index 0000000..bda04cd
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/prompt-api.md
@@ -0,0 +1,117 @@
+# Chrome Prompt API (LanguageModel) β€” Extension-Specific Notes
+
+The `LanguageModel` API (Prompt API) works in all extension contexts β€” service worker, popup,
+side panel, and other extension pages β€” with no additional manifest permissions required.
+
+For general Prompt API usage (availability checks, session creation, streaming, session
+management), use the `modern-web-guidance` skill.
+
+## Deprecated namespace
+
+Extensions that used the origin trial may still have the old API surface. Remove it:
+
+```js
+// ❌ OLD β€” deprecated
+const session = await self.ai.languageModel.create();
+
+// βœ… CURRENT (Chrome 138+)
+const session = await LanguageModel.create({ ... });
+```
+
+Also remove the expired permission from manifest.json:
+```json
+"permissions": ["aiLanguageModelOriginTrial"]  // ❌ remove this
+```
+
+## Extension-only: `LanguageModel.params()`
+
+Extensions have access to `LanguageModel.params()`, which returns model constraints not
+available on the web:
+
+```js
+const params = await LanguageModel.params();
+// { defaultTopK: 3, maxTopK: 128, defaultTemperature: 1, maxTemperature: 2 }
+
+const session = await LanguageModel.create({
+  temperature: 0.7,
+  topK: 5
+});
+```
+
+## Complete extension example: page summarizer
+
+A full wiring example showing manifest + service worker + side panel together.
+Note the use of `tabs` + `host_permissions` instead of `activeTab` β€” side panel button
+clicks do NOT activate `activeTab` (see Rule 12).
+
+### manifest.json
+```json
+{
+  "manifest_version": 3,
+  "name": "AI Page Summarizer",
+  "version": "1.0",
+  "permissions": ["sidePanel", "tabs", "scripting"],
+  "host_permissions": ["<all_urls>"],
+  "background": { "service_worker": "service-worker.js" },
+  "side_panel": { "default_path": "sidepanel/sidepanel.html" },
+  "action": { "default_title": "Summarize Page" }
+}
+```
+
+### service-worker.js
+```js
+chrome.action.onClicked.addListener(async (tab) => {
+  await chrome.sidePanel.open({ windowId: tab.windowId });
+});
+```
+
+### sidepanel/sidepanel.js
+```js
+const statusEl = document.getElementById('status');
+const summaryEl = document.getElementById('summary');
+
+document.getElementById('summarize').addEventListener('click', async () => {
+  if (!globalThis.LanguageModel) {
+    statusEl.textContent = 'Prompt API not available in this browser.';
+    return;
+  }
+
+  const availability = await LanguageModel.availability({
+    expectedInputs: [{ type: "text", languages: ["en"] }],
+    expectedOutputs: [{ type: "text", languages: ["en"] }]
+  });
+  if (availability === 'unavailable') {
+    statusEl.textContent = 'AI model not available on this device.';
+    return;
+  }
+
+  // Requires "tabs" + "host_permissions" β€” activeTab does NOT work from a side panel button
+  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+  const [{ result: pageText }] = await chrome.scripting.executeScript({
+    target: { tabId: tab.id },
+    func: () => {
+      const body = document.body.cloneNode(true);
+      body.querySelectorAll('script, style, nav, footer, header').forEach(el => el.remove());
+      return body.innerText.substring(0, 4000);
+    }
+  });
+
+  const session = await LanguageModel.create({
+    expectedInputs: [{ type: "text", languages: ["en"] }],
+    expectedOutputs: [{ type: "text", languages: ["en"] }],
+    initialPrompts: [{ role: 'system', content: 'Summarize web page content in 3-5 bullet points.' }],
+    monitor(m) {
+      m.addEventListener('downloadprogress', (e) => {
+        const pct = e.total ? Math.floor((e.loaded / e.total) * 100) : 0;
+        statusEl.textContent = `Downloading model: ${pct}%`;
+      });
+    }
+  });
+
+  summaryEl.textContent = '';
+  for await (const chunk of session.promptStreaming(`Summarize:\n\n${pageText}`)) {
+    summaryEl.textContent += chunk; // APPEND β€” do not replace
+  }
+  session.destroy();
+});
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/service-worker.md b/.agents/skills/chrome-extensions/references/extensions/service-worker.md
new file mode 100644
index 0000000..20b88c6
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/service-worker.md
@@ -0,0 +1,148 @@
+# Service Worker Lifetime & State Management
+
+## The Core Problem
+
+Chrome terminates extension service workers after ~30 seconds of inactivity. Unlike Manifest V2
+persistent background pages, you CANNOT rely on in-memory state.
+
+## Rules
+
+1. **Never store state in global variables** β€” treat every event handler as if the SW just started
+2. **Use chrome.storage for all persistent state** β€” read on demand, write after changes
+3. **Use chrome.alarms for timers** β€” not setTimeout/setInterval (these die with the SW)
+4. **Use chrome.storage.session for ephemeral session state** β€” survives SW restart but not browser restart
+
+## Storage Tier Selection
+
+| Need | Use |
+|------|-----|
+| Survives browser restart, syncs across devices | `chrome.storage.sync` (8KB/item, 100KB total) |
+| Survives browser restart, local only | `chrome.storage.local` (10MB default) |
+| Survives SW restart only | `chrome.storage.session` (10MB default) |
+| Never persisted (avoid) | Global variables ❌ |
+
+## Pattern: State Read-on-Demand
+
+```js
+// ❌ BAD: State in memory
+let count = 0;
+chrome.webNavigation.onCompleted.addListener(() => {
+  count++;
+  chrome.action.setBadgeText({ text: String(count) });
+});
+
+// βœ… GOOD: State in storage
+chrome.webNavigation.onCompleted.addListener(async (details) => {
+  if (details.frameId !== 0) return; // Main frame only
+  const data = await chrome.storage.local.get({ visitCount: 0 });
+  data.visitCount++;
+  await chrome.storage.local.set(data);
+  chrome.action.setBadgeText({ text: String(data.visitCount) });
+});
+```
+
+## Pattern: Alarms Instead of Timers
+
+```js
+// ❌ BAD: Timer dies when SW terminates
+setInterval(() => checkForUpdates(), 60000);
+
+// βœ… GOOD: Alarm persists
+chrome.alarms.create('check-updates', { periodInMinutes: 1 });
+chrome.alarms.onAlarm.addListener((alarm) => {
+  if (alarm.name === 'check-updates') {
+    checkForUpdates();
+  }
+});
+```
+
+Minimum alarm interval: 0.5 minutes.
+
+## Pattern: One-Time Initialization
+
+```js
+// Set up defaults and context menus on install
+chrome.runtime.onInstalled.addListener(async (details) => {
+  if (details.reason === 'install') {
+    await chrome.storage.local.set({ settings: defaultSettings });
+  }
+  // Context menus must be re-created (they persist, but re-creating is idempotent)
+  chrome.contextMenus.create({
+    id: 'myItem',
+    title: 'My Context Menu Item',
+    contexts: ['selection']
+  });
+});
+```
+
+## Pattern: Keeping the SW Alive (When Necessary)
+
+Occasionally you need the SW alive for a long-running operation. Use one of:
+
+1. **chrome.offscreen** β€” create an offscreen document for long tasks
+2. **Periodic storage writes** β€” each chrome.storage call resets the idle timer
+3. **Active port connections** β€” an open port keeps the SW alive
+
+```js
+// Port-based keepalive from popup/side panel
+const port = chrome.runtime.connect({ name: 'keepalive' });
+// The SW stays alive as long as this port is open
+```
+
+⚠️ Do NOT abuse keepalive patterns. Chrome may enforce stricter limits in future versions.
+
+## Pattern: Event Registration
+
+All event listeners MUST be registered synchronously at the top level of the service worker.
+Chrome replays events to a restarted SW, but only for listeners that were registered synchronously.
+
+```js
+// βœ… GOOD: Top-level registration
+chrome.runtime.onMessage.addListener(handleMessage);
+chrome.tabs.onUpdated.addListener(handleTabUpdate);
+chrome.webNavigation.onCompleted.addListener(handleNavigation);
+
+// ❌ BAD: Conditional or async registration
+async function setup() {
+  const { enabled } = await chrome.storage.local.get('enabled');
+  if (enabled) {
+    chrome.tabs.onUpdated.addListener(handleTabUpdate); // Too late!
+  }
+}
+setup();
+```
+
+Instead, register all listeners and check conditions inside them:
+
+```js
+chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+  const { enabled } = await chrome.storage.local.get('enabled');
+  if (!enabled) return;
+  // Process...
+});
+```
+
+## Date-Based Resets
+
+For daily counters, store the date alongside the count:
+
+```js
+function getToday() {
+  return new Date().toISOString().split('T')[0]; // "2025-01-15"
+}
+
+async function incrementDailyCount() {
+  const { dailyCount = 0, countDate = '' } = await chrome.storage.local.get(['dailyCount', 'countDate']);
+  const today = getToday();
+
+  if (countDate !== today) {
+    // New day β€” reset
+    await chrome.storage.local.set({ dailyCount: 1, countDate: today });
+    return 1;
+  } else {
+    const newCount = dailyCount + 1;
+    await chrome.storage.local.set({ dailyCount: newCount });
+    return newCount;
+  }
+}
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/side-panel.md b/.agents/skills/chrome-extensions/references/extensions/side-panel.md
new file mode 100644
index 0000000..5a1e5b7
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/side-panel.md
@@ -0,0 +1,147 @@
+# Side Panel
+
+## Setup
+
+Add to manifest.json:
+```json
+{
+  "permissions": ["sidePanel"],
+  "side_panel": {
+    "default_path": "sidepanel/sidepanel.html"
+  }
+}
+```
+
+## Opening the Side Panel β€” REQUIRED
+
+**A side panel definition alone does NOT make it openable.** You MUST provide an explicit
+trigger to open it. Without one of these, users have no way to access the panel:
+
+### Most common: Open on action icon click
+
+If the extension's primary function is the side panel, remove `default_popup` from the action
+and use `chrome.action.onClicked` to open the side panel:
+
+```js
+// service-worker.js
+chrome.action.onClicked.addListener(async (tab) => {
+  await chrome.sidePanel.open({ windowId: tab.windowId });
+});
+```
+
+⚠️ `chrome.action.onClicked` only fires when there is NO `default_popup` set. If you have both
+a popup and a side panel, open the side panel from the popup via a button, or use a different
+trigger.
+
+### Alternative triggers
+
+```js
+// Open from a context menu item
+chrome.contextMenus.onClicked.addListener(async (info, tab) => {
+  if (info.menuItemId === 'open-panel') {
+    await chrome.sidePanel.open({ windowId: tab.windowId });
+  }
+});
+
+// Open from a keyboard shortcut (defined in manifest commands)
+chrome.commands.onCommand.addListener(async (command) => {
+  if (command === 'open-side-panel') {
+    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+    await chrome.sidePanel.open({ windowId: tab.windowId });
+  }
+});
+```
+
+You can also open it for a specific tab:
+```js
+await chrome.sidePanel.open({ tabId: tab.id });
+```
+
+### Simplest: Auto-open via setPanelBehavior
+
+If the side panel should open whenever the user clicks the extension icon, use `setPanelBehavior`
+as a one-liner instead of an `onClicked` listener:
+
+```js
+// service-worker.js
+chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
+```
+
+⚠️ **The property is `openPanelOnActionClick` β€” NOT `openPanelOnActionIconClick`.**
+Using the wrong name causes a synchronous TypeError that silently aborts the service worker.
+
+When using `setPanelBehavior`, do NOT also define `default_popup` β€” the popup takes priority.
+
+## Setting Panel Per-Tab
+
+```js
+// Different side panel content for different tabs
+chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+  if (tab.url?.includes('github.com')) {
+    await chrome.sidePanel.setOptions({
+      tabId,
+      path: 'sidepanel/github-panel.html',
+      enabled: true
+    });
+  }
+});
+```
+
+## Communication with Side Panel
+
+The side panel is an extension page, so it can use all chrome.* APIs directly and communicate
+with the service worker via `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`.
+
+To get data from the active tab's content script:
+
+```js
+// In side panel JS
+async function getPageContent() {
+  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+  const response = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTENT' });
+  return response;
+}
+```
+
+Or use `chrome.scripting.executeScript` from the side panel (requires `scripting` and `activeTab` permissions):
+
+```js
+const [{ result }] = await chrome.scripting.executeScript({
+  target: { tabId: tab.id },
+  func: () => document.body.innerText
+});
+```
+
+## Side Panel vs Popup
+
+| Feature | Side Panel | Popup |
+|---------|-----------|-------|
+| Stays open | Yes | Closes when clicking away |
+| Resizable | Yes (by user) | Fixed size |
+| Coexists with page | Yes (side by side) | Overlays page |
+| Use when | Extended interaction, reading | Quick actions, settings |
+
+## Important Notes
+
+- The side panel shares a single instance per window β€” opening it replaces existing content
+- Use `chrome.sidePanel.setOptions({ enabled: false })` to disable for specific tabs
+- Side panel HTML files have full access to chrome.* APIs
+- The side panel persists across tab switches (per-window)
+
+### ⚠️ `activeTab` does NOT work from side panel interactions
+
+`activeTab` only grants tab access on direct user gestures: clicking the extension icon, context
+menu items, keyboard shortcuts, or omnibox suggestions. **Clicking a button inside a side panel
+does NOT activate `activeTab`.**
+
+If your side panel needs to read or modify page content (e.g., a "Summarize" button), use
+`tabs` + `host_permissions` instead:
+
+```json
+{
+  "permissions": ["tabs", "scripting", "sidePanel"],
+  "host_permissions": ["<all_urls>"]
+}
+```
+
+Do NOT rely on `activeTab` for side panel functionality.
diff --git a/.agents/skills/chrome-extensions/references/extensions/storage.md b/.agents/skills/chrome-extensions/references/extensions/storage.md
new file mode 100644
index 0000000..0909440
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/storage.md
@@ -0,0 +1,65 @@
+# Chrome Storage API
+
+## Storage Areas
+
+| Area | Persists | Syncs | Quota | Use For |
+|------|----------|-------|-------|---------|
+| `chrome.storage.local` | Yes | No | 10 MB | Most extension data |
+| `chrome.storage.sync` | Yes | Yes (across devices) | 100 KB total, 8 KB/item | User preferences, small data |
+| `chrome.storage.session` | Until browser close | No | 10 MB | Ephemeral state, survives SW restart |
+
+Permission required: `"storage"`
+
+## Basic Operations
+
+```js
+// Set
+await chrome.storage.local.set({ key: 'value', count: 42, items: [1,2,3] });
+
+// Get (with defaults)
+const { key = 'default', count = 0 } = await chrome.storage.local.get(['key', 'count']);
+
+// Get all
+const allData = await chrome.storage.local.get(null);
+
+// Remove
+await chrome.storage.local.remove('key');
+await chrome.storage.local.remove(['key1', 'key2']);
+
+// Clear all
+await chrome.storage.local.clear();
+```
+
+## Change Listener (Works Across All Contexts)
+
+```js
+chrome.storage.onChanged.addListener((changes, areaName) => {
+  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
+    console.log(`${areaName}.${key}: ${oldValue} β†’ ${newValue}`);
+  }
+});
+```
+
+## storage.sync Quotas
+
+Be aware of limits when using sync:
+- `QUOTA_BYTES_PER_ITEM`: 8,192 bytes per key-value pair
+- `MAX_ITEMS`: 512 items
+- `QUOTA_BYTES`: 102,400 bytes total
+- `MAX_WRITE_OPERATIONS_PER_HOUR`: 1,800
+- `MAX_WRITE_OPERATIONS_PER_MINUTE`: 120
+
+For large data, split across multiple keys or use `chrome.storage.local`.
+
+## storage.session Notes
+
+- Only available in MV3
+- Cleared when the browser closes (not just when SW terminates)
+- Accessible from service worker, popup, side panel, etc.
+- Good for: auth tokens, temporary caches, in-progress operations
+
+## Why Not localStorage?
+
+`localStorage` works in popup and extension pages but NOT in service workers.
+`chrome.storage` works everywhere and supports the cross-context change listener.
+Always prefer `chrome.storage`.
diff --git a/.agents/skills/chrome-extensions/references/extensions/tab-management.md b/.agents/skills/chrome-extensions/references/extensions/tab-management.md
new file mode 100644
index 0000000..c86855a
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/tab-management.md
@@ -0,0 +1,132 @@
+# Tab Management & Groups
+
+## Permissions
+
+```json
+{
+  "permissions": ["tabs", "tabGroups"]
+}
+```
+
+Note: `tabs` permission gives access to `url`, `title`, `favIconUrl` on Tab objects.
+Without it, you can still use `chrome.tabs` but won't see sensitive tab properties.
+
+## Querying Tabs
+
+```js
+// All tabs
+const allTabs = await chrome.tabs.query({});
+
+// Active tab in current window
+const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
+
+// Tabs matching a URL pattern
+const gmailTabs = await chrome.tabs.query({ url: '*://mail.google.com/*' });
+```
+
+## Tab Operations
+
+```js
+// Create
+const tab = await chrome.tabs.create({ url: 'https://example.com', active: true });
+
+// Update
+await chrome.tabs.update(tabId, { url: 'https://new-url.com', pinned: true });
+
+// Close
+await chrome.tabs.remove(tabId);
+await chrome.tabs.remove([tabId1, tabId2]); // Multiple
+
+// Move
+await chrome.tabs.move(tabId, { index: 0 }); // Move to first position
+
+// Reload
+await chrome.tabs.reload(tabId);
+```
+
+## Tab Groups
+
+```js
+// Create a group from tabs
+const groupId = await chrome.tabs.group({ tabIds: [tabId1, tabId2] });
+
+// Customize the group
+await chrome.tabGroups.update(groupId, {
+  title: 'Work',
+  color: 'blue',     // grey, blue, red, yellow, green, pink, purple, cyan, orange
+  collapsed: false
+});
+
+// Move tab into existing group
+await chrome.tabs.group({ tabIds: [newTabId], groupId: existingGroupId });
+
+// Ungroup
+await chrome.tabs.ungroup(tabId);
+```
+
+## Grouping by Domain
+
+```js
+async function groupByDomain() {
+  const tabs = await chrome.tabs.query({ currentWindow: true });
+  const byDomain = {};
+
+  for (const tab of tabs) {
+    try {
+      const domain = new URL(tab.url).hostname;
+      (byDomain[domain] ??= []).push(tab.id);
+    } catch { /* ignore tabs without URLs */ }
+  }
+
+  for (const [domain, tabIds] of Object.entries(byDomain)) {
+    if (tabIds.length > 1) {
+      const groupId = await chrome.tabs.group({ tabIds });
+      await chrome.tabGroups.update(groupId, {
+        title: domain.replace('www.', ''),
+        color: 'blue'
+      });
+    }
+  }
+}
+```
+
+## Events
+
+```js
+chrome.tabs.onCreated.addListener((tab) => { /* new tab */ });
+chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { /* tab changed */ });
+chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { /* tab closed */ });
+chrome.tabs.onActivated.addListener(({ tabId, windowId }) => { /* tab focused */ });
+chrome.tabGroups.onUpdated.addListener((group) => { /* group changed */ });
+```
+
+## Windows
+
+⚠️ **`chrome.windows` has NO `.query()` method.** Unlike `chrome.tabs.query()`, there is no
+`chrome.windows.query()`. Use the correct method for your need:
+
+```js
+// ❌ BROKEN
+const windows = await chrome.windows.query({ focused: true });
+// TypeError: chrome.windows.query is not a function
+
+// βœ… CORRECT
+const focused = await chrome.windows.getLastFocused({ populate: true }); // includes tabs array
+const current = await chrome.windows.getCurrent({ populate: true });
+const all     = await chrome.windows.getAll({ populate: true });
+const single  = await chrome.windows.get(windowId, { populate: true });
+```
+
+Full API: `getAll`, `getLastFocused`, `getCurrent`, `get(windowId)`, `create`, `update`, `remove`.
+Pass `{ populate: true }` to include the `tabs` array on the returned window object.
+
+```js
+// Create a new window with specific tabs
+const win = await chrome.windows.create({ url: 'https://example.com', focused: true });
+
+// Move current window to a specific position/size
+await chrome.windows.update(windowId, { left: 0, top: 0, width: 800, height: 600 });
+
+// Minimise / maximise
+await chrome.windows.update(windowId, { state: 'minimized' }); // 'normal' | 'minimized' | 'maximized' | 'fullscreen'
+```
diff --git a/.agents/skills/chrome-extensions/references/extensions/user-scripts.md b/.agents/skills/chrome-extensions/references/extensions/user-scripts.md
new file mode 100644
index 0000000..ec9dff8
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/extensions/user-scripts.md
@@ -0,0 +1,273 @@
+# User Scripts API
+
+The `chrome.userScripts` API lets extensions run **arbitrary code provided by the user** at
+runtime β€” code that cannot be shipped as part of the extension package. This is fundamentally
+different from content scripts (which are bundled with the extension) and `chrome.scripting`
+(which executes extension-owned code programmatically).
+
+**Use userScripts when:** you are building a script manager, custom automation tool, or any
+feature where the user supplies JavaScript that should run on web pages.  
+**Use content scripts when:** the injected code is written by you and ships with the extension.  
+**Use `chrome.scripting.executeScript` when:** you need one-off execution of known, extension-owned code.
+
+## Manifest
+
+```json
+{
+  "manifest_version": 3,
+  "minimum_chrome_version": "120",
+  "permissions": ["userScripts"],
+  "host_permissions": ["https://example.com/*"]
+}
+```
+
+- `"userScripts"` permission is required β€” without it `chrome.userScripts` is undefined.
+- `host_permissions` must cover the sites where scripts will be injected.
+
+## User Enablement β€” CRITICAL
+
+**The `chrome.userScripts` API requires explicit user opt-in. Without it, the API throws on
+property access.** Behavior differs by Chrome version:
+
+| Chrome version | Requirement |
+|----------------|-------------|
+| < 138 | User must enable **Developer mode** at `chrome://extensions` |
+| β‰₯ 138 | User must toggle **"Allow User Scripts"** on the extension's details page |
+
+```js
+// ❌ BROKEN β€” crashes if user hasn't enabled the API
+await chrome.userScripts.register([{ id: 'foo', matches: [...], js: [...] }]);
+// TypeError: Cannot read properties of undefined
+
+// βœ… CORRECT β€” always guard before any chrome.userScripts.* call
+function isUserScriptsAvailable() {
+  try {
+    chrome.userScripts; // throws if not enabled
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+if (!isUserScriptsAvailable()) {
+  document.getElementById('warning').style.display = 'block';
+  document.getElementById('main-ui').style.display = 'none';
+  return;
+}
+```
+
+## Execution Worlds
+
+| World | Constant | Behavior |
+|-------|----------|----------|
+| **USER_SCRIPT** (default) | `ExecutionWorld.USER_SCRIPT` | Isolated from host page JS; exempt from page CSP |
+| **MAIN** | `ExecutionWorld.MAIN` | Shares JS context with the host page; can access page variables |
+
+Use `USER_SCRIPT` (the default) for safety. Use `MAIN` only when the user's script explicitly
+needs to interact with the host page's JavaScript environment.
+
+Chrome 133+ supports multiple isolated worlds via `worldId`, letting different user scripts
+run in separate execution environments without interfering with each other.
+
+## Registering Scripts (Persistent)
+
+`register()` / `update()` / `getScripts()` / `unregister()` manage scripts that persist across
+page loads and browser sessions. Registered scripts survive the service worker being terminated.
+
+```js
+// ❌ BROKEN β€” both code and file specified (ScriptSource must have exactly one)
+await chrome.userScripts.register([{
+  id: 'my-script',
+  matches: ['https://example.com/*'],
+  js: [{ code: 'alert(1)', file: 'user-script.js' }] // Error: specify code OR file, not both
+}]);
+
+// ❌ BROKEN β€” id starts with underscore (reserved prefix)
+await chrome.userScripts.register([{ id: '_my-script', ... }]);
+// Error: User script IDs must not start with '_'
+
+// βœ… CORRECT β€” register from a file bundled with the extension
+await chrome.userScripts.register([{
+  id: 'my-user-script',
+  matches: ['https://example.com/*'],
+  js: [{ file: 'user-script.js' }],
+  runAt: 'document_idle'  // optional, this is the default
+}]);
+
+// βœ… CORRECT β€” register inline code supplied by the user
+await chrome.userScripts.register([{
+  id: 'my-user-script',
+  matches: ['https://example.com/*'],
+  js: [{ code: userProvidedCode }]
+}]);
+```
+
+Always check before calling `register()` vs `update()` β€” registering an already-existing ID
+throws an error:
+
+```js
+const existing = await chrome.userScripts.getScripts({ ids: ['my-user-script'] });
+if (existing.length > 0) {
+  await chrome.userScripts.update([{ id: 'my-user-script', js: [{ code: updatedCode }] }]);
+} else {
+  await chrome.userScripts.register([{
+    id: 'my-user-script',
+    matches: ['https://example.com/*'],
+    js: [{ code: userProvidedCode }]
+  }]);
+}
+
+// Remove a specific script
+await chrome.userScripts.unregister({ ids: ['my-user-script'] });
+
+// Remove all registered user scripts
+await chrome.userScripts.unregister();
+```
+
+## Persistence: Restore on Extension Update
+
+**Registered user scripts are cleared when the extension updates.**
+
+```js
+// ❌ BROKEN β€” scripts registered once at install time are lost on every update
+chrome.runtime.onInstalled.addListener(({ reason }) => {
+  if (reason === 'install') {
+    chrome.userScripts.register([{ id: 'foo', matches: [...], js: [...] }]);
+    // After the next extension update, 'foo' is gone β€” no re-registration
+  }
+});
+
+// βœ… CORRECT β€” persist configs in storage, restore on both install and update
+chrome.runtime.onInstalled.addListener(async ({ reason }) => {
+  if (reason === chrome.runtime.OnInstalledReason.UPDATE) {
+    const { scripts = {} } = await chrome.storage.local.get('scripts');
+    for (const s of Object.values(scripts)) {
+      await chrome.userScripts.register([s]).catch(() => {});
+    }
+  }
+});
+```
+
+Save the full script config (id, matches, js) to `chrome.storage` whenever the user saves
+a script, so it can be restored after an update.
+
+## One-Off Injection (Chrome 135+)
+
+`execute()` injects a script immediately into a specific tab/frame without registering it. It
+does not persist across page loads.
+
+```js
+// Requires Chrome 135+
+const results = await chrome.userScripts.execute({
+  target: { tabId: tabId },
+  js: [{ code: userProvidedCode }],
+  world: 'USER_SCRIPT'  // optional, default
+});
+
+for (const result of results) {
+  if (result.error) {
+    console.error('Injection failed in frame', result.frameId, result.error);
+  } else {
+    console.log('Result from frame', result.frameId, result.result);
+  }
+}
+```
+
+Guard for Chrome 135+ availability:
+
+```js
+if (typeof chrome.userScripts.execute === 'function') {
+  await chrome.userScripts.execute({ ... });
+} else {
+  // Fall back to register-based approach
+}
+```
+
+## Messaging from User Scripts
+
+User scripts run in the `USER_SCRIPT` world which has no access to `chrome.runtime` by
+default. Messaging requires an explicit opt-in.
+
+```js
+// ❌ BROKEN β€” messaging not enabled; chrome.runtime is undefined in the user script
+// sw.js
+chrome.runtime.onMessage.addListener((msg) => { ... }); // Never fires from user scripts
+
+// user script code (running in the page)
+chrome.runtime.sendMessage({ type: 'hello' }); // TypeError: chrome is not defined
+
+// βœ… CORRECT β€” opt in first, then use the dedicated listener
+// sw.js (run once, e.g. on install)
+await chrome.userScripts.configureWorld({ messaging: true });
+
+// sw.js β€” listen on the dedicated handler, not onMessage
+chrome.runtime.onUserScriptMessage.addListener((message, sender, sendResponse) => {
+  sendResponse({ ok: true });
+  return true; // keep channel open for async responses
+});
+
+// user script code β€” chrome.runtime is now available
+chrome.runtime.sendMessage({ type: 'FROM_USER_SCRIPT', data: 42 });
+```
+
+For long-lived connections use `chrome.runtime.onUserScriptConnect` (analogous to
+`runtime.onConnect` for content scripts).
+
+Chrome 133+ supports per-world messaging with `worldId`:
+
+```js
+await chrome.userScripts.configureWorld({ worldId: 'my-world', messaging: true });
+```
+
+## `configureWorld()` β€” CSP and Messaging
+
+```js
+await chrome.userScripts.configureWorld({
+  csp: "script-src 'self'",  // custom CSP for the world
+  messaging: true             // enable chrome.runtime messaging
+});
+
+// Chrome 133+: configure a named world
+await chrome.userScripts.configureWorld({
+  worldId: 'my-world',
+  messaging: true
+});
+
+// Chrome 133+: reset a world's configuration
+await chrome.userScripts.resetWorldConfiguration('my-world');
+
+// Chrome 133+: list all world configurations
+const configs = await chrome.userScripts.getWorldConfigurations();
+```
+
+## Complete Script Manager Pattern
+
+```js
+// options.js β€” save user's script and (re)register it
+async function saveScript(id, matches, code) {
+  // Persist the config so we can restore it after extension updates
+  const { scripts = {} } = await chrome.storage.local.get('scripts');
+  scripts[id] = { id, matches, js: [{ code }] };
+  await chrome.storage.local.set({ scripts });
+
+  // Register or update the live script
+  const existing = await chrome.userScripts.getScripts({ ids: [id] });
+  if (existing.length > 0) {
+    await chrome.userScripts.update([{ id, matches, js: [{ code }] }]);
+  } else {
+    await chrome.userScripts.register([{ id, matches, js: [{ code }] }]);
+  }
+}
+```
+
+## Key Differences from Content Scripts
+
+| | Content Scripts | userScripts |
+|--|-----------------|-------------|
+| Code source | Bundled with extension | Provided by user at runtime |
+| Persistence | Automatic (manifest) | Manual (register + restore on update) |
+| Arbitrary code | No | Yes |
+| User opt-in | No | Yes (Developer Mode / Allow User Scripts) |
+| Messaging | `runtime.sendMessage` | `runtime.onUserScriptMessage` (after `configureWorld`) |
+| CSP exemption | Yes | Yes (USER_SCRIPT world) |
+| Multiple worlds | No | Yes (Chrome 133+ with `worldId`) |
diff --git a/.agents/skills/chrome-extensions/references/webstore/chromewebstore-template.md b/.agents/skills/chrome-extensions/references/webstore/chromewebstore-template.md
new file mode 100644
index 0000000..976e9a5
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/webstore/chromewebstore-template.md
@@ -0,0 +1,178 @@
+# CHROMEWEBSTORE.md Template
+
+Copy this template into the project root as `CHROMEWEBSTORE.md` and fill in each section.
+Fields marked `[REQUIRED]` must be completed before submission. Fields marked `[RECOMMENDED]`
+are optional but improve listing quality and approval odds.
+
+---
+
+```markdown
+# Chrome Web Store Listing β€” [Extension Name]
+
+> Last Updated: YYYY-MM-DD
+
+## Store Listing
+
+**Extension Name** [REQUIRED]
+<!-- Must match manifest.json "name". Max 75 characters. -->
+
+
+**Short Description** [REQUIRED]
+<!-- Max 132 characters. Shown in search results and tiles. Be specific about function. -->
+
+
+**Detailed Description** [REQUIRED]
+<!-- Max 16,000 characters. Structure recommendation:
+     Line 1: One-sentence summary of what the extension does
+     Paragraph 2: Key features (use line breaks, not bullet points β€” CWS strips markdown)
+     Paragraph 3: How to use it (step-by-step)
+     Paragraph 4: Privacy/permissions note (builds trust)
+     Paragraph 5: Support/feedback info
+
+     WRITE FROM A USER'S PERSPECTIVE β€” what the extension does FOR them, not HOW it works.
+     Never mention implementation details: APIs used, libraries, frameworks, code patterns.
+
+     ❌ "Uses a MutationObserver and custom elements to track page changes"
+     βœ… "Automatically detects new content as you scroll"
+
+     ❌ "Powered by a service worker for background processing"
+     βœ… "Runs quietly in the background without slowing your browser"
+
+     ❌ "Implements declarativeNetRequest for network filtering"
+     βœ… "Blocks ads and trackers without reading your page content"
+-->
+
+
+**Category** [REQUIRED]
+<!-- Pick one: Accessibility, Blogging, Developer Tools, Fun, News & Weather,
+     Photos, Productivity, Search Tools, Shopping, Social & Communication, Sports -->
+
+
+**Single Purpose** [REQUIRED]
+<!-- One sentence. Narrow and easy to understand.
+     Good: "Highlights and saves text selections on web pages"
+     Bad:  "Productivity tool that helps you work better" -->
+
+
+**Primary Language** [REQUIRED]
+<!-- e.g., English, German, etc. -->
+
+
+## Graphics & Assets
+
+| Asset | Dimensions | Status | Filename |
+|-------|-----------|--------|----------|
+| Store Icon [REQUIRED] | 128Γ—128 PNG | ⬜ Not created | |
+| Screenshot 1 [REQUIRED] | 1280Γ—800 or 640Γ—400 | ⬜ Not created | |
+| Screenshot 2 [RECOMMENDED] | 1280Γ—800 or 640Γ—400 | ⬜ Not created | |
+| Screenshot 3 [RECOMMENDED] | 1280Γ—800 or 640Γ—400 | ⬜ Not created | |
+| Screenshot 4 | 1280Γ—800 or 640Γ—400 | ⬜ Not created | |
+| Screenshot 5 | 1280Γ—800 or 640Γ—400 | ⬜ Not created | |
+| Small Promo Tile [RECOMMENDED] | 440Γ—280 | ⬜ Not created | |
+| Marquee Promo Tile | 1400Γ—560 | ⬜ Not created | |
+
+<!-- Status options: ⬜ Not created | 🟑 Needs update | βœ… Ready -->
+
+### Screenshot Notes
+<!-- Describe what each screenshot should show. Good screenshots demonstrate the extension
+     in action, not just the popup. Include annotations if helpful. -->
+
+
+## Permissions Justification
+
+<!-- Every permission in manifest.json needs a justification. The review team reads these.
+     Be specific about WHY the permission is needed and WHAT user-facing feature uses it.
+     "Required for functionality" will be rejected. -->
+
+| Permission | Type | Justification |
+|------------|------|---------------|
+| | permissions | |
+| | host_permissions | |
+
+<!-- Type is either "permissions" or "host_permissions" -->
+
+
+## Privacy & Data Use
+
+<!-- These map to the CWS data use disclosure form. Be exhaustive and accurate β€”
+     mismatches between what you declare and what the code does cause rejection. -->
+
+### Data Collection
+
+**Does the extension collect user data?** Yes / No
+
+<!-- If Yes, fill in the table below. If No, skip to the certification. -->
+
+| Data Type | Collected? | Transmitted Off-Device? | Purpose | Shared with Third Parties? |
+|-----------|-----------|------------------------|---------|---------------------------|
+| Personally identifiable info | | | | |
+| Health info | | | | |
+| Financial info | | | | |
+| Authentication info | | | | |
+| Personal communications | | | | |
+| Location | | | | |
+| Web history | | | | |
+| User activity | | | | |
+| Website content | | | | |
+
+### Data Use Certification
+<!-- Check all that apply: -->
+- [ ] Data is NOT sold to third parties
+- [ ] Data is NOT used for purposes unrelated to the extension's core functionality
+- [ ] Data is NOT used for creditworthiness or lending purposes
+
+
+## Privacy Policy
+
+**Privacy Policy URL** [REQUIRED]
+
+<!-- Host this at a publicly accessible URL. GitHub Pages, your website, or a
+     Notion page all work. See references/webstore/privacy-policy.md for a template. -->
+
+
+## Distribution
+
+**Visibility**: Public / Unlisted / Private
+**Regions**: All regions / [List specific regions]
+
+## Developer Info
+
+**Publisher Name** [REQUIRED]
+
+**Contact Email** [REQUIRED]
+<!-- Displayed publicly on the store listing. -->
+
+**Support URL / Email** [RECOMMENDED]
+<!-- Where users go for help. Can be a GitHub Issues page, email, or support site. -->
+
+**Homepage URL** [RECOMMENDED]
+
+
+## Version History
+
+<!-- Add an entry for every version submitted to the Chrome Web Store.
+     Most recent first. -->
+
+| Version | Date | Changes | Status |
+|---------|------|---------|--------|
+| | | | Draft |
+
+<!-- Status options: Draft | Submitted | In Review | Published | Rejected -->
+
+
+## Review Notes
+
+<!-- Track rejection reasons, communication with the review team, and fixes applied.
+     This section is for your records, not published to the store. -->
+
+### Known Issues / Limitations
+<!-- Document anything reviewers might flag or users should know about. -->
+
+
+### Rejection History
+<!-- If applicable:
+| Date | Reason | Fix Applied | Resubmitted |
+|------|--------|-------------|-------------|
+-->
+
+```
diff --git a/.agents/skills/chrome-extensions/references/webstore/privacy-policy.md b/.agents/skills/chrome-extensions/references/webstore/privacy-policy.md
new file mode 100644
index 0000000..7715376
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/webstore/privacy-policy.md
@@ -0,0 +1,128 @@
+# Privacy Policy Guidance
+
+## When is a Privacy Policy Required?
+
+A privacy policy URL is **required** if your extension:
+- Handles personal or sensitive user data (as defined by CWS policies)
+- Uses any of these permissions: `identity`, `cookies`, `webRequest`, `browsingData`,
+  `history`, `bookmarks`, `topSites`, `<all_urls>` host permission
+- Collects any form of analytics or telemetry
+- Transmits any data off the user's device
+
+A privacy policy is **recommended** for all extensions, even if no data is collected.
+It demonstrates professionalism and can prevent delays if a reviewer flags your extension.
+
+## Where to Host It
+
+The privacy policy must be at a publicly accessible URL. Options:
+- **GitHub Pages**: Free, version-controlled. Create a `privacy.md` in a `docs/` branch.
+- **GitHub Gist**: Quick and dirty. Create a public gist and link to the raw URL.
+- **Project website**: If you have one, add a `/privacy` page.
+- **Notion / Google Sites**: Free hosted pages. Stable URLs.
+
+Avoid hosting on a URL that might go down or change. The CWS review team checks the link.
+
+## What to Include
+
+### Minimal Policy (No Data Collection)
+
+If your extension genuinely collects no data, the policy can be short:
+
+```
+Privacy Policy for [Extension Name]
+
+Last updated: [Date]
+
+[Extension Name] does not collect, store, or transmit any personal data or
+browsing information. All data stays on your device.
+
+This extension does not use cookies, analytics, or third-party services.
+
+If you have questions, contact [email].
+```
+
+### Standard Policy (Some Data Collection)
+
+If your extension stores or transmits data, cover these topics:
+
+1. **What data is collected** β€” Be specific. "User preferences" is not enough.
+   Say "Your selected theme preference (light/dark) and saved highlight colors."
+
+2. **How data is stored** β€” Local storage only? Synced via chrome.storage.sync?
+   Sent to a server?
+
+3. **Why data is collected** β€” Tie each data type to a specific feature.
+
+4. **Third-party services** β€” If you use any APIs (analytics, auth, etc.), name them
+   and link to their privacy policies.
+
+5. **Data sharing** β€” State whether data is shared with third parties. If yes, with
+   whom and why. If no, say so explicitly.
+
+6. **Data retention** β€” How long is data kept? Can the user delete it?
+
+7. **User controls** β€” How can users access, export, or delete their data?
+   If the extension has a "clear data" button, mention it.
+
+8. **Changes to the policy** β€” State that you'll update the policy if practices change
+   and how users will be notified.
+
+9. **Contact** β€” Email or URL for privacy questions.
+
+### Template
+
+```
+Privacy Policy for [Extension Name]
+
+Last updated: [Date]
+
+## What Data We Collect
+
+[Describe each type of data collected and the feature that requires it.]
+
+## How Data Is Stored
+
+[Describe storage mechanism β€” local only, synced, or server-side.]
+
+## How Data Is Used
+
+[Describe each use case. Tie to specific features.]
+
+## Third-Party Services
+
+[List any third-party services used. Link to their privacy policies.
+If none, state "This extension does not use any third-party services."]
+
+## Data Sharing
+
+[State whether data is shared. If yes, with whom and why.]
+
+## Data Retention and Deletion
+
+[How long data is kept. How users can delete it.]
+
+## Changes to This Policy
+
+[How and when the policy may be updated. How users will be notified.]
+
+## Contact
+
+[Email or support URL for privacy inquiries.]
+```
+
+## Common Mistakes
+
+- **Policy doesn't match the data disclosure form**: The CWS data disclosure form and your
+  privacy policy must be consistent. If the form says "no data collected" but the policy
+  mentions analytics, you'll be rejected.
+
+- **Policy is too vague**: "We may collect some data" is not acceptable. Be specific.
+
+- **Dead link**: If your privacy policy URL returns a 404, the submission is auto-rejected.
+  Verify the link before submitting.
+
+- **Missing data types**: If your extension uses `chrome.storage.sync`, that data goes to
+  Google's servers β€” disclose this. If you make any `fetch()` calls, disclose what's sent.
+
+- **No contact information**: The CWS requires a way for users to reach you about privacy
+  concerns. Include an email address at minimum.
diff --git a/.agents/skills/chrome-extensions/references/webstore/review-checklist.md b/.agents/skills/chrome-extensions/references/webstore/review-checklist.md
new file mode 100644
index 0000000..0ad374a
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/webstore/review-checklist.md
@@ -0,0 +1,145 @@
+# Pre-Publish Review Checklist
+
+Run through this checklist before every submission to the Chrome Web Store. Each item
+corresponds to a common rejection reason or publishing failure.
+
+## Manifest & Package
+
+- [ ] **manifest_version is 3** β€” Manifest V2 is no longer accepted for new submissions.
+- [ ] **Version bumped** β€” CWS rejects uploads with a version ≀ the currently published
+      version. Use semver: bump patch for fixes, minor for features, major for breaking
+      changes.
+- [ ] **Name matches CHROMEWEBSTORE.md** β€” The `name` field in manifest.json must exactly
+      match what you put in the store listing.
+- [ ] **Description in manifest ≀ 132 chars** β€” This is the short description shown in
+      chrome://extensions. It should match or be close to your CWS short description.
+- [ ] **No unnecessary files in ZIP** β€” Exclude: `.git/`, `node_modules/`, `.env`,
+      `*.map`, test files, build configs, `CHROMEWEBSTORE.md` itself, `README.md`,
+      `.DS_Store`, `thumbs.db`. Use a build script or `.cws-ignore`-style exclusion.
+- [ ] **ZIP under 2GB** β€” Maximum package size. Most extensions should be under 10MB.
+- [ ] **No absolute file paths** β€” All paths in manifest.json must be relative.
+
+## Permissions
+
+- [ ] **Minimum permissions** β€” Only request what you need. `<all_urls>` is a red flag.
+      Use specific host_permissions like `*://*.example.com/*` when possible.
+- [ ] **Every permission justified** β€” Check the Permissions Justification section in
+      CHROMEWEBSTORE.md. The CWS dashboard has a field for each permission β€” you'll need
+      to fill these in during submission.
+- [ ] **activeTab preferred over tabs + <all_urls>** β€” If you only need access to the
+      current tab when the user clicks your icon, `activeTab` is the right permission.
+- [ ] **No unused permissions** β€” If you removed a feature that used a permission, remove
+      the permission from manifest.json too. Leftover permissions cause rejection.
+- [ ] **host_permissions justified** β€” Explain which features need access to which domains
+      and why.
+
+## Store Listing Content
+
+- [ ] **Detailed description is specific** β€” Describes exactly what the extension does.
+      No vague marketing language. The review team reads this.
+- [ ] **Single purpose is narrow** β€” One sentence that clearly states the primary function.
+      "Manages bookmarks into categorized folders" not "Productivity enhancement tool."
+- [ ] **No misleading claims** β€” Don't claim features you don't have. Don't exaggerate
+      performance claims.
+- [ ] **No keyword stuffing** β€” Don't repeat keywords unnaturally in the description.
+- [ ] **No trademark violations** β€” Don't use other companies' names, logos, or trademarks
+      in your extension name, description, or screenshots unless you have authorization.
+- [ ] **Contact email is valid** β€” The email shown on the listing must be monitored. Google
+      sends important notifications (takedowns, policy changes) to this address.
+
+## Graphics
+
+- [ ] **Store icon**: 128Γ—128 PNG, no transparency issues, readable at small sizes.
+- [ ] **At least 1 screenshot**: 1280Γ—800 or 640Γ—400 pixels. Shows the extension in action.
+- [ ] **Screenshots are current** β€” Match the current version of the extension UI. Outdated
+      screenshots can trigger rejection.
+- [ ] **No misleading screenshots** β€” Screenshots must accurately represent the extension.
+- [ ] **No phone/tablet mockups** β€” Unless the extension actually works on those devices.
+- [ ] **Small promo tile** (recommended): 440Γ—280 PNG or JPEG. Used for featured placements.
+
+## Privacy & Compliance
+
+- [ ] **Data disclosure form matches reality** β€” The CWS data use disclosure checkboxes
+      must accurately reflect what the extension code actually does. Mismatch = rejection.
+- [ ] **Privacy policy URL is live** β€” Visit the URL yourself. Confirm it loads and
+      contains an actual privacy policy, not a 404 or placeholder.
+- [ ] **Privacy policy matches disclosure** β€” The text of the policy must be consistent
+      with what you declared in the disclosure form.
+- [ ] **chrome.storage.sync disclosed** β€” If you use `chrome.storage.sync`, data is
+      transmitted to Google's servers. This counts as off-device transmission.
+- [ ] **Remote code prohibition** β€” Extensions cannot execute remotely hosted code.
+      All JS must be bundled in the extension package. No loading scripts from CDNs
+      at runtime (Manifest V3 enforces this, but verify).
+- [ ] **No obfuscated code** β€” Minification is fine. Obfuscation (intentionally making
+      code unreadable) is prohibited and will cause rejection.
+
+## Functionality
+
+- [ ] **Extension works** β€” Load it unpacked in Chrome, test all features. Check the
+      console for errors.
+- [ ] **No crashes or blank popups** β€” Test popup, side panel, options page, content
+      scripts. All should load without errors.
+- [ ] **Works on intended sites** β€” If the extension targets specific websites, verify
+      it works on current versions of those sites.
+- [ ] **Graceful degradation** β€” The extension should handle edge cases (no internet,
+      empty data, restricted pages like chrome:// URLs) without crashing.
+- [ ] **Uninstall is clean** β€” No persistent side effects after the extension is removed.
+- [ ] **No excessive resource use** β€” The extension shouldn't noticeably slow down
+      browsing. Content scripts in particular should be lightweight.
+
+## Updates (for existing extensions)
+
+- [ ] **CHROMEWEBSTORE.md version history updated** β€” New entry with version, date, and
+      summary of changes.
+- [ ] **Last Updated date bumped** β€” If any user-facing changes were made.
+- [ ] **Feature list in descriptions updated** β€” If new features were added.
+- [ ] **Permissions justification updated** β€” If manifest.json permissions changed.
+- [ ] **Screenshots refreshed** β€” If the UI changed significantly.
+- [ ] **Privacy disclosures updated** β€” If data practices changed.
+
+## Packaging Script
+
+To create a clean ZIP for submission, use a script like:
+
+```bash
+#!/bin/bash
+# package-extension.sh β€” Creates a clean ZIP for Chrome Web Store submission
+
+EXTENSION_NAME="my-extension"
+VERSION=$(node -p "require('./manifest.json').version")
+OUTPUT="${EXTENSION_NAME}-v${VERSION}.zip"
+
+# Remove old package
+rm -f "$OUTPUT"
+
+# Create ZIP excluding dev files
+zip -r "$OUTPUT" . \
+  -x ".git/*" \
+  -x "node_modules/*" \
+  -x ".env" \
+  -x "*.map" \
+  -x "tests/*" \
+  -x "__tests__/*" \
+  -x "*.test.*" \
+  -x "*.spec.*" \
+  -x ".eslintrc*" \
+  -x ".prettierrc*" \
+  -x "tsconfig.json" \
+  -x "package.json" \
+  -x "package-lock.json" \
+  -x "webpack.config.*" \
+  -x "vite.config.*" \
+  -x "rollup.config.*" \
+  -x "CHROMEWEBSTORE.md" \
+  -x "README.md" \
+  -x "CHANGELOG.md" \
+  -x ".DS_Store" \
+  -x "Thumbs.db" \
+  -x "*.sh" \
+  -x "store-assets/*"
+
+echo "Packaged: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
+```
+
+Customize the exclusion list for your project. The key principle: ship only what Chrome
+needs to run the extension.
diff --git a/.agents/skills/chrome-extensions/references/webstore/store-listing.md b/.agents/skills/chrome-extensions/references/webstore/store-listing.md
new file mode 100644
index 0000000..46c7503
--- /dev/null
+++ b/.agents/skills/chrome-extensions/references/webstore/store-listing.md
@@ -0,0 +1,201 @@
+# Store Listing Tips & Common Rejections
+
+## Writing Effective Descriptions
+
+### Short Description (132 chars max)
+
+This appears in search results and category pages. It's your elevator pitch. Rules:
+
+- Start with a verb or the extension's function: "Blocks ads on all websites" not "Ad blocker"
+- Be specific: "Translates selected text into 50+ languages" not "Translation tool"
+- Include the primary keyword naturally
+- Don't waste characters on "Chrome extension" β€” the user already knows
+
+**Good examples:**
+- "Save articles to read later with one click. Works offline."
+- "Replace new tab with a minimal dashboard showing weather and tasks"
+- "Highlight and annotate text on any webpage. Export notes as Markdown."
+
+**Bad examples:**
+- "The best productivity tool for Chrome!" (vague, marketing-speak)
+- "Extension for helping you do things better" (says nothing)
+- "NEW! Amazing tab manager extension tool app for Chrome browser" (keyword stuffing)
+
+### Detailed Description (16,000 chars max)
+
+The CWS strips all markdown formatting. Use plain text with line breaks. Structure:
+
+```
+[One sentence: what does this extension do?]
+
+FEATURES
+β€’ Feature 1 β€” brief explanation
+β€’ Feature 2 β€” brief explanation
+β€’ Feature 3 β€” brief explanation
+
+HOW TO USE
+1. Click the extension icon in the toolbar
+2. [Next step]
+3. [Next step]
+
+PRIVACY
+This extension does not collect any personal data. Your [data type] is stored
+locally on your device and never transmitted to any server.
+
+PERMISSIONS
+β€’ "Read and change data on sites you visit" β€” needed to [specific feature].
+  The extension only activates when you [trigger action].
+
+SUPPORT
+Found a bug? Have a suggestion? Email [email] or open an issue at [URL].
+
+Version [X.Y.Z] β€” [Brief changelog for latest version]
+```
+
+### The Implementation-Detail Rule
+
+**Never describe how the extension is built.** Potential users are not developers evaluating your stack β€” they want to know what the extension does for them.
+
+Strip all of the following from every piece of copy:
+
+- Web API names: `MutationObserver`, `IntersectionObserver`, `Service Worker`, `Shadow DOM`, `IndexedDB`, `WebSockets`
+- Chrome API names: `chrome.storage`, `declarativeNetRequest`, `chrome.scripting`, `offscreen document`
+- Framework/library names: React, custom elements, Lit, Webpack, TypeScript
+- Architecture descriptions: "background processing", "event-driven", "declarative"
+
+**Transform every implementation sentence into a user benefit:**
+
+| Before (implementation) | After (user benefit) |
+|-------------------------|----------------------|
+| "Uses a MutationObserver to detect page changes" | "Automatically detects new content as you browse" |
+| "Built with custom elements and Shadow DOM" | "Works seamlessly without affecting page styles" |
+| "Powered by a service worker" | "Runs quietly in the background" |
+| "Your settings are synced via chrome.storage.sync" | "Your settings sync across all your devices" |
+| "Implements declarativeNetRequest for filtering" | "Blocks ads and trackers without reading your page content" |
+
+### Why This Structure Works
+
+1. **One-sentence opener** β€” The reviewer and users both scan the first line. Make it count.
+2. **Features list** β€” Users scan for capabilities. Plain-text bullets (β€’) render well.
+3. **How to use** β€” Reduces support requests and proves the extension actually works.
+4. **Privacy section** β€” Pre-empts user concerns about permissions. Builds trust.
+5. **Permissions explanation** β€” Users see permission warnings during install. If you
+   explain them in the description, they're less likely to abort installation.
+6. **Support info** β€” Required by CWS policy ("meaningful customer support").
+7. **Latest version note** β€” Shows the extension is actively maintained.
+
+### Single Purpose Statement
+
+This is filled in the developer dashboard, not shown to users. The review team reads it
+carefully. It must be a single sentence that describes the extension's narrow purpose.
+
+**Approved examples:**
+- "Saves highlighted text from web pages to a local reading list"
+- "Replaces the new tab page with a customizable dashboard"
+- "Blocks cookie consent banners on websites"
+
+**Rejected examples:**
+- "Improves your browsing experience" (too vague)
+- "Productivity and organization tool" (too broad)
+- "Highlights text, saves bookmarks, manages tabs, and blocks ads" (not single purpose)
+
+If your extension does multiple things, focus on the primary function. The detailed
+description can cover secondary features.
+
+## Common Rejection Reasons
+
+### 1. Excessive Permissions
+
+**Symptom:** "Your extension requests more permissions than it needs."
+
+**Fix:**
+- Replace `<all_urls>` with specific host patterns
+- Replace `tabs` with `activeTab` if you only need the current tab on click
+- Remove permissions you're not using
+- Ensure every permission has a clear justification
+
+### 2. Missing or Inadequate Single Purpose
+
+**Symptom:** "Your item does not have a single, clear purpose."
+
+**Fix:**
+- Rewrite the single purpose field to be narrow and specific
+- If the extension truly does too many unrelated things, consider splitting it
+
+### 3. Misleading Description or Functionality
+
+**Symptom:** "Your extension does not provide the functionality described."
+
+**Fix:**
+- Ensure every feature listed in the description actually works
+- Remove claims about features you haven't built yet
+- Don't use superlatives ("the best", "the fastest") unless verifiable
+
+### 4. Privacy Policy Issues
+
+**Symptom:** "Your extension requires a privacy policy." or "Your privacy policy URL
+is not accessible."
+
+**Fix:**
+- Host the privacy policy at a stable, public URL
+- Ensure it's not behind a login wall
+- Make sure it covers all data the extension actually collects
+- Match the privacy policy with the data disclosure form
+
+### 5. Trademark Violation
+
+**Symptom:** "Your extension uses trademarked content without authorization."
+
+**Fix:**
+- Don't use other companies' names in your extension name (e.g., "YouTube Downloader")
+- Don't use logos or brand colors that imply affiliation
+- Use generic terms: "Video Downloader for [site]" might be fine, but check the site's terms
+
+### 6. Code Readability
+
+**Symptom:** "Your extension contains obfuscated code."
+
+**Fix:**
+- Minification is allowed; obfuscation is not
+- If using a bundler (webpack, rollup, vite), ensure source maps are NOT included but
+  the output is minified, not obfuscated
+- Don't use string encoding tricks to hide code intent
+
+### 7. Remote Code Execution
+
+**Symptom:** "Your extension executes remotely hosted code."
+
+**Fix:**
+- Bundle all JavaScript in the extension package
+- Don't load scripts from CDNs at runtime
+- Don't use `eval()` or `new Function()` with remote content
+- Fetching JSON data from APIs is fine; fetching and executing JS is not
+
+### 8. User Data Disclosure Mismatch
+
+**Symptom:** "Your extension's data usage does not match your disclosure."
+
+**Fix:**
+- Audit every `fetch()`, `XMLHttpRequest`, and `chrome.storage.sync` call
+- Remember that `chrome.storage.sync` transmits data to Google's servers
+- If you use any analytics library (even self-hosted), declare it
+- If you log errors to an external service, declare it
+
+## After Rejection
+
+When an extension is rejected:
+
+1. Read the rejection email carefully β€” it specifies which policy was violated
+2. Update CHROMEWEBSTORE.md with the rejection reason and fix
+3. Make the required changes to the extension code or listing
+4. Re-verify against the pre-publish checklist
+5. Resubmit through the developer dashboard
+6. Note: Repeated policy violations can result in account suspension
+
+## Review Timeline
+
+- First submission: typically 1–3 business days, can be longer
+- Updates to existing extensions: usually faster, often within 24 hours
+- Expedited review: not officially available; maintaining a clean track record helps
+- Deferred publishing: you can choose to publish manually after review passes,
+  giving you control over timing. Must publish within 30 days of approval.
diff --git a/.agents/skills/handoff/SKILL.md b/.agents/skills/handoff/SKILL.md
new file mode 100644
index 0000000..043d9e1
--- /dev/null
+++ b/.agents/skills/handoff/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: handoff
+description: Compact the current conversation into a handoff document for another agent to pick up.
+argument-hint: "What will the next session be used for?"
+disable-model-invocation: true
+---
+
+Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace.
+
+Include a "suggested skills" section in the document, which suggests skills that the agent should invoke.
+
+Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
+
+Redact any sensitive information, such as API keys, passwords, or personally identifiable information.
+
+If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.
diff --git a/.agents/skills/handoff/agents/openai.yaml b/.agents/skills/handoff/agents/openai.yaml
new file mode 100644
index 0000000..6e1d8da
--- /dev/null
+++ b/.agents/skills/handoff/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+  display_name: "Handoff"
+  short_description: "Compact a conversation into a handoff"
+policy:
+  allow_implicit_invocation: false
diff --git a/.agents/skills/strategic-compact/SKILL.md b/.agents/skills/strategic-compact/SKILL.md
new file mode 100644
index 0000000..46d800b
--- /dev/null
+++ b/.agents/skills/strategic-compact/SKILL.md
@@ -0,0 +1,142 @@
+---
+name: strategic-compact
+description: Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction.
+metadata:
+  origin: ECC
+---
+
+# Strategic Compact Skill
+
+Suggests manual `/compact` at strategic points in your workflow rather than relying on arbitrary auto-compaction.
+
+## When to Activate
+
+- Running long sessions that approach context limits (200K+ tokens)
+- Working on multi-phase tasks (research β†’ plan β†’ implement β†’ test)
+- Switching between unrelated tasks within the same session
+- After completing a major milestone and starting new work
+- When responses slow down or become less coherent (context pressure)
+
+## Why Strategic Compaction?
+
+Auto-compaction triggers at arbitrary points:
+- Often mid-task, losing important context
+- No awareness of logical task boundaries
+- Can interrupt complex multi-step operations
+
+Strategic compaction at logical boundaries:
+- **After exploration, before execution** β€” Compact research context, keep implementation plan
+- **After completing a milestone** β€” Fresh start for next phase
+- **Before major context shifts** β€” Clear exploration context before different task
+
+## How It Works
+
+The `suggest-compact.js` script runs on PreToolUse (Edit/Write) and combines two signals:
+
+1. **Context size (primary)** β€” Reads the latest `usage` record from the session transcript (`transcript_path` in the hook payload) and sums `input_tokens + cache_read_input_tokens + cache_creation_input_tokens` (the true context size of the turn). Suggests `/compact` at a window-scaled threshold β€” 160k tokens on a 200k window, 250k on a 1M window (detected from a `[1m]` model marker, or inferred when observed tokens already exceed 200k) β€” and re-reminds after every additional 60k tokens of context growth
+2. **Tool-call count (secondary)** β€” Counts tool invocations in session; suggests at a configurable threshold (default: 50 calls), then every 25 calls after
+
+Tool count alone is a weak proxy for window pressure: a few large file reads or MCP responses can fill the window in very few calls, while many tiny calls can cross 50 with a near-empty window. The context-size signal fires when it actually matters.
+
+## Hook Setup
+
+**Installed as a plugin?** No setup is needed. The plugin's `hooks/hooks.json` already registers `suggest-compact.js` (hook id `pre:edit-write:suggest-compact`, active in the `standard` and `strict` hook profiles). Do not copy the block below into `~/.claude/settings.json` β€” `~/.claude/scripts/` does not exist on plugin installs, and duplicating a plugin hook causes double execution.
+
+**If installed manually** (`./install.sh`), add to your `~/.claude/settings.json`:
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "Edit",
+        "hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
+      },
+      {
+        "matcher": "Write",
+        "hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
+      }
+    ]
+  }
+}
+```
+
+## Configuration
+
+Environment variables:
+- `COMPACT_THRESHOLD` β€” Tool calls before first suggestion (default: 50)
+- `COMPACT_CONTEXT_THRESHOLD` β€” Context tokens before the context-size suggestion (default: 160000 on a 200k window, 250000 on a 1M window; `0` disables the context signal)
+- `COMPACT_CONTEXT_INTERVAL` β€” Additional context tokens before the suggestion repeats (default: 60000)
+- `COMPACT_STATE_TTL_DAYS` β€” Days before stale per-session state files in the temp dir are swept (default: 14)
+- `ECC_CONTEXT_WINDOW_TOKENS` β€” Explicit context-window size, in tokens, overriding auto-detection. Set this for large-window models whose reported id lacks a `[1m]` marker (e.g. 400k Opus 4.x, or a new 1M-window model family) so the threshold scales to the real window instead of defaulting to 200k and overstating context usage.
+- `CLAUDE_CODE_AUTO_COMPACT_WINDOW` β€” Claude Code's native window-size override, in tokens; honored as a fallback when `ECC_CONTEXT_WINDOW_TOKENS` is unset.
+
+> The context window is otherwise auto-detected from a `[1m]` model marker or inferred when observed tokens already exceed 200k. On a large-window model that carries neither signal, set one of the overrides above so the `/compact` suggestion fires at the right point.
+
+## Compaction Decision Guide
+
+Use this table to decide when to compact:
+
+| Phase Transition | Compact? | Why |
+|-----------------|----------|-----|
+| Research β†’ Planning | Yes | Research context is bulky; plan is the distilled output |
+| Planning β†’ Implementation | Yes | Plan is in TodoWrite or a file; free up context for code |
+| Implementation β†’ Testing | Maybe | Keep if tests reference recent code; compact if switching focus |
+| Debugging β†’ Next feature | Yes | Debug traces pollute context for unrelated work |
+| Mid-implementation | No | Losing variable names, file paths, and partial state is costly |
+| After a failed approach | Yes | Clear the dead-end reasoning before trying a new approach |
+
+## What Survives Compaction
+
+Understanding what persists helps you compact with confidence:
+
+| Persists | Lost |
+|----------|------|
+| CLAUDE.md instructions | Intermediate reasoning and analysis |
+| TodoWrite task list | File contents you previously read |
+| Memory files (`~/.claude/memory/`) | Multi-step conversation context |
+| Git state (commits, branches) | Tool call history and counts |
+| Files on disk | Nuanced user preferences stated verbally |
+
+## Best Practices
+
+1. **Compact after planning** β€” Once plan is finalized in TodoWrite, compact to start fresh
+2. **Compact after debugging** β€” Clear error-resolution context before continuing
+3. **Don't compact mid-implementation** β€” Preserve context for related changes
+4. **Read the suggestion** β€” The hook tells you *when*, you decide *if*
+5. **Write before compacting** β€” Save important context to files or memory before compacting
+6. **Use `/compact` with a summary** β€” Add a custom message: `/compact Focus on implementing auth middleware next`
+
+## Token Optimization Patterns
+
+### Trigger-Table Lazy Loading
+Instead of loading full skill content at session start, use a trigger table that maps keywords to skill paths. Skills load only when triggered, reducing baseline context by 50%+:
+
+| Trigger | Skill | Load When |
+|---------|-------|-----------|
+| "test", "tdd", "coverage" | tdd-workflow | User mentions testing |
+| "security", "auth", "xss" | security-review | Security-related work |
+| "deploy", "ci/cd" | deployment-patterns | Deployment context |
+
+### Context Composition Awareness
+Monitor what's consuming your context window:
+- **CLAUDE.md files** β€” Always loaded, keep lean
+- **Loaded skills** β€” Each skill adds 1-5K tokens
+- **Conversation history** β€” Grows with each exchange
+- **Tool results** β€” File reads, search results add bulk
+
+### Duplicate Instruction Detection
+Common sources of duplicate context:
+- Same rules in both `~/.claude/rules/` and project `.claude/rules/`
+- Skills that repeat CLAUDE.md instructions
+- Multiple skills covering overlapping domains
+
+### Context Optimization Tools
+- `token-optimizer` MCP β€” Automated 95%+ token reduction via content deduplication
+- `context-mode` β€” Context virtualization (315KB to 5.4KB demonstrated)
+
+## Related
+
+- [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) β€” Token optimization section
+- Memory persistence hooks β€” For state that survives compaction
+- `continuous-learning` skill β€” Extracts patterns before session ends
diff --git a/.agents/skills/to-spec/SKILL.md b/.agents/skills/to-spec/SKILL.md
new file mode 100644
index 0000000..3fd6495
--- /dev/null
+++ b/.agents/skills/to-spec/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: to-spec
+description: Turn the current conversation into a spec and publish it to the project issue tracker β€” no interview, just synthesis of what you've already discussed.
+disable-model-invocation: true
+---
+
+This skill takes the current conversation context and codebase understanding and produces a spec. Do NOT interview the user β€” just synthesize what you already know.
+
+The issue tracker and triage label vocabulary should have been provided to you β€” run `/setup-matt-pocock-skills` if not.
+
+## Process
+
+1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the spec, and respect any ADRs in the area you're touching.
+
+2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can. The fewer seams across the codebase, the better - the ideal number is one.
+
+Check with the user that these seams match their expectations.
+
+3. Write the spec using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
+
+<spec-template>
+
+## Problem Statement
+
+The problem that the user is facing, from the user's perspective.
+
+## Solution
+
+The solution to the problem, from the user's perspective.
+
+## User Stories
+
+A LONG, numbered list of user stories. Each user story should be in the format of:
+
+1. As an <actor>, I want a <feature>, so that <benefit>
+
+<user-story-example>
+1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
+</user-story-example>
+
+This list of user stories should be extremely extensive and cover all aspects of the feature.
+
+## Implementation Decisions
+
+A list of implementation decisions that were made. This can include:
+
+- The modules that will be built/modified
+- The interfaces of those modules that will be modified
+- Technical clarifications from the developer
+- Architectural decisions
+- Schema changes
+- API contracts
+- Specific interactions
+
+Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
+
+Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts β€” not a working demo, just the important bits.
+
+## Testing Decisions
+
+A list of testing decisions that were made. Include:
+
+- A description of what makes a good test (only test external behavior, not implementation details)
+- Which modules will be tested
+- Prior art for the tests (i.e. similar types of tests in the codebase)
+
+## Out of Scope
+
+A description of the things that are out of scope for this spec.
+
+## Further Notes
+
+Any further notes about the feature.
+
+</spec-template>
diff --git a/.agents/skills/to-spec/agents/openai.yaml b/.agents/skills/to-spec/agents/openai.yaml
new file mode 100644
index 0000000..549e6f7
--- /dev/null
+++ b/.agents/skills/to-spec/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+  display_name: "To Spec"
+  short_description: "Turn a conversation into a spec"
+policy:
+  allow_implicit_invocation: false
diff --git a/.env.example b/.env.example
index 1f3beee..6da41ed 100644
--- a/.env.example
+++ b/.env.example
@@ -3,9 +3,11 @@ PORT=8787
 
 # custom OPENAI compatible API provider 
 
+# If you use the OpenRouter URL below, OPENAI_API_KEY must be an OpenRouter
+# key from https://openrouter.ai/keys (normally starts with sk-or-).
 OPENAI_BASE_URL=https://openrouter.ai/api/v1
 OPENAI_API_KEY=
-OPENAI_MODEL=meta-llama/llama-3.3-70b-instruct:free
+OPENAI_MODEL=google/gemma-4-26b-a4b-it:free
 
 # server API fallback
 SARVAM_API_KEY=
diff --git a/.gitignore b/.gitignore
index 405ea9b..1d12f38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,17 @@
 node_modules/
 .env
-.claude
\ No newline at end of file
+.claude
+.DS_Store
+
+# Generated skill mirrors for other agent tools. The canonical skills and
+# provenance notes live in .agents/skills/; these per-tool copies are
+# machine-generated and should not be versioned (they inflate the diff without
+# adding reviewable source).
+.bob/
+.bolt/
+.cline/
+.cursor/
+.kilo/
+.roo/
+.github/skills/
+output/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..8c6839a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,177 @@
+# AGENTS
+
+Last updated: 2026-08-11
+
+This is the canonical instruction file for agents working in this repository.
+Prefer updating this file over creating duplicate tool-specific instruction
+files.
+
+## Output Mode Prefix
+
+Begin every assistant text response with exactly one of these mode prefixes on
+its own line, matching the current activity:
+
+- `planning> ` β€” planning, exploring, designing, or proposing approaches before
+  changes.
+- `act> ` β€” executing or implementing: editing files, running commands, making
+  changes.
+- `review> ` β€” reviewing, summarizing results, or reporting completed work.
+- `error> ` β€” reporting a failure, blocker, or problem encountered.
+
+Use exactly one prefix per response. If the mode changes mid-response, switch by
+starting a new line with the new prefix.
+
+## Project
+
+Continue it is a Manifest V3 Chrome extension for exporting long AI chat
+conversations from Claude, ChatGPT, Gemini, Grok, and Perplexity into portable
+handoffs. It also includes an optional local/shared Node backend for Server AI.
+
+Primary files:
+
+- `manifest.json` β€” MV3 permissions, host permissions, content script order.
+- `background.js` β€” extension service worker; AI summary routing and Chrome
+  built-in AI calls.
+- `content-site.js` β€” injected page capture, scroll/load workflow, modal launch.
+- `provider-config.js` β€” supported AI site selectors and role hints.
+- `shared-handoff.js` β€” handoff schema, local summarizer, chunking, storage.
+- `shared-ai.js` β€” AI modes, provider settings, permissions, background calls.
+- `shared-ui.js` β€” reusable extension UI helpers.
+- `popup.html`, `popup.css`, `popup.js` β€” extension popup controls.
+- `server/server.js` β€” optional Express backend for Server AI.
+- `docs/Plans/` β€” repo-local plans and specs.
+
+## Operating Style
+
+Use these rules from the Karpathy-style guidance:
+
+- Think before editing. State assumptions when they matter.
+- Prefer the smallest change that solves the requested problem.
+- Touch only files that are directly in scope.
+- Match the existing plain JavaScript style. Do not add a build system unless
+  the task explicitly calls for one.
+- Do not refactor adjacent code, comments, formatting, or dead code just because
+  you noticed it.
+- If the request is ambiguous and the wrong interpretation would be costly, ask
+  before changing files.
+- Define success criteria for nontrivial work and verify against them.
+
+Every changed line should trace back to the user request, the active spec, or a
+bug found while implementing that request.
+
+## Planning
+
+For nontrivial features, write or update a plan/spec before broad edits.
+
+- Use `docs/Plans/` for repo-local plans, specs, and cross-session design work.
+- Keep plans concrete: context, files, steps, verification, and known risks.
+- Prefer markdown task lists for executable plans.
+- Update the plan as implementation discovers important new facts.
+- Do not bury project handoffs or large design decisions in chat only.
+
+The current session knowledge export direction is captured in
+`docs/Plans/session-knowledge-repo-spec.md`.
+
+## Skills And References
+
+Before changing Chrome extension APIs, Chrome built-in AI, or Web Store-facing
+behavior, read the relevant local skills if present:
+
+- `.agents/skills/chrome-extensions/SKILL.md`
+- `.agents/skills/chrome-ai/SKILL.md`
+- `.agents/skills/built-in-ai/SKILL.md`
+- `.agents/skills/to-spec/SKILL.md` when turning discussion into specs
+
+Use these skills as project context, not as permission to make broad unrelated
+changes.
+
+## Architecture Rules
+
+- Keep content scripts focused on DOM capture, page interaction, and UI
+  injection.
+- Keep cross-origin provider calls and Chrome built-in AI calls in
+  `background.js` or another extension context with the right permissions.
+- Preserve content script load order from `manifest.json`:
+  `provider-config.js`, `shared-handoff.js`, `shared-ai.js`, `shared-ui.js`,
+  `content-site.js`.
+- Treat `shared-handoff.js` as the handoff data contract. Schema changes need
+  explicit compatibility handling.
+- Treat `shared-ai.js` as the unified client boundary for AI modes and settings.
+- Keep the extension usable in No AI mode. AI failures must fall back to local
+  summaries with clear warnings.
+- Do not put provider API keys or shared secrets in extension source.
+- `.env` is local secret configuration for the optional server and must stay
+  untracked.
+
+## Chrome Built-In AI
+
+- Use current Chrome built-in AI APIs, not deprecated examples. Prefer
+  `LanguageModel`, `LanguageModel.availability()`, `LanguageModel.params()`,
+  `LanguageModel.create()`, `prompt()`, `promptStreaming()`,
+  `measureContextUsage()`, and `destroy()` where available.
+- Record availability, context overflow, and fallback warnings in generated
+  handoffs.
+- Chrome built-in AI is a bounded local worker. Use it for summarization,
+  classification, extraction, compression, and handoff drafting. Do not rely on
+  it for irreversible publishing or unreviewed GitHub writes.
+- Destroy sessions after use to avoid leaking memory in long-running extension
+  sessions.
+
+## Server AI And BYOK
+
+- Server AI means the configured backend owns provider credentials. Users only
+  avoid keys when they point at a backend someone else operates.
+- BYOK keys live in extension local storage and are sent only to the configured
+  provider endpoint.
+- Do not bundle the server or shared provider keys into the extension package.
+- If changing `server/server.js`, preserve `/health`,
+  `POST /api/summarize`, quota behavior, and the quiet 204 handler for
+  `/.well-known/appspecific/com.chrome.devtools.json`.
+
+## Export And Knowledge-Repo Direction
+
+The planned next architecture is session-first and wiki-friendly:
+
+- Each exported session gets its own folder.
+- Raw transcripts are immutable and duplicated into a raw source layer.
+- Session summaries, immediate handoffs, produced documents, and references are
+  separate artifacts.
+- Manifests should include hashes, token counts, capture diagnostics, backend
+  used, warnings, and synthesis status.
+- Rolling synthesis should operate from saved files, not from model memory.
+- Weak/local AI work should be guided by explicit plans, persisted outputs,
+  verification gates, and resumable handoff files.
+
+## Verification
+
+There is no full automated extension test suite yet. Use the strongest practical
+checks for the files you touch:
+
+- For JavaScript changes, run syntax checks where possible, for example
+  `node --check background.js` or the touched server/content file.
+- For server changes, run or smoke-test `npm start` with a safe local `.env`
+  when practical, then check `/health`.
+- For extension behavior, reload the unpacked extension in Chrome and manually
+  test the affected provider flow when browser validation is required.
+- For docs-only changes, verify links/paths and read the rendered markdown shape.
+
+If you cannot run the relevant check, say so in the final response.
+
+## Git And Safety
+
+- The worktree may contain user changes. Do not revert, delete, or reformat
+  files you did not intentionally touch.
+- Check `git status` before committing or summarizing work.
+- Keep commits scoped to the confirmed task.
+- Do not commit unless the user explicitly asks for a commit.
+- Do not remove untracked agent/tool directories unless the user explicitly asks.
+
+## Avoid
+
+- Broad rewrites of the extension architecture without a plan.
+- New dependencies for the extension path unless the benefit is concrete.
+- Hidden network calls in No AI or Chrome built-in AI mode.
+- Storing secrets in exported artifacts, docs, screenshots, or extension files.
+- Polished summaries that hide incomplete transcript capture.
+- Treating browser examples that use deprecated `window.ai` APIs as copy-paste
+  implementation targets.
diff --git a/README.md b/README.md
index 2bd2cb5..e0d744e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
 When you're deep in a conversation with Claude, ChatGPT, Gemini, Grok, or Perplexity and want to switch platforms (or start a fresh session while keeping context), Continue it:
 
 1. **Scrolls through and captures** your entire conversation history from the page DOM
-2. **Generates a summary** of the thread β€” task, requirements, decisions, blockers, files, timeline. Choose one of three modes: **local (no AI)**, **shared Server AI** (5 free/day), or **your own API key** (unlimited) β€” see [AI summaries](#ai-summaries-three-modes)
+2. **Generates a summary** of the thread β€” task, requirements, decisions, blockers, files, timeline. Choose one of four modes: **local (no AI)**, **Chrome built-in AI**, **Server AI**, or **your own API key** β€” see [AI summaries](#ai-summaries-four-modes)
 3. **Packages the context** into a prompt (or a sequence of chunks for long conversations) ready to paste into any target LLM
 4. **Tracks chunk progress** in the popup so you can send large transcripts in staged batches
 
@@ -68,6 +68,17 @@ Single prompt with header + summary + chunk digest + import instructions. Paste
 - Transcript divided into ~12 KB chunks sent one at a time
 - The popup's **Copy next chunk** button advances the cursor automatically
 
+### Export progress indicator
+Exports can take a while β€” the scroll-scan plus a summarization round trip β€” so the page shows live progress instead of appearing frozen:
+- A progress light traces the browser window edge, filling clockwise as the export advances
+- The page dims to grey (non-blocking: the scrim ignores clicks, so the page stays usable)
+- A status chip names the current phase (scanning, building handoff, contacting the model, preparing review), counts messages found, animates ellipses, and shows elapsed seconds once past two seconds
+- The floating **Continue It** button doubles as a progress bar and reads `Exporting...`
+- The whole indicator turns green on success and red with the failure reason on error
+- Phases with an unknowable duration (an in-flight API call) creep toward their end so the bar never looks parked, and after 12 seconds the chip adds a "still waiting on the model" note
+- Only one export runs per tab at a time; a second click says so instead of starting a duplicate
+- Honors `prefers-reduced-motion` by dropping the spinner and pulse animations
+
 ### Popup dashboard
 - Stats: source platform, captured-at timestamp, message counts, chunk progress
 - Summary mode selector (persisted to storage)
@@ -82,34 +93,43 @@ Single prompt with header + summary + chunk digest + import instructions. Paste
 | `continueIt.summaryMode` | Preferred verbosity level |
 | `continueIt.chunkCursor` | Per-handoff chunk index for staged imports |
 | `continueIt.handoffHistory` | Last 10 exports (metadata only) |
-| `continueIt.ai.mode` | AI summary mode: `none` / `server` / `byok` |
+| `continueIt.ai.mode` | AI summary mode: `none` / `builtin` / `server` / `byok` |
 | `continueIt.ai.serverUrl` | Shared backend URL (Server AI mode) |
 | `continueIt.ai.byokBaseUrl` / `byokModel` / `byokApiKey` / `byokProvider` | Your own provider config |
 | `continueIt.clientId` | Anonymous id used for the Server AI daily quota |
 
-Handoff data lives in `chrome.storage.local`. In **No AI** mode nothing ever leaves the browser. In **Server AI** / **Your own key** modes, a compacted summary of the conversation is sent to the endpoint you choose (see below).
+Handoff data lives in `chrome.storage.local`. In **No AI** mode nothing ever leaves the browser. In **Chrome built-in AI** mode, the compacted conversation is sent only to Chrome's on-device model when the API is available. In **Server AI** / **Your own key** modes, a compacted summary of the conversation is sent to the endpoint you choose (see below).
 
 ---
 
-## AI summaries (three modes)
+## AI summaries (four modes)
 
-Pick a mode in the popup under **AI summary mode**. All three produce the same portable handoff; they differ only in how the summary is written.
+Pick a mode in the popup under **AI summary mode**. All four produce the same portable handoff; they differ only in how the summary is written.
 
 | Mode | Quality | Cost | Privacy | Setup |
 |---|---|---|---|---|
 | **No AI** (default) | Good (local heuristic) | Free | Fully local, offline | None |
-| **Server AI** | Better (real LLM) | Free, **5 exports / 24h** | Summary sent to the shared backend | None for the user |
+| **Chrome built-in AI** | Better (Gemini Nano) | Free | On-device when available | Chrome 138+ on supported desktop hardware |
+| **Server AI** | Better (real LLM) | Depends on configured backend | Summary sent to the configured backend | A hosted backend, or your own local backend with a `.env` key |
 | **Your own API key** | Best (any model you like) | Free on the providers below | Summary sent to your chosen provider | Paste a key |
 
 If an AI request ever fails (rate limit, bad key, network), the extension automatically falls back to the local summary and adds a warning β€” an export never breaks.
 
+### Chrome built-in AI β€” no key, no backend
+
+Chrome built-in AI uses Chrome's `LanguageModel` API from the extension background service worker. It requires a supported desktop Chrome installation and the on-device model may need to download the first time it is used. No provider key is stored and no backend server is contacted.
+
+Continue it uses the Prompt API instead of Chrome's task-specific `Summarizer` API for this mode because handoffs need comprehensive context transfer, not a short TLDR, headline, or limited bullet list. Chrome's `Summarizer` API is also not available in Web Workers right now, so it does not fit the existing MV3 background-service-worker request flow.
+
+If Chrome reports that built-in AI is unavailable, or if the conversation is too large for the on-device model context window, Continue it falls back to the local heuristic summary and records a warning in the handoff.
+
 ### Your own API key β€” free OpenAI-compatible providers
 
 Any OpenAI-compatible endpoint works. Create a key (most need no credit card), pick it in the popup, paste the key, and click **Save AI settings** (this also grants the extension permission to reach that host). Use **Test connection** to verify.
 
 | Provider | Base URL | Example free model | Get a key |
 |---|---|---|---|
-| **OpenRouter** | `https://openrouter.ai/api/v1` | `meta-llama/llama-3.3-70b-instruct:free` | [openrouter.ai/keys](https://openrouter.ai/keys) |
+| **OpenRouter** | `https://openrouter.ai/api/v1` | `google/gemma-4-26b-a4b-it:free` | [openrouter.ai/keys](https://openrouter.ai/keys) |
 | **Google AI Studio** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
 | **Groq** (fastest) | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | [console.groq.com/keys](https://console.groq.com/keys) |
 | **Cerebras** (highest volume) | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | [cloud.cerebras.ai](https://cloud.cerebras.ai/) |
@@ -118,9 +138,13 @@ Any OpenAI-compatible endpoint works. Create a key (most need no credit card), p
 
 Free-tier limits change often β€” verify the current numbers on each provider's site. Keys are stored only in `chrome.storage.local` on your machine and are sent only to the provider you configured.
 
-### Server AI β€” running the shared backend (for maintainers)
+### Server AI β€” running or using a backend
+
+Server AI is not bundled into the Chrome extension. The extension sends summary requests to a separate backend URL, and that backend uses a provider key from its own environment.
+
+Users do not need to enter an API key only when they are pointed at a backend already operated by someone else. If you run the backend locally, you must provide your own key in `.env`. Do not put a shared provider key in the extension files; extension source is inspectable by users.
 
-The Server AI mode lets you offer smart summaries to users without them needing a key, capped at **5 exports per 24h per user** (by anonymous client id + IP) so it stays cheap.
+The included backend caps requests at **5 exports per 24h per user** (by anonymous client id + IP) so a maintainer-operated deployment can control cost.
 
 ```bash
 cd server            # from the repo root
@@ -132,14 +156,16 @@ npm start
 Configure `.env` with any **free** OpenAI-compatible provider so it costs you nothing:
 
 ```bash
+# OpenRouter example: use an OpenRouter key from https://openrouter.ai/keys.
+# If you use a different provider key, change both OPENAI_BASE_URL and model.
 OPENAI_BASE_URL=https://openrouter.ai/api/v1
 OPENAI_API_KEY=sk-or-...            # your key, never exposed to users
-OPENAI_MODEL=meta-llama/llama-3.3-70b-instruct:free
+OPENAI_MODEL=google/gemma-4-26b-a4b-it:free
 DAILY_LIMIT=5                       # exports per window
 RATE_WINDOW_HOURS=24
 ```
 
-Then set the **Server URL** in the popup (default `http://localhost:8787`) and choose **Server AI**. Deploy the server anywhere (Render, Railway, Fly, a VPS) and point the popup at that URL.
+Then set the **Server URL** in the popup (default `http://localhost:8787`) and choose **Server AI**. To offer Server AI to other users without asking them for provider keys, deploy the server anywhere (Render, Railway, Fly, a VPS), configure provider secrets in that hosting environment, and tell users to point the popup at that URL.
 
 > The in-memory rate limiter is per-instance and resets on restart. For a hardened multi-instance deployment, back it with Redis or a database.
 
@@ -166,7 +192,7 @@ Supports any Chromium-based browser that handles Manifest V3: Chrome, Edge, Brav
 
 1. Open any supported AI chat page with an active conversation
 2. Click the **Export Context** button that appears on the right side of the page
-3. The extension scrolls through and captures the full thread
+3. The extension scrolls through and captures the full thread, then summarizes it β€” a progress light around the window, a status chip with elapsed time, and a progress fill on the button track the whole run
 4. A modal opens showing:
    - Conversation stats (messages, roles, estimated tokens, chunks)
    - Editable summary (switch mode to regenerate)
@@ -202,11 +228,11 @@ Supports any Chromium-based browser that handles Manifest V3: Chrome, Edge, Brav
 ```
 Continue it/
 β”œβ”€β”€ manifest.json          # Extension config (MV3), permissions, host permissions
-β”œβ”€β”€ background.js          # Service worker β€” performs AI summarize requests (Server AI + BYO)
+β”œβ”€β”€ background.js          # Service worker β€” performs AI summarize requests (Chrome built-in, Server AI + BYO)
 β”œβ”€β”€ provider-config.js     # Provider registry (selectors, role hints per platform)
 β”œβ”€β”€ shared-handoff.js      # Core data model, local summarizer, chunker, storage API
 β”œβ”€β”€ shared-ai.js           # Unified AI client β€” modes, provider presets, settings, permissions
-β”œβ”€β”€ shared-ui.js           # Toast, modal, and launcher button components
+β”œβ”€β”€ shared-ui.js           # Toast, modal, launcher button, and progress indicator components
 β”œβ”€β”€ content-site.js        # Generic content script injected on all supported sites
 β”œβ”€β”€ popup.html/.css/.js    # Popup UI (stats, AI mode selector, handoff controls)
 └── server/
@@ -229,6 +255,7 @@ host permission and makes the cross-origin request:
 ```js
 // content-site.js β†’ shared-ai.js β†’ background.js
 chrome.runtime.sendMessage({ type: "continueIt.summarize", payload }, cb)
+// β†’ Chrome built-in AI: LanguageModel.create(...).promptStreaming(...)
 // β†’ Server AI: POST <serverUrl>/api/summarize   (with x-continue-it-client header)
 // β†’ Your key:  POST <baseUrl>/chat/completions  (Authorization: Bearer <key>)
 
@@ -299,7 +326,7 @@ To test on a platform, navigate to a chat page with an active conversation and t
 | Host permissions (8 domains) | Inject content scripts on supported AI platforms |
 | `optional_host_permissions` | Requested only when you enable Server AI or your own API key β€” grants access to that one endpoint |
 
-In **No AI** mode, no network requests are made and no data leaves your browser. In **Server AI** or **Your own API key** mode, a compacted summary is sent only to the endpoint you configured, and the extension asks for permission to reach that host at the moment you save the setting.
+In **No AI** mode, no network requests are made and no data leaves your browser. In **Chrome built-in AI** mode, the compacted summary input is processed by Chrome's on-device model when available. In **Server AI** or **Your own API key** mode, a compacted summary is sent only to the endpoint you configured, and the extension asks for permission to reach that host at the moment you save the setting.
 
 ---
 
@@ -317,4 +344,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
 
 ## Version
 
-**0.7.0** β€” Manifest V3, schema v2. Adds three AI summary modes (local / Server AI with 5-per-day quota / bring-your-own OpenAI-compatible key) and an optional rate-limited backend.
+**0.7.0** β€” Manifest V3, schema v2. Adds four AI summary modes (local / Chrome built-in AI / Server AI / bring-your-own OpenAI-compatible key) and an optional rate-limited backend.
diff --git a/assets/icon-128.png b/assets/icon-128.png
new file mode 100644
index 0000000..6b6b8ef
Binary files /dev/null and b/assets/icon-128.png differ
diff --git a/assets/icon-16.png b/assets/icon-16.png
new file mode 100644
index 0000000..099eb10
Binary files /dev/null and b/assets/icon-16.png differ
diff --git a/assets/icon-32.png b/assets/icon-32.png
new file mode 100644
index 0000000..2d44f8f
Binary files /dev/null and b/assets/icon-32.png differ
diff --git a/assets/icon-48.png b/assets/icon-48.png
new file mode 100644
index 0000000..d5bbc19
Binary files /dev/null and b/assets/icon-48.png differ
diff --git a/background.js b/background.js
index 03830e7..9466aa7 100644
--- a/background.js
+++ b/background.js
@@ -10,9 +10,21 @@ const AI_STORAGE_KEYS = {
   serverUrl: "continueIt.ai.serverUrl",
   byokBaseUrl: "continueIt.ai.byokBaseUrl",
   byokModel: "continueIt.ai.byokModel",
-  byokApiKey: "continueIt.ai.byokApiKey"
+  byokApiKey: "continueIt.ai.byokApiKey",
+  chromeBuiltInStatus: "continueIt.ai.builtinStatus"
 };
 const DEFAULT_SERVER_URL = "http://localhost:8787";
+const CHROME_BUILTIN_MODE = "builtin";
+const CHROME_BUILTIN_PREWARM_TTL_MS = 2 * 60 * 1000;
+const SUMMARY_SYSTEM_PROMPT =
+  "You are a context-transfer agent. Your job is to write a comprehensive handoff document that captures the COMPLETE context of a conversation so a different AI can continue it seamlessly β€” with zero information loss. Do NOT produce a brief summary. Write as much as needed to preserve all meaningful context. Use plain text only (no markdown headers, no code fences). Write in clear, complete sentences. Preserve specifics β€” exact names, exact values, exact error messages, exact file names β€” never replace them with vague references. A reader must be able to pick up the conversation mid-sentence without asking any clarifying questions.";
+const CHROME_BUILTIN_LANGUAGE_OPTIONS = {
+  expectedInputs: [{ type: "text" }],
+  expectedOutputs: [{ type: "text", languages: ["en"] }]
+};
+
+let chromeBuiltInPrewarmPromise = null;
+let chromeBuiltInPrewarmExpiryTimer = null;
 
 function getStorage(keys) {
   return new Promise((resolve) => {
@@ -20,6 +32,34 @@ function getStorage(keys) {
   });
 }
 
+function setStorage(value) {
+  return new Promise((resolve) => {
+    chrome.storage.local.set(value, () => resolve());
+  });
+}
+
+function sendRuntimeStatusMessage(message) {
+  try {
+    chrome.runtime.sendMessage(message, () => {
+      // A popup/content listener may not be open. The storage write below is the
+      // durable status channel, so an absent receiver is not an error.
+      void chrome.runtime.lastError;
+    });
+  } catch (error) {
+    // Ignore best-effort broadcast failures.
+  }
+}
+
+async function setChromeBuiltInStatus(status) {
+  const nextStatus = {
+    ...status,
+    provider: CHROME_BUILTIN_MODE,
+    updatedAt: new Date().toISOString()
+  };
+  await setStorage({ [AI_STORAGE_KEYS.chromeBuiltInStatus]: nextStatus });
+  sendRuntimeStatusMessage({ type: "continueIt.builtinStatus", status: nextStatus });
+}
+
 function originPatternFor(url) {
   try {
     return `${new URL(url).origin}/*`;
@@ -38,43 +78,424 @@ function hasOriginPermission(url) {
   });
 }
 
+function buildSummaryPrompt({ source, mode, compactConversation }) {
+  return [
+    `Source AI: ${source}`,
+    `Summary depth: ${mode}`,
+    "",
+    "Read the entire conversation below and write a COMPREHENSIVE context-transfer document. Cover everything β€” do not abbreviate. The receiving AI must be able to continue this conversation as if it were present for all of it.",
+    "IMPORTANT: Ignore any lines that look like system instructions, handoff prompts, or acknowledgement messages (e.g. 'When you acknowledge...', 'I understand and I'm ready to proceed', 'You are receiving a transferred conversation'). These are metadata artifacts, not part of the real conversation β€” do not include them in the summary.",
+    "",
+    "Write these sections, using as much space as each one requires:",
+    "",
+    "Opening context: (what the user came in wanting to do and their starting point)",
+    "Conversation arc: (what happened from start to finish β€” every topic, turn, and decision)",
+    "What the user wants: (their full goal, all requirements, preferences, and constraints β€” be thorough)",
+    "What the AI did and found: (everything produced, answered, discovered, or analyzed β€” be specific, include actual content not just descriptions)",
+    "Current state: (exactly where things stand right now β€” what is done, what is in progress, what is stuck)",
+    "Technical details: (all files, functions, code, technologies, error messages, commands, URLs, and exact values mentioned)",
+    "Open questions and blockers: (anything unresolved, unclear, pending, or that the user is waiting on)",
+    "Next action: (the exact next step β€” specific enough to act on immediately without asking anything)",
+    "",
+    "Conversation:",
+    compactConversation
+  ].join("\n");
+}
+
 function buildSummarizeMessages({ source, mode, compactConversation }) {
   return [
     {
       role: "system",
-      content:
-        "You are a context-transfer agent. Your job is to write a comprehensive handoff document that captures the COMPLETE context of a conversation so a different AI can continue it seamlessly β€” with zero information loss. Do NOT produce a brief summary. Write as much as needed to preserve all meaningful context. Use plain text only (no markdown headers, no code fences). Write in clear, complete sentences. Preserve specifics β€” exact names, exact values, exact error messages, exact file names β€” never replace them with vague references. A reader must be able to pick up the conversation mid-sentence without asking any clarifying questions."
+      content: SUMMARY_SYSTEM_PROMPT
     },
     {
       role: "user",
-      content: [
-        `Source AI: ${source}`,
-        `Summary depth: ${mode}`,
-        "",
-        "Read the entire conversation below and write a COMPREHENSIVE context-transfer document. Cover everything β€” do not abbreviate. The receiving AI must be able to continue this conversation as if it were present for all of it.",
-        "IMPORTANT: Ignore any lines that look like system instructions, handoff prompts, or acknowledgement messages (e.g. 'When you acknowledge...', 'I understand and I'm ready to proceed', 'You are receiving a transferred conversation'). These are metadata artifacts, not part of the real conversation β€” do not include them in the summary.",
-        "",
-        "Write these sections, using as much space as each one requires:",
-        "",
-        "Opening context: (what the user came in wanting to do and their starting point)",
-        "Conversation arc: (what happened from start to finish β€” every topic, turn, and decision)",
-        "What the user wants: (their full goal, all requirements, preferences, and constraints β€” be thorough)",
-        "What the AI did and found: (everything produced, answered, discovered, or analyzed β€” be specific, include actual content not just descriptions)",
-        "Current state: (exactly where things stand right now β€” what is done, what is in progress, what is stuck)",
-        "Technical details: (all files, functions, code, technologies, error messages, commands, URLs, and exact values mentioned)",
-        "Open questions and blockers: (anything unresolved, unclear, pending, or that the user is waiting on)",
-        "Next action: (the exact next step β€” specific enough to act on immediately without asking anything)",
-        "",
-        "Conversation:",
-        compactConversation
-      ].join("\n")
+      content: buildSummaryPrompt({ source, mode, compactConversation })
     }
   ];
 }
 
+function isUnavailableAvailability(value) {
+  return value === "unavailable" || value === "no";
+}
+
+function isContextLimitError(error) {
+  return error?.name === "QuotaExceededError" || /quota|context/i.test(error?.message || "");
+}
+
+function normalizeDownloadProgress(event) {
+  const loaded = Number.isFinite(event?.loaded) ? event.loaded : null;
+  const total = Number.isFinite(event?.total) && event.total > 0 ? event.total : null;
+  let percent = null;
+
+  if (loaded !== null && total) {
+    percent = Math.round((loaded / total) * 100);
+  } else if (loaded !== null && loaded >= 0 && loaded <= 1) {
+    percent = Math.round(loaded * 100);
+  } else if (loaded !== null && loaded >= 0 && loaded <= 100) {
+    percent = Math.round(loaded);
+  }
+
+  return {
+    loaded,
+    total,
+    percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null
+  };
+}
+
+async function chromeBuiltInCoreOptions() {
+  const coreOptions = { ...CHROME_BUILTIN_LANGUAGE_OPTIONS };
+  if (typeof LanguageModel.params !== "function") {
+    return coreOptions;
+  }
+
+  try {
+    const params = await LanguageModel.params();
+    const defaultTemperature = Number.isFinite(params?.defaultTemperature) ? params.defaultTemperature : 1;
+    const maxTemperature = Number.isFinite(params?.maxTemperature) ? params.maxTemperature : defaultTemperature;
+    const topK = Number.isFinite(params?.defaultTopK) ? params.defaultTopK : undefined;
+    const temperature = Math.min(defaultTemperature, maxTemperature, 0.7);
+
+    if (Number.isFinite(topK) && Number.isFinite(temperature)) {
+      return { ...coreOptions, topK, temperature };
+    }
+  } catch (error) {
+    console.warn("[Continue It] Could not read Chrome built-in AI model params.", error);
+  }
+
+  return coreOptions;
+}
+
+function chromeBuiltInCreateOptions(coreOptions) {
+  return {
+    ...coreOptions,
+    initialPrompts: [{ role: "system", content: SUMMARY_SYSTEM_PROMPT }],
+    monitor(monitor) {
+      monitor.addEventListener("downloadprogress", (event) => {
+        const progress = normalizeDownloadProgress(event);
+        const percentText = progress.percent === null ? "in progress" : `${progress.percent}%`;
+        console.log(`[Continue It] Chrome built-in AI model download: ${percentText}`);
+        setChromeBuiltInStatus({
+          state: "downloading",
+          loaded: progress.loaded,
+          total: progress.total,
+          percent: progress.percent
+        });
+      });
+    }
+  };
+}
+
+async function createChromeBuiltInSession() {
+  if (!globalThis.LanguageModel) {
+    await setChromeBuiltInStatus({
+      state: "error",
+      availability: "unavailable",
+      error: "Chrome built-in AI is not available in this browser."
+    });
+    return {
+      ok: false,
+      session: null,
+      availability: "unavailable",
+      error: "Chrome built-in AI is not available in this browser. Use Chrome 138+ on a supported desktop device, or choose Server AI / Custom API key."
+    };
+  }
+
+  let availability = "unknown";
+  let coreOptions = CHROME_BUILTIN_LANGUAGE_OPTIONS;
+  try {
+    await setChromeBuiltInStatus({ state: "checking", availability });
+    coreOptions = await chromeBuiltInCoreOptions();
+    availability = await LanguageModel.availability(coreOptions);
+  } catch (error) {
+    await setChromeBuiltInStatus({
+      state: "error",
+      availability,
+      error: `Chrome built-in AI availability check failed: ${error?.message || String(error)}`
+    });
+    return {
+      ok: false,
+      session: null,
+      availability,
+      error: `Chrome built-in AI availability check failed: ${error?.message || String(error)}`
+    };
+  }
+
+  if (isUnavailableAvailability(availability)) {
+    await setChromeBuiltInStatus({
+      state: "error",
+      availability,
+      error: "Chrome built-in AI is not available on this device."
+    });
+    return {
+      ok: false,
+      session: null,
+      availability,
+      error: "Chrome built-in AI is not available on this device. Use Server AI or Custom API key instead."
+    };
+  }
+
+  try {
+    await setChromeBuiltInStatus({ state: "preparing", availability });
+    const session = await LanguageModel.create(chromeBuiltInCreateOptions(coreOptions));
+    await setChromeBuiltInStatus({ state: "ready", availability });
+    return {
+      ok: true,
+      session,
+      availability,
+      error: null
+    };
+  } catch (error) {
+    await setChromeBuiltInStatus({
+      state: "error",
+      availability,
+      error: `Chrome built-in AI failed: ${error?.message || String(error)}`
+    });
+    return {
+      ok: false,
+      session: null,
+      availability,
+      error: `Chrome built-in AI failed: ${error?.message || String(error)}`
+    };
+  }
+}
+
+function clearChromeBuiltInPrewarmExpiry() {
+  if (chromeBuiltInPrewarmExpiryTimer) {
+    clearTimeout(chromeBuiltInPrewarmExpiryTimer);
+    chromeBuiltInPrewarmExpiryTimer = null;
+  }
+}
+
+function destroyChromeBuiltInSessionResult(result) {
+  try {
+    result?.session?.destroy?.();
+  } catch (error) {
+    console.warn("[Continue It] Could not destroy expired Chrome built-in AI session.", error);
+  }
+}
+
+function scheduleChromeBuiltInPrewarmExpiry(resultPromise) {
+  clearChromeBuiltInPrewarmExpiry();
+  chromeBuiltInPrewarmExpiryTimer = setTimeout(() => {
+    if (chromeBuiltInPrewarmPromise !== resultPromise) {
+      return;
+    }
+    chromeBuiltInPrewarmPromise = null;
+    chromeBuiltInPrewarmExpiryTimer = null;
+    resultPromise
+      .then(async (result) => {
+        if (result?.ok && result.session) {
+          destroyChromeBuiltInSessionResult(result);
+          await setChromeBuiltInStatus({
+            state: "expired",
+            availability: result.availability,
+            error: "Chrome built-in AI prewarm expired before it was used."
+          });
+        }
+      })
+      .catch(() => {});
+  }, CHROME_BUILTIN_PREWARM_TTL_MS);
+}
+
+async function prewarmChromeBuiltIn() {
+  if (!chromeBuiltInPrewarmPromise) {
+    chromeBuiltInPrewarmPromise = createChromeBuiltInSession();
+    scheduleChromeBuiltInPrewarmExpiry(chromeBuiltInPrewarmPromise);
+  }
+
+  const resultPromise = chromeBuiltInPrewarmPromise;
+  const result = await resultPromise;
+  if (!result.ok) {
+    if (chromeBuiltInPrewarmPromise === resultPromise) {
+      chromeBuiltInPrewarmPromise = null;
+      clearChromeBuiltInPrewarmExpiry();
+    }
+  }
+
+  return {
+    ok: result.ok,
+    availability: result.availability,
+    error: result.error
+  };
+}
+
+async function takeChromeBuiltInSession() {
+  if (chromeBuiltInPrewarmPromise) {
+    const resultPromise = chromeBuiltInPrewarmPromise;
+    chromeBuiltInPrewarmPromise = null;
+    clearChromeBuiltInPrewarmExpiry();
+    const result = await resultPromise;
+    return result;
+  }
+  return createChromeBuiltInSession();
+}
+
+function finitePositiveNumber(value) {
+  return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+function normalizeContextUsage(measured) {
+  if (typeof measured === "number") {
+    return finitePositiveNumber(measured);
+  }
+  if (!measured || typeof measured !== "object") {
+    return null;
+  }
+  return finitePositiveNumber(measured.inputUsage)
+    || finitePositiveNumber(measured.usage)
+    || finitePositiveNumber(measured.tokens)
+    || finitePositiveNumber(measured.total);
+}
+
+async function measurePromptContextUsage(session, prompt) {
+  if (session && typeof session.measureContextUsage === "function") {
+    return normalizeContextUsage(await session.measureContextUsage(prompt));
+  }
+  if (session && typeof session.measureInputUsage === "function") {
+    return normalizeContextUsage(await session.measureInputUsage(prompt));
+  }
+  if (session && typeof session.countPromptTokens === "function") {
+    return normalizeContextUsage(await session.countPromptTokens(prompt));
+  }
+  return null;
+}
+
+function getAvailablePromptQuota(session) {
+  const contextWindow = finitePositiveNumber(session?.contextWindow);
+  if (contextWindow) {
+    const contextUsage = finitePositiveNumber(session?.contextUsage) || 0;
+    return Math.max(1, contextWindow - contextUsage);
+  }
+
+  return finitePositiveNumber(session?.inputQuota);
+}
+
+function truncateConversationExcerpt(text, maxChars) {
+  if (!text || text.length <= maxChars) {
+    return text || "";
+  }
+
+  const marker = "\n\n[...conversation excerpt truncated to fit Chrome built-in AI context window...]\n\n";
+  if (maxChars <= marker.length + 20) {
+    return text.slice(0, Math.max(0, maxChars));
+  }
+
+  const remaining = maxChars - marker.length;
+  const headLength = Math.floor(remaining * 0.45);
+  const tailLength = remaining - headLength;
+  return `${text.slice(0, headLength)}${marker}${text.slice(-tailLength)}`;
+}
+
+async function buildPromptWithinSessionQuota(session, payload) {
+  const prompt = buildSummaryPrompt(payload);
+  const quota = getAvailablePromptQuota(session);
+  const usage = await measurePromptContextUsage(session, prompt);
+  if (!quota || !usage) {
+    return { prompt, quota, usage, truncated: false };
+  }
+
+  const targetQuota = Math.max(1, quota - Math.max(64, Math.ceil(quota * 0.08)));
+  if (usage <= targetQuota) {
+    return { prompt, quota, usage, truncated: false };
+  }
+
+  const compactConversation = payload.compactConversation || "";
+  let low = 0;
+  let high = compactConversation.length;
+  let bestPrompt = buildSummaryPrompt({ ...payload, compactConversation: "" });
+  let bestUsage = await measurePromptContextUsage(session, bestPrompt);
+
+  while (low <= high) {
+    const mid = Math.floor((low + high) / 2);
+    const candidateConversation = truncateConversationExcerpt(compactConversation, mid);
+    const candidatePrompt = buildSummaryPrompt({ ...payload, compactConversation: candidateConversation });
+    const candidateUsage = await measurePromptContextUsage(session, candidatePrompt);
+
+    if (candidateUsage && candidateUsage <= targetQuota) {
+      bestPrompt = candidatePrompt;
+      bestUsage = candidateUsage;
+      low = mid + 1;
+    } else {
+      high = mid - 1;
+    }
+  }
+
+  if (bestUsage && bestUsage > quota) {
+    throw new Error(`Chrome built-in AI prompt exceeds the available context window (${bestUsage}/${quota}).`);
+  }
+
+  return { prompt: bestPrompt, quota, usage: bestUsage, truncated: true };
+}
+
+async function collectChromeBuiltInPrompt(session, prompt) {
+  if (typeof session.promptStreaming !== "function") {
+    return session.prompt(prompt);
+  }
+
+  let response = "";
+  const stream = session.promptStreaming(prompt);
+  for await (const chunk of stream) {
+    response += chunk;
+  }
+  return response;
+}
+
+async function summarizeViaChromeBuiltIn(payload) {
+  let session = null;
+  let overflowed = false;
+  try {
+    const sessionResult = await takeChromeBuiltInSession();
+    if (!sessionResult.ok) {
+      return { ok: false, used: true, summary: null, error: sessionResult.error };
+    }
+
+    session = sessionResult.session;
+    if (typeof session.addEventListener === "function") {
+      const onOverflow = () => {
+        overflowed = true;
+        console.warn("[Continue It] Chrome built-in AI context overflowed; some prompt context may be dropped.");
+      };
+      session.addEventListener("quotaoverflow", onOverflow);
+      session.addEventListener("contextoverflow", onOverflow);
+    }
+
+    const fitted = await buildPromptWithinSessionQuota(session, payload);
+    const summary = (await collectChromeBuiltInPrompt(session, fitted.prompt)).trim();
+    if (!summary) {
+      return { ok: false, used: true, summary: null, error: "Chrome built-in AI returned an empty summary." };
+    }
+
+    const warnings = [];
+    if (fitted.truncated) {
+      warnings.push(`Chrome built-in AI prompt was truncated to fit the available context window (${fitted.usage || "unknown"}/${fitted.quota || "unknown"}).`);
+    }
+    if (overflowed) {
+      warnings.push("Chrome built-in AI reported context overflow while generating the summary.");
+    }
+
+    return { ok: true, used: true, summary, quota: null, warnings, error: null };
+  } catch (error) {
+    if (isContextLimitError(error)) {
+      return {
+        ok: false,
+        used: true,
+        summary: null,
+        error: "Chrome built-in AI could not fit the full conversation in its context window. Use Server AI or Custom API key for this larger handoff."
+      };
+    }
+    return { ok: false, used: true, summary: null, error: `Chrome built-in AI failed: ${error?.message || String(error)}` };
+  } finally {
+    if (session) {
+      session.destroy();
+    }
+  }
+}
+
 // --- Shared backend (sarvam AI) 
 async function summarizeViaServer(payload) {
-  const serverUrl = DEFAULT_SERVER_URL.replace(/\/$/, "");
+  const stored = await getStorage([AI_STORAGE_KEYS.serverUrl]);
+  const serverUrl = (stored[AI_STORAGE_KEYS.serverUrl] || DEFAULT_SERVER_URL).replace(/\/$/, "");
   const url = `${serverUrl}/api/summarize`;
 
   if (!(await hasOriginPermission(serverUrl))) {
@@ -193,6 +614,9 @@ async function summarizeViaByok(payload) {
 }
 
 async function handleSummarize(payload) {
+  if (payload.aiMode === CHROME_BUILTIN_MODE) {
+    return summarizeViaChromeBuiltIn(payload);
+  }
   if (payload.aiMode === "server") {
     return summarizeViaServer(payload);
   }
@@ -202,6 +626,10 @@ async function handleSummarize(payload) {
   return { ok: false, used: false, summary: null, error: "AI is disabled." };
 }
 
+async function handlePrewarmBuiltIn() {
+  return prewarmChromeBuiltIn();
+}
+
 // Minimal request used to validate that a provider/key/server actually works.
 async function handleTest(payload) {
   const testPayload = {
@@ -225,16 +653,23 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
   }
 
   if (message.type === "continueIt.summarize") {
-    handleSummarize(message.payload || {})
-      .then((result) => sendResponse(result))
-      .catch((error) => sendResponse({ ok: false, used: true, summary: null, error: error?.message || "AI request failed." }));
+    (async () => {
+      sendResponse(await handleSummarize(message.payload || {}));
+    })().catch((error) => sendResponse({ ok: false, used: true, summary: null, error: error?.message || "AI request failed." }));
+    return true;
+  }
+
+  if (message.type === "continueIt.prewarmBuiltIn") {
+    (async () => {
+      sendResponse(await handlePrewarmBuiltIn());
+    })().catch((error) => sendResponse({ ok: false, error: error?.message || "Chrome built-in AI prewarm failed." }));
     return true;
   }
 
   if (message.type === "continueIt.test") {
-    handleTest(message.payload || {})
-      .then((result) => sendResponse(result))
-      .catch((error) => sendResponse({ ok: false, error: error?.message || "Test failed." }));
+    (async () => {
+      sendResponse(await handleTest(message.payload || {}));
+    })().catch((error) => sendResponse({ ok: false, error: error?.message || "Test failed." }));
     return true;
   }
 
diff --git a/content-site.js b/content-site.js
index 51b45c2..9b3ba02 100644
--- a/content-site.js
+++ b/content-site.js
@@ -12,6 +12,8 @@
     return;
   }
 
+  const LAUNCHER_ID = `continue-it-launcher-${provider.id}`;
+
   function getNodeText(node) {
     return shared.cleanMessageText(node?.innerText || node?.textContent || "");
   }
@@ -328,7 +330,7 @@
     modal.content.querySelector("#continue-it-close-debug").addEventListener("click", () => modal.close());
   }
 
-  async function captureConversation() {
+  async function captureConversation({ onProgress } = {}) {
     const scrollRoot = findScrollableContainer();
     const originalScrollTop = scrollRoot.scrollTop;
     const cache = new Map();
@@ -338,7 +340,27 @@
     let scanSteps = 0;
     const stepCounts = [];
 
-    ui.toast(`Scanning ${provider.name} conversation for full context...`, "default", 2000);
+    // How far we have to scroll back up is the only real measure of scan
+    // progress. Virtualized transcripts can grow while we walk up, so the
+    // fraction is a best effort β€” the progress UI clamps it monotonically.
+    const scanDistance = originalScrollTop;
+
+    function reportProgress(step) {
+      if (!onProgress) {
+        return;
+      }
+      const scrolled = scanDistance > 0
+        ? 1 - Math.min(1, Math.max(0, scrollRoot.scrollTop / scanDistance))
+        : Math.min(1, (step + 1) / 4);
+      // The cache re-keys the same message at each scroll offset it was seen at,
+      // so count distinct text instead β€” that is what survives deduplication and
+      // what the review modal will report.
+      const found = new Set([...cache.values()].map((candidate) => candidate.text)).size;
+      onProgress({
+        fraction: scrolled,
+        detail: found === 1 ? "1 message found so far" : `${found} messages found so far`
+      });
+    }
 
     for (let step = 0; step < 80; step += 1) {
       scanSteps = step + 1;
@@ -352,6 +374,7 @@
 
       const currentCount = cache.size;
       stepCounts.push(currentCount);
+      reportProgress(step);
       const reachedTop = scrollRoot.scrollTop <= 0;
       if (currentCount === previousCount) {
         stableSteps += 1;
@@ -657,26 +680,104 @@
     return error && typeof error.message === "string" && error.message.toLowerCase().includes("extension context invalidated");
   }
 
+  let exportInFlight = false;
+
+  function describeBuiltInStatus(status) {
+    if (!status || status.provider !== "builtin") {
+      return "";
+    }
+    if (status.state === "downloading") {
+      return status.percent === null || status.percent === undefined
+        ? "Downloading Chrome built-in AI model..."
+        : `Downloading Chrome built-in AI model: ${status.percent}%.`;
+    }
+    if (status.state === "checking") {
+      return "Checking Chrome built-in AI availability...";
+    }
+    if (status.state === "preparing") {
+      return "Preparing Chrome built-in AI model...";
+    }
+    if (status.state === "ready") {
+      return "Chrome built-in AI model is ready.";
+    }
+    if (status.state === "expired") {
+      return status.error || "Chrome built-in AI prewarm expired before it was used.";
+    }
+    if (status.state === "error") {
+      return `Chrome built-in AI is not ready: ${status.error || "unknown error"}`;
+    }
+    return "";
+  }
+
+  function attachBuiltInProgress(progress) {
+    function onBuiltInStatus(event) {
+      const detail = describeBuiltInStatus(event.detail);
+      if (detail) {
+        progress.setDetail(detail);
+      }
+    }
+
+    window.addEventListener("continueIt:builtinStatus", onBuiltInStatus);
+    return () => window.removeEventListener("continueIt:builtinStatus", onBuiltInStatus);
+  }
+
   async function exportConversation() {
+    if (exportInFlight) {
+      ui.toast("An export is already running on this tab.", "warning");
+      return;
+    }
+    exportInFlight = true;
+
+    const progress = ui.createProgress({
+      launcherId: LAUNCHER_ID,
+      busyLabel: "Exporting",
+      label: "Starting export"
+    });
+
     try {
-      return await _exportConversation();
+      return await _exportConversation(progress);
     } catch (error) {
       console.error("[Continue It] Export failed:", error);
       if (isContextInvalidated(error)) {
+        progress.fail("Extension reloaded", "Refresh this page (F5), then try again.");
         ui.toast("Extension was reloaded β€” please refresh this page (F5), then try again.", "error", 8000);
       } else {
+        progress.fail("Export failed", error?.message || String(error));
         ui.toast(`Export failed: ${error?.message || String(error)}`, "error", 8000);
       }
+    } finally {
+      exportInFlight = false;
     }
   }
 
-  async function _exportConversation() {
-    const { messages, diagnostics, rawCandidates } = await captureConversation();
+  async function _exportConversation(progress) {
+    const builtInPrewarm = window.ContinueItAI && typeof window.ContinueItAI.prewarmBuiltInModel === "function"
+      ? window.ContinueItAI.prewarmBuiltInModel()
+      : null;
+
+    progress.phase({
+      label: `Scanning ${provider.name} conversation`,
+      detail: "Scrolling back for the full transcript…",
+      from: 0,
+      to: 0.5
+    });
+
+    const { messages, diagnostics, rawCandidates } = await captureConversation({
+      onProgress: ({ fraction, detail }) => progress.set(fraction, detail)
+    });
     if (!messages.length) {
+      progress.fail("No messages found", `Nothing to export from this ${provider.name} page.`);
       ui.toast(`No ${provider.name} messages found on this page.`, "error");
       return;
     }
 
+    progress.phase({
+      label: "Building handoff",
+      detail: `${messages.length} messages captured.`,
+      from: 0.5,
+      to: 0.58
+    });
+
     const summaryMode = await shared.getSummaryMode();
     const handoff = shared.buildHandoff({
       source: provider.name,
@@ -696,23 +797,65 @@
       const aiSettings = await window.ContinueItAI.getSettings();
       const modes = window.ContinueItAI.AI_MODES;
       const usingAI = aiSettings.mode !== modes.none;
+      if (aiSettings.mode === modes.builtin && builtInPrewarm) {
+        builtInPrewarm.then((result) => {
+          if (result && !result.ok && result.error) {
+            console.warn(`[Continue It] Chrome built-in AI prewarm failed: ${result.error}`);
+          }
+        });
+      }
       if (usingAI) {
-        ui.toast(aiSettings.mode === modes.server ? "Contacting Server AI..." : "Generating summary with your API key...", "default", 2500);
+        const phaseLabel = aiSettings.mode === modes.server
+          ? "Contacting Server AI"
+          : aiSettings.mode === modes.builtin
+            ? "Summarizing with Chrome built-in AI"
+            : "Summarizing with your API key";
+        // The request length is unknowable, so this phase creeps toward its end
+        // and shows elapsed time β€” the bar must never look parked.
+        progress.phase({
+          label: phaseLabel,
+          detail: "Waiting for the model to respond…",
+          from: 0.58,
+          to: 0.94,
+          creep: true,
+          slowHintAfter: 12000,
+          slowHint: "Still waiting on the model. Long conversations can take a minute or more."
+        });
+      } else {
+        progress.phase({ label: "Summarizing locally", from: 0.58, to: 0.94 });
+      }
+      const detachBuiltInProgress = aiSettings.mode === modes.builtin ? attachBuiltInProgress(progress) : null;
+      if (detachBuiltInProgress && typeof window.ContinueItAI.getBuiltInStatus === "function") {
+        const detail = describeBuiltInStatus(await window.ContinueItAI.getBuiltInStatus());
+        if (detail) {
+          progress.setDetail(detail);
+        }
+      }
+      let aiResult;
+      try {
+        aiResult = await window.ContinueItAI.summarizeConversation({
+          source: provider.name,
+          messages: handoff.messages,
+          mode: summaryMode,
+          shared
+        });
+      } finally {
+        if (detachBuiltInProgress) {
+          detachBuiltInProgress();
+        }
       }
-      const aiResult = await window.ContinueItAI.summarizeConversation({
-        source: provider.name,
-        messages: handoff.messages,
-        mode: summaryMode,
-        shared
-      });
       if (aiResult.summary) {
         handoff.summary = aiResult.summary;
+        (aiResult.warnings || []).forEach((warning) => handoff.warnings.push(warning));
         if (aiSettings.mode === modes.server) {
           summarySource = "Server AI (backend API)";
           const left = aiResult.quota && Number.isFinite(aiResult.quota.remaining)
             ? ` ${aiResult.quota.remaining}/${aiResult.quota.limit} free exports left today.`
             : "";
           ui.toast(`βœ“ Summary generated by the Server API.${left}`, "success", 5000);
+        } else if (aiSettings.mode === modes.builtin) {
+          summarySource = "Chrome built-in AI";
+          ui.toast("βœ“ Summary generated by Chrome built-in AI.", "success", 4000);
         } else {
           summarySource = "Your own API key";
           ui.toast("βœ“ Summary generated by your own API key.", "success", 4000);
@@ -723,14 +866,16 @@
         handoff.warnings.push(`AI summarization failed, local summary used instead: ${aiResult.error}`);
         ui.toast(`⚠ AI failed β€” used the local (DOM) summary instead. ${aiResult.error}`, "warning", 7000);
       } else if (!usingAI) {
-        ui.toast("Summary generated locally (no AI). Enable Server AI or add your key for smarter summaries.", "default", 4500);
+        ui.toast("Summary generated locally (no AI). Enable Chrome built-in AI, Server AI, or add your key for smarter summaries.", "default", 4500);
       }
     }
 
     handoff.summarySource = summarySource;
 
+    progress.phase({ label: "Preparing review", detail: "", from: 0.94, to: 1 });
     await shared.resetChunkCursor(handoff.id);
     await openExportModal(handoff, { rawCandidates, diagnostics, messageCount: messages.length });
+    progress.succeed("Export ready");
   }
 
   async function importConversation() {
@@ -756,8 +901,11 @@
   }
 
   function boot() {
+    if (window.ContinueItAI && typeof window.ContinueItAI.getSettings === "function") {
+      window.ContinueItAI.getSettings();
+    }
     ui.mountLauncher({
-      id: `continue-it-launcher-${provider.id}`,
+      id: LAUNCHER_ID,
       label: "Continue It",
       getAnchor: getLauncherAnchor,
       actions: [
diff --git a/docs/Plans/2026-08-11-chrome-ai-summary-pr-handoff.md b/docs/Plans/2026-08-11-chrome-ai-summary-pr-handoff.md
new file mode 100644
index 0000000..0a6fb10
--- /dev/null
+++ b/docs/Plans/2026-08-11-chrome-ai-summary-pr-handoff.md
@@ -0,0 +1,164 @@
+---
+title: "Chrome AI Summary and Knowledge Export PR Handoff"
+date: "2026-08-11"
+authors: ["Codex"]
+purpose: "Review handoff for the stacked PR from agent/chrome-built-in-ai-summary onto agent/fix-devtools-csp-probe."
+source_branch: "agent/chrome-built-in-ai-summary"
+base_branch: "agent/fix-devtools-csp-probe"
+---
+
+# Chrome AI Summary and Knowledge Export PR Handoff
+
+## Review Target
+
+This branch is intended to be reviewed as a stacked PR against the earlier
+`agent/fix-devtools-csp-probe` branch, not directly against upstream `main`.
+
+The earlier branch already contains the Server AI backend configuration and the
+quiet DevTools probe handler. This branch builds on top of it with Chrome
+built-in AI robustness, project agent governance, local skills, and a session
+knowledge export spec.
+
+## What Changed
+
+- Added a root `AGENTS.md` for this repository.
+- Added the required output-mode prefix rule for future agent responses.
+- Added repo-specific agent guidance for:
+  - Manifest V3 architecture boundaries
+  - Chrome built-in AI API usage
+  - Server AI and BYOK privacy rules
+  - session-first knowledge export direction
+  - verification expectations
+- Installed/project-staged local skills:
+  - `.agents/skills/chrome-ai`
+  - `.agents/skills/chrome-extensions`
+  - `.agents/skills/built-in-ai`
+  - `.agents/skills/to-spec`
+- Added `docs/Plans/session-knowledge-repo-spec.md`, a full implementation
+  plan for exporting sessions to a Git-backed, Obsidian-friendly LLM wiki.
+- Expanded the export spec with lessons from bookmark organizers, AI chat
+  importers/exporters, context-anchor systems, tab organizers, prompt-packet
+  tools, and personal RAG/search systems.
+- Added `docs/Plans/README.md` and `docs/index.jsonl` to index repo-local
+  planning docs.
+- Updated Chrome built-in AI summary handling in `background.js`:
+  - reads `LanguageModel.params()` when available
+  - uses `promptStreaming()` when available
+  - logs `measureContextUsage()` when available
+  - detects context overflow and routes the user toward Server AI or BYOK
+  - destroys model sessions after use
+- Updated `README.md` to explain why Continue it uses the Prompt API instead of
+  Chrome's task-specific Summarizer API for comprehensive handoffs.
+- Updated `.gitignore` to ignore `.DS_Store`.
+
+## Why It Changed
+
+The product direction is shifting from simple handoff export toward a durable
+session knowledge repository. The central design premise is that Chrome built-in
+AI can be useful even when it is weaker than hosted frontier models, provided
+the extension supplies the missing structure:
+
+- explicit plans
+- immutable raw transcript saves
+- manifests with hashes and token counts
+- continuously updated handoff files
+- verification gates
+- rolling synthesis from saved artifacts
+
+The new spec captures that architecture so future implementation can happen in
+small, reviewable slices.
+
+## Important Design Notes
+
+- Chrome built-in AI should be treated as a bounded local worker, not as an
+  autonomous publisher.
+- Server AI remains separate from the extension. Shared provider keys must not
+  be bundled into extension source.
+- BYOK keys remain local extension settings and should only be sent to the
+  configured provider.
+- The session knowledge export plan separates:
+  - `sessions/` for complete per-session folders
+  - `raw/` for immutable transcripts
+  - `projects/` for rolling project synthesis
+  - `concepts/` for reusable knowledge pages
+  - `skills/` for distilled procedures
+  - `meta/` for manifests, changelogs, and health checks
+- The spec includes source matrices for DeepWiki research across AI chat
+  exporters, Chrome AI implementations, WebMCP examples, repo packagers,
+  handoff systems, autoresearch loops, bookmark organizers, tab/context
+  systems, prompt-packet tools, and personal search/RAG systems.
+- The latest research pass added explicit guidance for:
+  - dual JSON/Markdown exports
+  - top-level indexes and metadata files
+  - first-class reference/bookmark objects
+  - URL normalization and deduplication
+  - attachment preservation with relative links
+  - source-bound annotation sidecars
+  - canonical snapshot layers
+  - background/offscreen write authority
+  - local semantic and hybrid search
+  - preview/approval gates for structural changes
+  - backups before destructive reorganizations
+  - RAG provenance capture
+  - state-anchor handoffs
+
+## Review Checklist
+
+- Confirm the stacked PR base is `agent/fix-devtools-csp-probe`.
+- Review `background.js` for current Chrome built-in AI API compatibility.
+- Review `README.md` for accurate Prompt API vs Summarizer wording.
+- Review `AGENTS.md` for repo governance and response-prefix expectations.
+- Review `docs/Plans/session-knowledge-repo-spec.md` for product direction and
+  scope boundaries.
+- Review the spec's new bookmark/reference/context section and added matrix rows
+  for Relai, ChatGPT exporters, SiftMarks, Context-Sync, MindVault, Context
+  Anchor, promptPACK, TabBrain, Khoj, AI-MarkDone, and related bookmark tools.
+- Generated multi-tool skill mirrors under `.bob`, `.bolt`, `.cline`, `.cursor`,
+  `.github`, `.kilo`, `.roo`, and `output/` are intentionally ignored after the
+  second-pass PR. Review the canonical `.agents/skills/*` folders instead.
+
+## Validation
+
+Recommended checks before merge:
+
+```bash
+node --check background.js
+node --check server/server.js
+```
+
+Manual validation still required:
+
+- Reload unpacked extension in Chrome.
+- Use Chrome built-in AI mode on a supported Chrome profile.
+- Confirm a normal-sized conversation summarizes.
+- Confirm a large conversation produces a clear context-window fallback warning.
+- Confirm Server AI and BYOK still work from the popup.
+
+## Known Risks
+
+- Chrome built-in AI APIs are still changing. Older examples use deprecated
+  `window.ai` APIs; this branch intentionally uses the current `LanguageModel`
+  path.
+- MV3 service worker lifecycle can interrupt long-running work. Future Git
+  bridge or rolling synthesis work may need an offscreen document or extension
+  page.
+- The session knowledge export spec is intentionally broad. Implementation
+  should start with a narrow artifact builder and local zip/download flow before
+  GitHub sync or autonomous synthesis.
+- Generated skill mirrors are not canonical and should not be reviewed as source
+  after the second-pass cleanup. Re-run the generator locally when those mirrors
+  are needed for another tool.
+
+## Next Implementation Slice
+
+1. Add a session artifact builder that emits:
+   - `session.json`
+   - `transcript.jsonl`
+   - `transcript.xml`
+   - `summary.md`
+   - `handoff.md`
+   - `manifest.json`
+2. Add a local zip/download export for one session folder.
+3. Add stronger scroll-to-top diagnostics and top-proof metadata.
+4. Add fixture-based tests for artifact shape.
+5. Add optional GitHub sync only after local export artifacts are stable.
diff --git a/docs/Plans/2026-08-11-source-resweep-review.md b/docs/Plans/2026-08-11-source-resweep-review.md
new file mode 100644
index 0000000..de7f47b
--- /dev/null
+++ b/docs/Plans/2026-08-11-source-resweep-review.md
@@ -0,0 +1,165 @@
+---
+title: "Source Re-Sweep and Ideonomy Review of the Session Knowledge Export Spec"
+date: "2026-08-11"
+authors: ["Claude"]
+purpose: "Second-pass review of session-knowledge-repo-spec.md: a fresh sweep of all referenced repositories plus a structural audit for missed patterns and contradictions."
+reviews: "session-knowledge-repo-spec.md"
+---
+
+# Source Re-Sweep and Ideonomy Review
+
+## Process
+
+All 49 referenced repositories were re-swept, one focused pass per repo, using
+DeepWiki plus repomix source inspection for ground truth. The sweep produced 193
+concrete implementation tidbits: 7 already reflected in the spec, 164 that extend
+a spec lesson, and 22 that are genuinely new or that contradict a current spec
+decision.
+
+The findings were then audited for what the spec *missed* through two structural
+lenses (Gunkel/Kind ideonomy), each drawn independently:
+
+- **Lens A** β€” operators combination / dimension-identification / abstraction-lift;
+  organons *lattice* and *state-machine*.
+- **Lens B** β€” operators abstraction-lift / substitution / cross-domain
+  re-instantiation; organons *graph* and *matrix*.
+
+The strongest signal is where the two lenses converged from different directions
+on the same gap.
+
+## Convergent findings (highest confidence)
+
+1. **Verifier independence.** The weak local model can generate an artifact *and*
+   be asked to verify its own completeness β€” self-grading dressed as a gate.
+   `Superkikim`-style adjudicator/proposer separation and
+   `Auto-claude-code-research-in-sleep`'s required different reviewer family both
+   guard this. The manifest must record which backend *generated* versus which
+   *verified* each artifact; same-model self-verification lowers confidence rather
+   than counting as a passed gate.
+
+2. **Atomic multi-file write.** The 6–7 file `sessions/<id>/` bundle can tear
+   (transcript lands, manifest does not). The spec only *detects* this after the
+   fact via hashes; it never *prevents* it. Write to a temp directory and make
+   `manifest.json` the last file written (the commit marker) β€” `leonhartX/gas-github`'s
+   blob→tree→commit→ref sequencing applied to local disk. This also gives the
+   otherwise-unschematized `meta/manifest.json` a concrete role.
+
+3. **"Verification gates" is two mechanisms under one name.** *Verify* (did the
+   artifact improve β€” density, coverage, token delta β†’ rework) and *Guard* (was an
+   invariant violated β€” schema, refs resolve, no dropped messages β†’ hard revert)
+   have opposite failure semantics. `karpathy/autoresearch`, `uditgoenka/autoresearch`,
+   and `supratikpm/gemini-autoresearch` all split them.
+
+4. **Loops have no bounds.** The `CAPTURE_INCOMPLETE β†’ SCROLLING_TO_TOP` retry
+   cycle and the rolling-synthesis loop lack a retry cap, wall-clock ceiling, and
+   stuck-recovery β€” unlike every autoresearch repo in the corpus (`karpathy`'s
+   time budget, `uditgoenka`'s max-iterations, `supratikpm`/`davidondrej`'s
+   stuck-recovery). A provider stuck on a loading spinner retries forever.
+
+5. **Session lifecycle leak.** `LanguageModel` sessions are destroyed only after
+   generation, not on service-worker suspend / tab close / extension disable β€”
+   `Mazen-Embaby/gogo-va-extension` defines `destroy()` and never calls it. Add
+   lifecycle handlers that best-effort destroy, and a manifest flag distinguishing
+   "session ended cleanly" from "killed mid-flight."
+
+6. **Claim-level provenance.** The spec's own goal β€” "every durable claim
+   traceable back to the source session" β€” is implemented only at reference/URL
+   level. `wanshuiyin/Anti-Autoresearch`'s span-anchored claims ledger and
+   `pzqpzq/Principia`'s canonical-tuple hydration ground individual sentences to
+   byte-offset + hash. The goal outruns the mechanism once `concepts/` and
+   `projects/` compile prose from multiple sessions.
+
+## The contradiction that should change the design
+
+**Branch/DAG capture.** The scroll-to-top state machine assumes one *linear* path,
+but regenerate/edit on ChatGPT and Claude produce a *tree*, and the rejected
+branches are exactly the "failed approaches" material `REMvisual/claude-handoff`
+rates as high-value. The current model silently flattens them.
+
+This resolves together with the spec's own sharpest internal tension β€” "prefer
+API-derived or canonical data" versus the DOM-scroll emphasis. A canonical
+provider-graph snapshot (`zhaoliangbin42/AI-MarkDone`, `daugaard47`'s
+branch-disambiguation) yields the message DAG *and* sidesteps virtualization.
+Recommended capture order: canonical/API snapshot first, DOM-scroll fallback; and
+`transcript.jsonl` carries `parent_message_id` per entry.
+
+## Consolidated tiering
+
+Promoted to first-slice requirements and written into the spec's new
+"Second-Pass Requirements" section: atomic bundle write with manifest-as-commit-marker;
+Verify/Guard split; loop bounds and capture retry cap; deterministic session
+`destroy()` on lifecycle events; provenance-backlink field on `concepts/`/`skills/`;
+mechanical anti-skimming density floor on `summary.md`/`handoff.md`; branch/DAG-aware
+transcript schema.
+
+Kept as future notes: full cross-model independent-review gate; claim-level ledger;
+MCP *inversion* (expose the wiki as an MCP server, not only consume MCP β€”
+`greyhaven-ai/autocontext` precedent); session chain-continuity metadata (thread
+across providers/dates, distinct from topic rollup); an archive/retirement
+absorbing state (nothing prunes today); multi-writer concurrency and handoff
+revision/lock.
+
+## Appendix A β€” Lens A organons (lattice + state-machine)
+
+### Continuity, verification, and grounding: a generality lattice
+
+```
+EXTERNAL STATE ANCHOR (any mechanism externalizing convo state past context loss)
+β”œβ”€β”€ ROLLING HANDOFF (mutable, re-read every resume)   ⟷ INCOMPARABLE ⟷   SINGLE-USE CAPSULE (write-once, cleared on consume)
+β”‚     [spec handoff.md; context-anchor persona/state/next]     [relai pendingContext; Context-Sync PENDING_INJECT_KEY]
+β”‚     ⚠ DIAMOND HAZARD: handoff.md has no version/lock field, so it behaves like a
+β”‚       ROLLING HANDOFF but gets consumed like a SINGLE-USE CAPSULE when two
+β”‚       devices/tabs resume the same project β€” neither parent's contract is honored.
+VERIFICATION GATE (something must pass before a change is kept)
+β”œβ”€β”€ GROUND-TRUTH METRIC ISOLATION (metric code walled off)        [karpathy/autoresearch]
+β”‚     └── MECHANICAL METRIC GATE (numeric-only, no subjective override)   [uditgoenka, supratikpm]
+β”œβ”€β”€ ADJUDICATED FINDING GATE (LLM proposes; separate deterministic code rules)   [Anti-Autoresearch]
+└── DEBATE / REBUTTAL GATE (adversarial cross-examination)         [Auto-claude-code-research-in-sleep]
+      β€” LEAF WITH NO SPEC-SIDE PARENT: the spec's review/preview gates never climb here.
+EVIDENCE GROUNDING (claims must trace to a source)
+β”œβ”€β”€ REFERENCE-LEVEL DEDUP/NORMALIZATION (URL/domain identity)      [spec, khoj, SiftMarks]
+└── CLAIM-LEVEL SPAN-ANCHORED LEDGER (sentence β†’ byte-offset+hash) [Anti-Autoresearch, Principia]
+      β€” the spec's stated goal is at CLAIM level; its only built mechanism is one level down.
+```
+
+### Capture-to-synthesis lifecycle: a state machine with missing edges
+
+Key gaps surfaced: the `CAPTURE_INCOMPLETE β†’ SCROLLING_TO_TOP` cycle is unbounded
+(no retry cap / wall-clock timeout, unlike every autoresearch loop); `CAPTURE_INCOMPLETE
+β†’ patch-in-place β†’ CAPTURE_COMPLETE` is a *forbidden* transition (raw immutability
+forces a full restart) that is never named as a tradeoff; `HANDOFF_ROLLING β†’
+HANDOFF_STALE` has *no trigger* (two writers both believe they hold latest state);
+`VERIFICATION_FAILED β†’ COMMITTED` is *undefined* (may a user commit a session that
+failed its own health check?); and there is *no absorbing archive/prune state* β€”
+`sessions/`, `raw/`, `projects/` only ever grow.
+
+## Appendix B β€” Lens B organons (graph + matrix)
+
+### Dependency-and-contradiction map (selected edges)
+
+- `DualGate(Verify, Guard)` **splits** the single `VerificationGates` node into two
+  mechanisms with different failure semantics.
+- `EvidenceHydrationRegistry` **requires** `RawTranscript` but **contradicts**
+  `Concepts/Skills` (paraphrase-by-design cannot carry verbatim quotes) β€” an
+  `M(A,B) β‰  M(B,A)` asymmetry; the repair edge is a provenance backlink (hash/id
+  pointer, not a quote).
+- `AtomicMultiFileWrite` has **no named edge** from `SessionBundle` β€” the clearest
+  "edge you cannot name" in the spec.
+- `BranchDAGPreservation` **contradicts** `ScrollToTopStateMachine` (built for one
+  linear path).
+- `MCPServerInversion` **inverts** MCP(backend): the spec only consumes MCP; nothing
+  exposes the local wiki *as* an MCP server.
+- Hidden hubs by degree: the weak-model-needs-external-structure thesis (highest
+  implicit in-degree, never an explicit node); the `sessions/` bundle (actual
+  capture↔synthesis join point); `manifest.json` (could double as the atomic commit
+  marker, treated only as bookkeeping today).
+
+### Missed-technique Γ— storage-layer applicability
+
+The `sessions/` column is hit "Strong" by six of eight missed-technique rows β€” it
+is the load-bearing layer the spec under-specifies relative to its centrality. The
+`meta/` column is almost entirely passive ("Partial"/"Guard-only") where several
+rows (chain-continuity, atomic-write, evidence-audit) want it to be an active
+enforcement point. Independent-review and MCP-inversion concentrate in the synthesis
+tier (`projects/`, `concepts+skills/`); branch/DAG concentrates in capture/`raw`/`sessions`
+and is the one row that actively contradicts an already-built mechanism.
diff --git a/docs/Plans/README.md b/docs/Plans/README.md
new file mode 100644
index 0000000..4553224
--- /dev/null
+++ b/docs/Plans/README.md
@@ -0,0 +1,6 @@
+# Plans
+
+Repo-local plans and implementation specs for Continue it.
+
+These documents capture product and architecture decisions that are useful while
+developing the extension. They are not bundled with the Chrome extension.
diff --git a/docs/Plans/session-knowledge-repo-spec.md b/docs/Plans/session-knowledge-repo-spec.md
new file mode 100644
index 0000000..91013ee
--- /dev/null
+++ b/docs/Plans/session-knowledge-repo-spec.md
@@ -0,0 +1,676 @@
+---
+title: "Session Knowledge Repository Export Spec"
+date: "2026-08-11"
+authors: ["Codex"]
+purpose: "Plan a Chrome extension workflow that captures AI chat sessions into a Git-backed, wiki-friendly knowledge repository."
+source_files:
+  - "/Users/m/Downloads/revivalstack-ai-chat-exporter-8a5edab282632443.txt"
+  - "https://developer.chrome.com/docs/ai/webmcp/compare-mcp?hl=en"
+  - "https://mcp.deepwiki.com/mcp"
+  - "https://developer.chrome.com/docs/ai/prompt-api"
+  - "https://developer.chrome.com/docs/ai/summarizer-api"
+  - "https://deepwiki.com/nico-martin/gemma4-browser-extension"
+  - "https://deepwiki.com/mrauter1/GitPreProcess"
+  - "https://deepwiki.com/atjsh/llmlingua-2-js"
+  - "https://deepwiki.com/kiro0x/five-mcp"
+  - "https://deepwiki.com/ulyssestenn/funes"
+  - "https://deepwiki.com/adam-s/cordyceps"
+  - "https://deepwiki.com/yamadashy/repomix"
+  - "https://deepwiki.com/coderamp-labs/gitingest"
+  - "https://deepwiki.com/leonhartX/gas-github"
+  - "https://deepwiki.com/natsu1211/deepwiki-skill"
+  - "https://deepwiki.com/saharmor/sidekick-dev-web"
+  - "https://deepwiki.com/REMvisual/claude-handoff"
+  - "https://deepwiki.com/wanshuiyin/Auto-claude-code-research-in-sleep"
+  - "https://deepwiki.com/karpathy/autoresearch"
+  - "https://deepwiki.com/gaasher/Agent-Loop-Skills"
+  - "https://deepwiki.com/jmilinovich/goal-md"
+  - "https://deepwiki.com/pzqpzq/Principia"
+  - "https://deepwiki.com/davidondrej/jailbreak-autoresearch"
+  - "https://deepwiki.com/greyhaven-ai/autocontext"
+  - "https://deepwiki.com/uditgoenka/autoresearch"
+  - "https://deepwiki.com/wanshuiyin/Anti-Autoresearch"
+  - "https://deepwiki.com/Rescenix/ResceneAgent"
+  - "https://deepwiki.com/supratikpm/gemini-autoresearch"
+  - "https://deepwiki.com/revivalstack/ai-chat-exporter"
+  - "https://deepwiki.com/Mazen-Embaby/gogo-va-extension"
+  - "https://deepwiki.com/itamaker/go-chrome-ai"
+  - "https://deepwiki.com/donpark/chrome-ai-tools"
+  - "https://deepwiki.com/7Xme/chrome-ai-learning-assistant"
+  - "https://deepwiki.com/moerasermax/Tools_ForSharing"
+  - "https://deepwiki.com/matoliva/grammar-ai"
+  - "https://deepwiki.com/V-Gutierrez/browser-llm-lab"
+  - "https://deepwiki.com/oliuntangled/webmcp-gen"
+  - "https://deepwiki.com/airwomandivanbed693/gemini-nano-chrome"
+  - "https://deepwiki.com/kirillpolevoy/relai"
+  - "https://deepwiki.com/FdezRomero/chatgpt-exporter"
+  - "https://deepwiki.com/Edmon02/bookmark-ai-organizer"
+  - "https://deepwiki.com/Superkikim/nexus-ai-chat-importer"
+  - "https://deepwiki.com/daugaard47/ChatGPT_Conversations_To_Markdown"
+  - "https://deepwiki.com/Lling0000/SiftMarks"
+  - "https://deepwiki.com/Vineetpandey0/Context-Sync"
+  - "https://deepwiki.com/rathi-yash/MindVault-AI-Bookmarker"
+  - "https://deepwiki.com/andrewjtyo-glitch/context-anchor"
+  - "https://deepwiki.com/LumenHelixLab/promptPACK"
+  - "https://deepwiki.com/redzumi/ai-ai-bookmarks"
+  - "https://deepwiki.com/ooye-sanket/Deja-vu"
+  - "https://deepwiki.com/kyruntime/bookmark-organizer"
+  - "https://deepwiki.com/ndg8743/TabBrain"
+  - "https://deepwiki.com/khoj-ai/khoj"
+  - "https://deepwiki.com/zhaoliangbin42/AI-MarkDone"
+---
+
+# Session Knowledge Repository Export Spec
+
+## Problem Statement
+
+Users have long AI conversations across ChatGPT, Claude, Gemini, Grok,
+Perplexity, and similar browser products. Those conversations often contain
+requirements, decisions, code, references, generated documents, rejected
+approaches, and handoff state. Browser UIs virtualize old messages and often
+only expose the latest loaded portion of a conversation, so an export that does
+not intentionally scroll to the beginning can silently lose the earliest
+context.
+
+The current extension can capture a conversation and create a portable handoff,
+but the next product goal is larger: preserve each session as a Git-backed,
+human-readable and machine-readable knowledge unit. Each session should retain
+the full raw transcript, a pretty GitHub/Obsidian-readable summary, an immediate
+handoff, any produced documents, any referenced sources, and enough metadata for
+later rolling synthesis into projects, concepts, skills, and research indexes.
+
+The user wants this organized like an LLM wiki: raw sources remain immutable,
+session-level notes are compiled from those sources, project and concept pages
+are synthesized over time, and every durable claim remains traceable back to the
+source session.
+
+## Solution
+
+Build a session knowledge export workflow around three outputs:
+
+1. **Full export**: complete transcript plus metadata, diagnostics, attachments,
+   references, produced files, and hashes.
+2. **Summary export**: GitHub/Obsidian-friendly session summary with frontmatter,
+   tags, wikilinks, source provenance, and a concise but complete narrative.
+3. **Immediate handoff export**: continuation-focused handoff optimized for the
+   next AI session, including current goal, exact state, open blockers, and next
+   action.
+
+Each exported session becomes a directory:
+
+```text
+sessions/
+  2026-08-11-chatgpt-title/
+    session.json
+    transcript.jsonl
+    transcript.xml
+    summary.md
+    handoff.md
+    manifest.json
+    produced/
+      ...
+    references/
+      ...
+```
+
+The same immutable raw transcript is also copied or indexed into a raw source
+layer so future agents can synthesize skills and higher-level knowledge without
+rewriting the session folder:
+
+```text
+raw/
+  sessions/
+    2026/
+      08/
+        2026-08-11-chatgpt-title.transcript.jsonl
+```
+
+Compiled knowledge lives separately:
+
+```text
+projects/
+  continue-it/
+    continue-it.md
+    session-index.md
+    handoff.md
+    synthesis/
+      chrome-ai-session-capture.md
+
+concepts/
+  chrome-built-in-ai.md
+  browser-transcript-virtualization.md
+  git-backed-llm-wiki.md
+
+skills/
+  chrome-ai-session-export.md
+
+meta/
+  manifest.json
+  changelog.md
+  health/
+```
+
+The extension owns browser capture and export artifact creation. A repo-side
+maintenance process owns rolling synthesis, manifest refreshes, Git commits,
+health checks, and optional MCP/WebMCP integrations.
+
+## Core Thesis
+
+Chrome built-in AI models should not be treated like one-shot frontier models.
+The product should make weaker local models useful by giving them a narrow job,
+an explicit plan, durable files, and a recoverable loop. The model does not need
+to remember the whole project if the extension saves each step into a
+well-structured repository.
+
+The loop should work like this:
+
+1. Capture the page state and transcript.
+2. Write immutable raw files.
+3. Generate or update a small handoff file.
+4. Generate a session summary with provenance and tags.
+5. Update a manifest with hashes, token counts, capture diagnostics, and open
+   work.
+6. Run a cheap verification pass over required files and links.
+7. Save or commit the artifact package.
+8. Resume the next pass from the files, not from model memory.
+
+This is the practical bridge between Chrome built-in AI and larger agentic
+workflows. The browser model can summarize, classify, compress, extract
+references, and draft handoffs because every output is immediately externalized
+and checked. Larger Server AI, BYOK, MCP, or repo-side agents can later consume
+the same saved state for deeper synthesis.
+
+## User Stories
+
+1. As a user, I want a full transcript export, so that no conversation context is
+   lost.
+2. As a user, I want a summary export, so that I can review a session quickly on
+   GitHub.
+3. As a user, I want an immediate handoff export, so that a new AI session can
+   continue work without re-reading everything.
+4. As a user, I want every session in its own directory, so that related files do
+   not get scattered.
+5. As a user, I want produced documents saved under the session, so that code,
+   plans, specs, prompts, and generated artifacts stay attached to their origin.
+6. As a user, I want referenced sources saved under the session, so that links,
+   docs, PDFs, and repo references are available later.
+7. As a user, I want raw transcripts copied into a raw source layer, so that the
+   wiki can be rebuilt or resynthesized later.
+8. As a user, I want session summaries synthesized into project pages, so that
+   related sessions compound into useful project memory.
+9. As a user, I want references and produced documents to feed concept pages, so
+   that repeated ideas become reusable knowledge.
+10. As a user, I want sessions tagged and linked, so that Obsidian graph and
+    GitHub search both work well.
+11. As a user, I want the browser to scroll to the top automatically, so that I
+    do not have to remember a manual pre-export step.
+12. As a user, I want the extension to track whether the beginning was captured,
+    so that it can warn or retry when the transcript is incomplete.
+13. As a user, I want transcript view or verbose view preferred, so that the
+    exporter captures complete text instead of compressed UI cards.
+14. As a user, I want capture diagnostics, so that I can tell whether the export
+    hit a scroll limit, missed roles, or stopped early.
+15. As a user, I want machine-readable transcripts, so that scripts and agents can
+    process sessions without parsing Markdown.
+16. As a user, I want pretty Markdown summaries, so that humans can read sessions
+    directly in GitHub.
+17. As an Obsidian user, I want frontmatter and wikilinks, so that exported
+    sessions connect into a graph.
+18. As an AI agent, I want a manifest with hashes and token counts, so that I can
+    skip unchanged files and choose the right processing tier.
+19. As an AI agent, I want chunk maps for long transcripts, so that I can avoid
+    missing middle context.
+20. As an AI agent, I want rolling handoff files, so that I can resume from the
+    latest validated state rather than the latest chat tail.
+21. As a user, I want one session export to be one reviewable Git change, so that
+    I can inspect and revert it cleanly.
+22. As a user, I want GitHub sync, so that session knowledge is preserved outside
+    browser storage.
+23. As a user, I want safe GitHub authentication, so that tokens are not exposed
+    in exported artifacts.
+24. As a user, I want explicit commit boundaries, so that each session can become
+    a meaningful commit.
+25. As a user, I want rolling analysis only after enough sessions exist, so that
+    synthesis is based on evidence rather than one-off guesses.
+26. As a user, I want rolling synthesis to be bounded and logged, so that agents
+    do not loop forever.
+27. As a user, I want references to DeepWiki and MCP sources preserved, so that
+    later agents can revisit the source reasoning.
+28. As a user, I want Chrome built-in AI used when appropriate, so that local,
+    private summaries work on supported devices.
+29. As a user, I want Server AI or BYOK fallback, so that large handoffs and
+    unsupported Chrome AI states still work.
+30. As a user, I want language and context limits detected, so that the exporter
+    can choose the right summarization backend.
+31. As a user, I want a health check over the knowledge repo, so that broken
+    links, stale manifests, missing raw files, and orphaned summaries are caught.
+32. As a user, I want artifact and reference classification, so that screenshots,
+    code, docs, URLs, and generated files are searchable by type.
+33. As a user, I want future skill synthesis, so that repeated session patterns
+    can become reusable agent skills.
+34. As a developer, I want tests at the export-package seam, so that refactors do
+    not break the shape of saved sessions.
+35. As a developer, I want browser automation validation, so that scroll-to-top
+    capture works on real supported providers.
+36. As a developer, I want explicit out-of-scope boundaries, so that WebMCP,
+    MCP, vector databases, and agent loops do not bloat the first implementation.
+
+## Implementation Decisions
+
+- Treat a session as the primary unit of storage, review, and commit.
+- Store all files for one session under a slugged date-provider-title directory.
+- Store full transcripts in both JSONL and XML-compatible forms. JSONL is the
+  primary machine format; XML is the continuation-friendly structured format.
+- Keep raw transcripts immutable. Never edit raw transcript files after export;
+  write corrected or normalized derivatives as compiled files.
+- Maintain a session manifest with:
+  - stable session id
+  - provider
+  - source URL
+  - title
+  - capture timestamp
+  - message count
+  - role counts
+  - estimated token count
+  - content hashes
+  - capture diagnostics
+  - generated file inventory
+  - reference inventory
+  - synthesis status
+- Use hash-based caching for rolling analysis. If a raw transcript hash has not
+  changed, repo-side analysis should skip transcript reprocessing.
+- Use token counts to choose processing mode:
+  - quick pass for short sessions
+  - deep pass for medium sessions
+  - chunked map-reduce pass for very long sessions
+- Prefer transcript or verbose platform views when available.
+- Add a capture-state model that records whether the top of the conversation was
+  reached.
+
+Prototype decision shape:
+
+```json
+{
+  "capture": {
+    "state": "idle|scrolling_to_top|top_reached|extracting|complete|incomplete",
+    "topReached": true,
+    "topProof": {
+      "scrollTop": 0,
+      "oldestMessageHash": "sha256:...",
+      "stableIterations": 3
+    },
+    "warnings": []
+  }
+}
+```
+
+- Automatic scroll must be incremental, observable, and cancellable. It should
+  scroll upward until it reaches a stable top condition, not merely a fixed step
+  count.
+- Top detection should combine scroll position, oldest message identity, message
+  count stability, and provider-specific loading indicators.
+- The extension should remember oldest captured message hashes per conversation
+  URL/session so it can detect whether a later export includes the beginning.
+- Deduplication must preserve order and role while removing repeated dynamically
+  loaded messages.
+- The local capture layer should not rely on Chrome DevTools Protocol.
+- Browser automation should be implemented through extension APIs and DOM APIs,
+  borrowing the Cordyceps-style lesson that robust DOM automation needs
+  frame-aware and shadow-aware extraction where possible.
+- Treat tab management, page extraction, scroll/load, artifact detection, and
+  GitHub sync as separate internal tools with structured results.
+- Chrome built-in AI should use `LanguageModel` for comprehensive handoff and
+  wiki-style summary generation.
+- Chrome `Summarizer` may be used later for preview cards or short summaries,
+  but not as the main handoff path because task-specific summaries are too short
+  and currently do not fit the background service worker flow.
+- `LanguageModel.availability()` and `LanguageModel.create()` must receive
+  matching options.
+- Use `LanguageModel.params()` when available to tune local generation.
+- Use `promptStreaming()` and concatenate chunks because streaming chunks are
+  independent.
+- Use `measureContextUsage()` and `contextoverflow` handling to route large
+  sessions to Server AI or BYOK.
+- Do not use deprecated `window.ai.*` names.
+- Use Server AI or BYOK for large sessions, unsupported languages, unsupported
+  hardware, or higher-quality rolling synthesis.
+- Keep MCP and WebMCP separate:
+  - MCP is for persistent backend capabilities such as DeepWiki, GitHub, and
+    repo maintenance.
+  - WebMCP is for live page affordances exposed to browser agents while a page
+    is open.
+- Consider WebMCP as a future browser-agent interface, not a replacement for the
+  extension's Git-backed export pipeline.
+- GitHub sync should be optional and explicit. The extension can create local
+  export bundles first, then add GitHub commit/PR support behind configuration.
+- GitHub operations must be auditable and user-confirmed unless the user
+  intentionally enables automatic session commits.
+- Rolling analysis should happen repo-side, not inside the content script.
+- Rolling analysis should use bounded loops:
+  - one change per iteration
+  - explicit queue
+  - mechanical checks
+  - append-only log
+  - stop criteria
+  - reviewable Git diff
+- Maintain a continuously updated project handoff file, but derive it from
+  session summaries and raw manifests rather than rewriting it from memory.
+- Use LLM-wiki layering:
+  - raw session transcript as source
+  - session summary as source note
+  - project synthesis as compiled knowledge
+  - concept pages as reusable knowledge
+  - skills as distilled procedures
+- Produced documents and references should remain attached to their source
+  session, then be indexed into compiled layers.
+- Add a meta changelog for every repo-side synthesis operation.
+- Add health checks for:
+  - missing raw files
+  - session folders missing required files
+  - stale manifests
+  - broken links
+  - orphaned concept pages
+  - duplicate concepts
+  - summaries not reflected in project synthesis
+
+## Testing Decisions
+
+- The highest-value test seam is: given a fully loaded transcript capture result,
+  exporting produces a complete session artifact bundle with raw transcript,
+  summary, handoff, manifest, references, produced artifacts, and diagnostics.
+- Tests should assert external behavior and artifact shape, not implementation
+  details.
+- The first automated tests should cover:
+  - session slug creation
+  - JSONL transcript generation
+  - XML transcript generation
+  - summary Markdown frontmatter
+  - immediate handoff Markdown sections
+  - manifest hashes and token counts
+  - raw-layer copy/index records
+  - warning when top-of-chat proof is missing
+  - warning when scan/scroll limit is hit
+  - chunked transcript map generation
+- Browser-level validation should cover supported providers with fixture DOMs
+  and at least one live manual or DevTools MCP run per provider class.
+- Scroll-to-top behavior should be tested as a state machine:
+  - begins at nonzero scroll position
+  - detects loading while older messages appear
+  - stops only after top proof is stable
+  - records incomplete capture when proof is absent
+- GitHub sync should be tested with a mocked GitHub API first:
+  - create session files
+  - commit one session directory
+  - reject commits with missing required files
+  - handle API failures without losing local export data
+- Rolling synthesis should be tested on fixture sessions:
+  - one session updates only session-level files
+  - multiple related sessions update a project synthesis page
+  - unchanged hashes skip reprocessing
+  - health check reports missing links and stale manifests
+- Chrome built-in AI should be validated in real Chrome because Node cannot run
+  the browser built-in AI APIs.
+- Server AI and BYOK paths should remain fallback-compatible with the same
+  payload contract used by Chrome built-in AI.
+
+## Chrome AI Implementation Lessons
+
+The Chrome AI examples support a practical implementation strategy, but they
+also show API drift. Older examples use `window.ai`, `window.ai.languageModel`,
+or `createTextSession()`. Continue it should not copy those calls directly.
+Use the current API surface already reflected in this repo: `LanguageModel`,
+`LanguageModel.availability()`, `LanguageModel.params()`,
+`LanguageModel.create()`, `session.prompt()`, `session.promptStreaming()`,
+`session.measureContextUsage()`, and `session.destroy()`.
+
+Implementation lessons to carry forward:
+
+- Always record the backend used for a generated artifact:
+  `chrome-built-in-ai`, `server-ai`, `byok-openai`, `byok-anthropic`, or
+  `manual`.
+- Record model availability at generation time:
+  `readily`, `after-download`, `no`, unsupported language, unsupported device,
+  context overflow, or API exception.
+- Treat Chrome built-in AI as a bounded local worker. It should summarize,
+  classify, compress, extract references, draft handoff updates, and validate
+  file completeness, but it should not own irreversible GitHub publication.
+- Destroy model sessions after generation to avoid memory leaks in long-running
+  extension use.
+- Use streaming where possible, but persist only validated complete outputs.
+  Partial streaming text can be shown in UI, but exported Markdown should be
+  written after completion or marked incomplete.
+- Keep service-worker lifecycle limits in mind. Long-lived work, local bridge
+  connections, or heavy browser-side model execution may need an extension page
+  or offscreen document rather than relying only on the MV3 background service
+  worker.
+- Do not assume the model is available. The UI needs an explicit state for
+  unavailable, downloading, ready, generation failed, and routed to fallback.
+- Capture prompt/system instructions and model parameters in `manifest.json` so
+  generated summaries are reproducible enough for audit.
+- Keep privacy posture explicit. Chrome AI paths are local, but GitHub sync,
+  Server AI, and BYOK paths may move private transcript data outside the
+  browser.
+- Treat WebMCP tools as typed live-page affordances. Mutating tools should carry
+  annotations and human confirmation, especially if future tools can commit,
+  upload, delete, or rewrite wiki files.
+
+## Bookmark, Reference, and Context Lessons
+
+The bookmark, tab, ChatGPT export, and personal-search systems point to a
+broader artifact model. Continue it should not treat an AI chat transcript as
+the only source worth preserving. A session folder should be able to carry the
+conversation, referenced URLs, bookmarks, visible tabs, source annotations,
+attachments, generated files, and context filters that shaped the session.
+
+Implementation lessons to carry forward:
+
+- Use dual exports by default: structured JSON for machines and readable
+  Markdown for GitHub/Obsidian review.
+- Add top-level and per-session indexes. A session folder can be self-contained,
+  but a repo-level `index.json`, `metadata.json`, or manifest makes incremental
+  processing practical.
+- Treat references as first-class objects with normalized URL, title,
+  description, domain, favicon, tags, source page, capture timestamp, and
+  extraction method.
+- Normalize URLs before deduplication. Strip obvious tracking noise and account
+  for protocol/trailing-slash differences while preserving the original URL.
+- Preserve attachments with relative links from Markdown. Images, audio,
+  documents, generated code, and tool outputs belong in `produced/` or
+  `references/` with manifest entries.
+- Add source-bound annotations as sidecars. Highlights, comments, message
+  bookmarks, and reader notes should point back to stable message/reference ids
+  rather than modifying raw transcript files.
+- Prefer API-derived or canonical data when available. DOM capture is necessary
+  for live AI chat pages, but a canonical snapshot layer should shield export,
+  reader, bookmark, and word-count features from DOM churn.
+- Keep the background/offscreen layer as the write authority for sensitive
+  storage writes. Content scripts should submit capture intents and snapshots,
+  not own final persistent writes.
+- Use local-first semantic search where possible. Small embeddings or local
+  browser models can rank references, bookmarks, and summaries without sending
+  private browsing context to a provider.
+- Support hybrid search later: keyword/FTS for exact filenames, domains, and
+  errors; embeddings for semantic retrieval; tags and project filters for user
+  control.
+- Add review gates before destructive or structural changes. Reorganizing
+  bookmarks, rewriting project pages, restoring backups, or applying AI
+  refactors should use virtual previews and explicit approval.
+- Create backups before mutating user-controlled knowledge stores. Git history is
+  useful, but an export/import JSON backup gives users a separate recovery path.
+- Track incremental export state: new, changed, skipped, unavailable,
+  permanently unavailable, retried, and failed.
+- Keep privacy labels on every generated artifact. Record whether it was local,
+  sent to BYOK, sent to Server AI, or produced from an external metadata service.
+- Use state anchors for long conversations. A compact, approved anchor with
+  persona/role, current state, key artifacts, constraints, and next action can
+  become the continuously updated `handoff.md`.
+- Treat prompt packs as first-class produced artifacts. Prompt compression,
+  must-fact preservation, and structured handoff packets should be saved under
+  `produced/` with their source transcript chunk ids.
+- For RAG sessions, save the retrieved context. Exported answers should identify
+  which notes, bookmarks, tabs, or references were used to generate them.
+
+## Second-Pass Requirements (2026-08-11 Source Re-Sweep)
+
+A second sweep of all referenced repositories and a structural audit
+(`docs/Plans/2026-08-11-source-resweep-review.md`) promoted the following from
+implicit assumptions to first-slice requirements. Each is traceable to a
+convergent pattern across multiple swept repositories.
+
+- **Atomic session-bundle write.** Write a session folder to a temporary
+  directory and make `manifest.json` the last file written β€” the commit marker.
+  After every content file and the manifest are complete, atomically rename or
+  promote the temporary directory into `sessions/<id>`. A bundle without a final
+  `manifest.json`, or one still under a temporary name, is treated as torn and
+  ignored on read. Do not rely on post-hoc hash verification to detect a partial
+  write.
+  (Pattern: `leonhartX/gas-github` blob→tree→commit→ref sequencing.)
+- **Split verification into Verify and Guard.** *Guard* checks invariants (schema
+  valid, references resolve, no dropped messages, raw hash matches) and, on
+  violation, forces a hard revert/discard. *Verify* checks improvement (density,
+  coverage, token delta) and, on regression, triggers rework. They are separate
+  gates with separate decision rules, not one "verification gate."
+  (Pattern: `karpathy/autoresearch`, `uditgoenka/autoresearch`,
+  `supratikpm/gemini-autoresearch`.)
+- **Bounded capture and synthesis loops.** The capture state machine gets a
+  `maxScrollRetries` cap and a wall-clock ceiling alongside `topProof.stableIterations`.
+  Rolling synthesis gets a max-iteration count and stop criteria. No loop may run
+  unbounded. (Pattern: every autoresearch-family repo.)
+- **Deterministic session teardown.** Destroy `LanguageModel` sessions on
+  generation completion *and* on service-worker suspend, tab close, and extension
+  disable/update. Record in the manifest whether an artifact's generating session
+  ended cleanly or was killed mid-flight. (Pattern: the unenforced `destroy()`
+  gap in `Mazen-Embaby/gogo-va-extension`.)
+- **Verifier-backend provenance.** The manifest records both the backend that
+  *generated* an artifact and the backend (if any) that *verified* it. A summary
+  verified by the same model that produced it is not a passed gate; it lowers
+  recorded confidence. (Pattern: `wanshuiyin/Auto-claude-code-research-in-sleep`
+  different-family reviewer requirement.)
+- **Provenance backlink on compiled layers.** Every `concepts/` and `skills/`
+  entry carries `source_session` id + `raw_chunk_hash` pointers rather than
+  assuming reference sidecars suffice at every tier. This closes the tension
+  between distilled paraphrase and "never trust the model's re-quoting."
+  (Pattern: `pzqpzq/Principia`, `wanshuiyin/Anti-Autoresearch`.)
+- **Mechanical anti-skimming density floor.** `summary.md` and `handoff.md` must
+  pass a minimum-specificity score (quote count, file/URL/error-token count)
+  before a synthesis pass is accepted. A second gap-filling pass may not be used
+  to reach the floor. This is the operational form of the core thesis applied to
+  the one step where the spec currently trusts the weak model unsupervised.
+  (Pattern: `REMvisual/claude-handoff` baseline-then-gap-fill enforcement.)
+- **Branch/DAG-aware transcript.** `transcript.jsonl` carries `parent_message_id`
+  per entry so regenerate/edit branches are preserved, not flattened by the
+  linear scroll-to-top model. Prefer a canonical provider-graph/API snapshot as
+  the primary capture path (it yields the DAG and sidesteps virtualization); use
+  DOM scroll-to-top as the fallback. Rejected branches are retained as the
+  high-value "failed approaches" record. (Pattern: `zhaoliangbin42/AI-MarkDone`,
+  `daugaard47/ChatGPT_Conversations_To_Markdown`.)
+
+Deferred to future notes (not first slice): full cross-model independent-review
+gate before promotion; claim-level span-anchored ledger; MCP inversion (exposing
+the wiki as an MCP server); session chain-continuity metadata distinct from topic
+rollup; an archive/retirement absorbing state; and multi-writer concurrency with
+handoff revision/lock semantics.
+
+## Out of Scope
+
+- Building a full autonomous research agent inside the extension.
+- Replacing GitHub with a full database-backed sync service.
+- Requiring a hosted server for users who only want local or BYOK exports.
+- Making `Summarizer` the primary handoff generator.
+- Implementing a complete vector database in the first version.
+- Automatically publishing private transcripts without explicit user setup.
+- Guaranteeing perfect extraction from every future AI website redesign.
+- Solving all iframe and shadow DOM extraction issues in the first pass.
+- Auto-generating public skills from private transcripts without review.
+- Treating WebMCP as a replacement for MCP or the extension pipeline.
+
+## Further Notes
+
+The RevivalStack AI Chat Exporter reference is useful for export formatting,
+multi-provider support, Markdown/JSON output, metadata, table of contents, and
+platform-specific selector maintenance. Continue it should borrow the export
+discipline, but not become a Tampermonkey script; it should keep the stronger
+MV3 extension architecture and existing handoff workflow.
+
+## Source Incorporation Matrix
+
+| Source | Incorporated lesson | Concrete design implication |
+| --- | --- | --- |
+| `nico-martin/gemma4-browser-extension` | Browser agents should expose tab, page/RAG, and history tools as explicit capabilities. | Model capture as internal tools: tab inventory, page extraction, scroll/load, reference extraction, and history lookup. |
+| `mrauter1/GitPreProcess` | AI-friendly repos need manifests with classification, summaries, relevancy, hashes, and token counts. | Add `manifest.json` per session and aggregate manifests under `meta/` for delta processing and routing. |
+| `atjsh/llmlingua-2-js` | Weak models benefit from lossy-but-faithful compression before expensive passes. | Add a compression stage for long transcripts before Chrome AI summaries, while preserving immutable raw transcripts. |
+| `kiro0x/five-mcp` | Agents need external goal/state anchors when context windows expire. | Keep `handoff.md`, active goal fields, progress state, and resume instructions in every session folder. |
+| `ulyssestenn/funes` | Git-backed knowledge work should separate raw sources, compiled wiki, outputs, metadata, changelog, and health. | Use `raw/`, `sessions/`, `projects/`, `concepts/`, `skills/`, and `meta/health/` as separate layers. |
+| `adam-s/cordyceps` | Extension/DOM automation can be robust without CDP if it uses snapshot-style extraction and frame-aware locators. | Keep capture in MV3 content scripts and DOM APIs; plan for frame and shadow DOM extraction rather than DevTools-only capture. |
+| `yamadashy/repomix` | Repositories can be packed into single AI-friendly context artifacts. | Add future `packs/` or generated context bundles for session folders, projects, and selected concept clusters. |
+| `coderamp-labs/gitingest` | Repo ingestion should classify, filter, and package source trees for downstream agents. | Make exports both human-readable folders and machine-ingestable bundles. |
+| `leonhartX/gas-github` | Browser Git integration needs explicit provider auth, repo binding, and safe commit operations. | Keep GitHub sync optional, auditable, and separate from transcript capture; never store tokens in exported artifacts. |
+| `natsu1211/deepwiki-skill` | Codebase/wiki generation works best with comprehensive, structured Markdown pages. | Generate wiki-style summaries with frontmatter, headings, source links, tags, and cross-links. |
+| `saharmor/sidekick-dev-web` | Agent context files should be generated automatically and optimized for downstream coding agents. | Treat summary, handoff, manifest, and context pack files as first-class generated artifacts. |
+| `REMvisual/claude-handoff` | Handoff documents preserve continuity better than raw chat tails. | Maintain continuously updated `handoff.md` with goal, state, blockers, next actions, and verification notes. |
+| Chrome WebMCP compare doc | MCP and WebMCP solve different layers: backend persistent tools vs live page affordances. | Use MCP for DeepWiki/GitHub/repo services and consider WebMCP for live browser page interaction, not as a replacement pipeline. |
+| `wanshuiyin/Auto-claude-code-research-in-sleep` | Long-running agents resume from structured pipeline status and project wiki files. | Store session status and project-level rolling handoff in files that are re-read at the start of each pass. |
+| `karpathy/autoresearch` | Small agents improve through repeated bounded research loops with written state. | Add rolling analysis queues that work from saved files and stop at explicit criteria. |
+| `gaasher/Agent-Loop-Skills` | Loops need a skill/program, artifact slot, feedback signal, run ledger, and termination condition. | Define each synthesis job with inputs, outputs, checks, log entry, and stop condition. |
+| `jmilinovich/goal-md` | Goal files, fitness checks, iteration logs, and keep/revert decisions limit drift. | Add `goal.md` or manifest goal fields plus `iterations.jsonl` for rolling synthesis. |
+| `pzqpzq/Principia` | Research systems need staged evidence, critique, evolution, selection, and portable packs. | Require references to be source-linked and use staged synthesis before promoting concept pages. |
+| `davidondrej/jailbreak-autoresearch` | A fixed rubric and success signal make iterative experiments comparable. | Let summary/synthesis jobs include rubrics such as completeness, provenance, link health, and user-approved usefulness. |
+| `greyhaven-ai/autocontext` | Agents improve by curating durable playbooks and lessons from prior runs. | Periodically distill repeated successful session patterns into `skills/` and project playbooks. |
+| `uditgoenka/autoresearch` | Atomic change, commit, verify, decide, log, repeat is a durable weak-agent loop. | Make rolling analysis atomic: one session or one synthesis target per iteration, with logs and rollback. |
+| `wanshuiyin/Anti-Autoresearch` | Loop systems need explicit failure-mode awareness and anti-patterns. | Add guardrails against infinite loops, fabricated progress, overcompression, and unreviewed publication. |
+| `Rescenix/ResceneAgent` | Local audit trails and rollback protect agent-written files. | Keep append-only changelogs and file hashes so generated wiki changes can be reviewed or reverted. |
+| `supratikpm/gemini-autoresearch` | Dual-gate loops keep only changes that improve the target and pass guards. | Require both content-quality checks and repository-health checks before promoting rolling synthesis. |
+| `revivalstack/ai-chat-exporter` | A mature chat exporter needs rich frontmatter, export metadata, table of contents, platform-specific selectors, customizable filenames, and scroll-to-load handling. | Use RevivalStack-style metadata and organization, but implement it as MV3 extension artifacts rather than a userscript-only export. |
+| `Mazen-Embaby/gogo-va-extension` | Chrome AI extensions can combine side panel UI, background message routing, content scripts, local conversation storage, and availability checks across Prompt, Summarizer, Translator, Writer, and Rewriter style APIs. | Define explicit session/message types, store drafts in `chrome.storage.local`, and expose model-ready/download/unavailable states before generating summaries. |
+| `itamaker/go-chrome-ai` | Chrome AI feature availability can depend on local flags, region, model download policy, and OS-managed policy state. | Do not try to modify Chrome state from the extension; instead surface clear diagnostics and setup guidance when built-in AI is unavailable. |
+| `donpark/chrome-ai-tools` | MV3 service workers are fragile for persistent local bridges; offscreen documents or extension pages are better for long-lived connections and model/tool routing. | Put local Git bridge or long synthesis streams behind an offscreen/extension-page design, with dynamic tokens and origin checks. |
+| `7Xme/chrome-ai-learning-assistant` | Chrome AI apps can compose Prompt, Summarizer, Translator, Writer, Rewriter, and Proofreader APIs for different task shapes. | Route by task: `LanguageModel` for handoffs, summarizer for quick preview cards, writer/rewriter/proofreader for future cleanup passes if available. |
+| `moerasermax/Tools_ForSharing` | Page context capture works best as layered extraction: user selection, readability extraction, then raw DOM fallback. | Use layered capture for references and produced page context rather than relying only on chat message selectors. |
+| `matoliva/grammar-ai` | DeepWiki did not find Chrome extension or Chrome built-in AI implementation details; it appears to be a Next.js app rather than an MV3 extension. | Treat as low-relevance for extension architecture; at most borrow general grammar/writing UX ideas after separate inspection. |
+| `V-Gutierrez/browser-llm-lab` | Browser LLM labs show backend switching, availability guardrails, download progress, params inspection, streaming, JSON-mode experiments, and explicit session destruction. | Record backend, params, availability, prompt mode, and guardrail status in manifests; destroy sessions after generation. |
+| `oliuntangled/webmcp-gen` | WebMCP benefits from generated typed tool definitions, schemas, annotations, compatibility shims, and human-in-the-loop security. | Future WebMCP export tools should be generated from typed schemas and mark mutating actions as confirmation-required. |
+| `airwomandivanbed693/gemini-nano-chrome` | Simple MV3 Gemini Nano examples separate popup UI from background AI orchestration and stream chunks back over extension messaging. | Keep UI responsive by routing generation through background/offscreen logic and sending progress events without persisting partial output as final. |
+| `kirillpolevoy/relai` | Local AI chat transfer tools benefit from IndexedDB persistence, JSON backup/restore, vanilla MV3 architecture, and platform-specific extractors. | Consider IndexedDB for larger local staging; keep JSON backup/restore separate from GitHub sync. |
+| `FdezRomero/chatgpt-exporter` | Robust chat backup uses both per-conversation JSON and Markdown, a top-level `metadata.json`, an `index.json`, incremental mode, attachment handling, retries, and unavailable-file tracking. | Add top-level export metadata and incremental processing state; preserve attachments with relative paths and retry/unavailable markers. |
+| `Edmon02/bookmark-ai-organizer` | Bookmark organization should generate folder/tag suggestions while keeping state local and surfacing AI-provider dependence. | Treat bookmark/reference categorization as suggested metadata, not automatic truth; store provider and confidence. |
+| `Superkikim/nexus-ai-chat-importer` | Importers need provider adapters, a standardized conversation model, attachment handling, smart dedupe, selective import, detailed reports, and Obsidian-friendly Markdown. | Define `StandardConversation`-style normalized session objects and write import reports beside exported artifacts. |
+| `daugaard47/ChatGPT_Conversations_To_Markdown` | Chat exports should support local browser or script conversion, YAML frontmatter, folder-per-conversation organization, multimodal attachments, and alternate date/category layouts. | Keep Markdown exports frontmatter-rich and attachment-aware; allow alternate repo views without moving immutable raw files. |
+| `Lling0000/SiftMarks` | Local-first bookmark knowledge can use SQLite, CLI/web/extension/MCP entry points, AI summaries/tags/embeddings, FTS plus vector hybrid search, and review-first cleanup suggestions. | Future knowledge repo tooling can expose an MCP/search layer and use hybrid search over sessions, bookmarks, and references. |
+| `Vineetpandey0/Context-Sync` | Cross-platform AI chat transfer works best with a normalized capsule schema, local storage, searchable saved conversations, and optional compression that preserves code blocks. | Add a capsule-style session interchange format and compression rules that keep code/tool output verbatim. |
+| `rathi-yash/MindVault-AI-Bookmarker` | Bookmark clustering can extract title/description/domain metadata, embed content, cluster with semantic similarity, and label groups while allowing user correction. | Add reference metadata fields and optional local categorization/tagging with user-editable labels. |
+| `andrewjtyo-glitch/context-anchor` | Long chats can be stabilized with approved state anchors containing persona, current state, key artifacts, constraints, and next action, then rebooted into new sessions. | Make `handoff.md` an anchor-derived artifact and store approved anchor history with ids and timestamps. |
+| `LumenHelixLab/promptPACK` | Local-first prompt compression should be objective-aware and preserve must-keep facts while producing structured handoff packets. | Add prompt-pack artifacts under `produced/` and record preserved facts plus source chunk ids in the manifest. |
+| `redzumi/ai-ai-bookmarks` | AI bookmark refactors should be virtual-first, approval-gated, backup-first, and provider-abstracted through tool-calling agents. | Require preview/approval and backup before applying structural wiki/bookmark reorganizations. |
+| `ooye-sanket/Deja-vu` | Local semantic bookmark search can run in an MV3 background worker using a small local embeddings model, structured bookmark metadata, tags, and similarity ranking. | Support local semantic search indexes over references without making network calls by default. |
+| `kyruntime/bookmark-organizer` | Bookmark agents need full-tree capture, parent-child JSON, domain clustering, URL normalization, propose-confirm-execute flow, backups, and rollback. | Use explicit tree schemas for bookmark/reference hierarchies and require backups before destructive reorganizations. |
+| `ndg8743/TabBrain` | Tab/bookmark AI tools benefit from side panel UI, strict message contracts, typed domain objects, duplicate detection, window/topic metadata, retrying batch processors, and messy JSON parsers. | Add typed `TabInfo`/`ReferenceInfo` artifacts, duplicate checks, prompt builders, and robust AI response parsing. |
+| `khoj-ai/khoj` | Personal AI assistants should save retrieved context, support offline/online modes, export conversations, and use Git-like traces for query/response/system-prompt provenance. | For RAG-backed summaries, save retrieved notes/references and system prompts alongside the generated answer. |
+| `zhaoliangbin42/AI-MarkDone` | High-quality ChatGPT tooling uses canonical snapshots from provider graphs, background-as-write-authority, versioned runtime messages, immutable semantic models, source-bound annotations, and safe restore previews. | Add a canonical snapshot layer, versioned message protocol, annotation sidecars, and preview-before-restore semantics. |
+
+The likely first implementation slice is (reordered so capture-completeness, the
+foundational correctness property, comes first β€” everything downstream is
+worthless if capture silently drops the beginning):
+
+1. Strengthen capture: bounded scroll-to-top state machine with `topProof`,
+   `maxScrollRetries`, and a wall-clock ceiling; branch/DAG-aware transcript with
+   `parent_message_id`; a hard "capture incomplete" marker propagated into the
+   manifest, summary, and handoff when top-proof is absent.
+2. Add a session artifact builder that emits JSONL, XML, summary Markdown,
+   handoff Markdown, and manifest objects from captures whether complete or
+   incomplete. Incomplete captures must carry the hard warning into every
+   generated artifact. Promotion to rolling synthesis, not local artifact
+   creation, requires `verified-complete`.
+3. Apply the Verify/Guard split and the anti-skimming density floor to generated
+   summaries and handoffs; record verifier-backend provenance in the manifest.
+4. Add export/download UI for a zipped session directory, plus repo layout
+   documentation and fixtures.
+5. Add optional GitHub sync after local artifact export is stable β€” with a
+   secret-redaction/scan pass over every file selected for sync, including
+   transcripts, summaries, handoffs, manifests, produced documents, references,
+   attachments, and generated packs. Block the entire commit if any artifact
+   fails the gate.
+
+The main risk is silent incompleteness. The extension should prefer an explicit
+"capture incomplete" warning over a polished but partial summary. See
+`docs/Plans/2026-08-11-source-resweep-review.md` for the full second-pass audit
+and the Second-Pass Requirements section above for concrete acceptance criteria.
diff --git a/docs/index.jsonl b/docs/index.jsonl
new file mode 100644
index 0000000..1d782c2
--- /dev/null
+++ b/docs/index.jsonl
@@ -0,0 +1 @@
+{"path":"docs/Plans","title":"Plans","type":"plans","added_by":"Codex","added_at":"2026-08-11","summary":"Repo-local plans and implementation specs for Continue it."}
diff --git a/manifest.json b/manifest.json
index 3ca5a24..7a38ecd 100644
--- a/manifest.json
+++ b/manifest.json
@@ -19,17 +19,19 @@
     "https://www.perplexity.ai/*"
   ],
   "icons": {
-    "16": "assets/icon.png",
-    "48": "assets/icon.png",
-    "128": "assets/icon.png"
+    "16": "assets/icon-16.png",
+    "32": "assets/icon-32.png",
+    "48": "assets/icon-48.png",
+    "128": "assets/icon-128.png"
   },
   "action": {
     "default_title": "Continue it",
     "default_popup": "popup.html",
     "default_icon": {
-      "16": "assets/icon.png",
-      "48": "assets/icon.png",
-      "128": "assets/icon.png"
+      "16": "assets/icon-16.png",
+      "32": "assets/icon-32.png",
+      "48": "assets/icon-48.png",
+      "128": "assets/icon-128.png"
     }
   },
   "content_scripts": [
@@ -48,4 +50,4 @@
       "run_at": "document_idle"
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/popup.css b/popup.css
index b5959cf..8074b2b 100644
--- a/popup.css
+++ b/popup.css
@@ -259,6 +259,13 @@ button.danger:disabled {
   line-height: 1.4;
 }
 
+.test-status.pending {
+  background: #eef2ff;
+  color: #3730a3;
+  border: 1px solid #c7d2fe;
+  font-variant-numeric: tabular-nums;
+}
+
 .test-status.ok {
   background: #dcfce7;
   color: #166534;
diff --git a/popup.html b/popup.html
index ac2a366..a80e952 100644
--- a/popup.html
+++ b/popup.html
@@ -61,11 +61,21 @@ <h2>AI summary mode</h2>
         <p class="field-label">Choose how the export summary is generated.</p>
         <div class="radio-group">
           <label class="radio-option"><input type="radio" name="aiMode" value="none" /> <span><strong>No AI</strong> β€” local, free, private, works offline</span></label>
-          <label class="radio-option"><input type="radio" name="aiMode" value="server" /> <span><strong>Server AI</strong> β€” better, 5 free exports / 24h</span></label>
+          <label class="radio-option"><input type="radio" name="aiMode" value="builtin" /> <span><strong>Chrome built-in AI</strong> β€” on-device, no key</span></label>
+          <label class="radio-option"><input type="radio" name="aiMode" value="server" /> <span><strong>Server AI</strong> β€” use a configured backend</span></label>
           <label class="radio-option"><input type="radio" name="aiMode" value="byok" /> <span><strong>Custom API key</strong> β€” unlimited, free providers</span></label>
         </div>
 
+        <div id="builtinSettings" class="subpanel" hidden>
+          <p class="hint">Uses Chrome's built-in Gemini Nano model when it is available on this device. No API key or backend is used.</p>
+          <div class="inline-row">
+            <button id="testBuiltIn" type="button" class="secondary">Test Chrome AI</button>
+          </div>
+        </div>
+
         <div id="serverSettings" class="subpanel" hidden>
+          <label class="field-label" for="serverUrl">Server URL</label>
+          <input id="serverUrl" type="text" placeholder="http://localhost:8787" />
           <p id="quotaInfo" class="hint"></p>
         </div>
 
@@ -77,15 +87,15 @@ <h2>AI summary mode</h2>
           <label class="field-label" for="byokBaseUrl">Base URL</label>
           <input id="byokBaseUrl" type="text" placeholder="https://openrouter.ai/api/v1" />
           <label class="field-label" for="byokModel">Model</label>
-          <input id="byokModel" type="text" placeholder="meta-llama/llama-3.3-70b-instruct:free" />
+          <input id="byokModel" type="text" placeholder="google/gemma-4-26b-a4b-it:free" />
           <label class="field-label" for="byokApiKey">API key</label>
           <input id="byokApiKey" type="password" placeholder="Paste your API key" autocomplete="off" />
-          <p id="testStatus" class="test-status" hidden></p>
           <div class="inline-row">
             <button id="testAi" type="button" class="secondary">Test connection</button>
           </div>
         </div>
 
+        <p id="testStatus" class="test-status" hidden></p>
         <button id="saveAi" type="button">Save AI settings</button>
         <p id="saveAiFeedback" class="save-feedback" aria-live="polite"></p>
       </section>
@@ -114,4 +124,4 @@ <h2>Recommended prompt preview</h2>
     <script src="shared-ai.js"></script>
     <script src="popup.js"></script>
   </body>
-</html>
\ No newline at end of file
+</html>
diff --git a/popup.js b/popup.js
index 6dd0ecd..2d5b6ee 100644
--- a/popup.js
+++ b/popup.js
@@ -11,7 +11,9 @@ const chunkProgressEl = document.getElementById("chunkProgress");
 const summaryModeEl = document.getElementById("summaryMode");
 
 const aiModeRadios = Array.from(document.querySelectorAll('input[name="aiMode"]'));
+const builtinSettingsEl = document.getElementById("builtinSettings");
 const serverSettingsEl = document.getElementById("serverSettings");
+const serverUrlEl = document.getElementById("serverUrl");
 const quotaInfoEl = document.getElementById("quotaInfo");
 const byokSettingsEl = document.getElementById("byokSettings");
 const providerSelectEl = document.getElementById("providerSelect");
@@ -21,6 +23,7 @@ const byokBaseUrlEl = document.getElementById("byokBaseUrl");
 const byokModelEl = document.getElementById("byokModel");
 const byokApiKeyEl = document.getElementById("byokApiKey");
 const testStatusEl = document.getElementById("testStatus");
+const testBuiltInButton = document.getElementById("testBuiltIn");
 const testAiButton = document.getElementById("testAi");
 const saveAiButton = document.getElementById("saveAi");
 
@@ -56,13 +59,14 @@ function selectedAiMode() {
 
 function updateModeVisibility() {
   const mode = selectedAiMode();
+  builtinSettingsEl.hidden = mode !== ai.AI_MODES.builtin;
   serverSettingsEl.hidden = mode !== ai.AI_MODES.server;
   byokSettingsEl.hidden = mode !== ai.AI_MODES.byok;
 }
 
 function renderQuota(quota) {
   if (!quota) {
-    quotaInfoEl.textContent = "You get 5 free server summaries every 24 hours.";
+    quotaInfoEl.textContent = "Use a hosted backend or run your own local backend with a provider key in .env.";
     quotaInfoEl.className = "hint";
     return;
   }
@@ -71,6 +75,64 @@ function renderQuota(quota) {
   quotaInfoEl.className = quota.remaining <= 0 ? "hint quota-empty" : quota.remaining <= 1 ? "hint quota-low" : "hint";
 }
 
+function describeBuiltInStatus(status) {
+  if (!status || status.provider !== ai.AI_MODES.builtin) {
+    return null;
+  }
+  if (status.state === "downloading") {
+    return status.percent === null || status.percent === undefined
+      ? "Downloading Chrome built-in AI model..."
+      : `Downloading Chrome built-in AI model: ${status.percent}%.`;
+  }
+  if (status.state === "checking") {
+    return "Checking Chrome built-in AI availability...";
+  }
+  if (status.state === "preparing") {
+    return "Preparing Chrome built-in AI model...";
+  }
+  if (status.state === "ready") {
+    return "Chrome built-in AI is ready.";
+  }
+  if (status.state === "expired") {
+    return status.error || "Chrome built-in AI prewarm expired before it was used.";
+  }
+  if (status.state === "error") {
+    return `Chrome built-in AI is not ready: ${status.error || "unknown error"}`;
+  }
+  return null;
+}
+
+function statusClassForBuiltInStatus(status) {
+  if (!status) {
+    return "";
+  }
+  if (status.state === "ready") {
+    return "ok";
+  }
+  if (status.state === "error" || status.state === "expired") {
+    return "error";
+  }
+  return "pending";
+}
+
+function renderBuiltInStatus(status) {
+  if (selectedAiMode() !== ai.AI_MODES.builtin) {
+    return;
+  }
+  const message = describeBuiltInStatus(status);
+  if (!message) {
+    return;
+  }
+  const state = statusClassForBuiltInStatus(status);
+  if (state === "pending") {
+    testStatusEl.textContent = message;
+    testStatusEl.className = "test-status pending";
+    testStatusEl.hidden = false;
+    return;
+  }
+  setTestStatus(message, state);
+}
+
 function populateProviders(selectedId) {
   providerSelectEl.innerHTML = "";
   ai.PROVIDER_PRESETS.forEach((preset) => {
@@ -105,6 +167,7 @@ async function loadAiSettings() {
   aiModeRadios.forEach((radio) => {
     radio.checked = radio.value === settings.mode;
   });
+  serverUrlEl.value = settings.serverUrl || ai.DEFAULT_SERVER_URL;
   populateProviders(settings.byok.provider);
   byokBaseUrlEl.value = settings.byok.baseUrl || "";
   byokModelEl.value = settings.byok.model || "";
@@ -116,6 +179,9 @@ async function loadAiSettings() {
   }
   updateModeVisibility();
   renderQuota(settings.mode === ai.AI_MODES.server ? settings.lastQuota : null);
+  if (settings.mode === ai.AI_MODES.builtin) {
+    renderBuiltInStatus(await ai.getBuiltInStatus());
+  }
 }
 
 async function renderPopup() {
@@ -178,6 +244,8 @@ aiModeRadios.forEach((radio) => {
     await ai.saveSettings({ mode: selectedAiMode() });
     if (selectedAiMode() === ai.AI_MODES.none) {
       setStatus("Using local summaries (no AI).");
+    } else if (selectedAiMode() === ai.AI_MODES.builtin) {
+      setStatus("Using Chrome built-in AI when available on this device.");
     } else {
       setStatus('Fill in the details below, then click "Save AI settings".');
     }
@@ -209,12 +277,34 @@ saveAiButton.addEventListener("click", async () => {
     return;
   }
 
-  if (mode === ai.AI_MODES.server) {
+  if (mode === ai.AI_MODES.builtin) {
     await ai.saveSettings({ mode });
-    const granted = await ai.requestOriginPermission(ai.DEFAULT_SERVER_URL);
+    showSaveFeedback("βœ“ Saved β€” preparing Chrome built-in AI.");
+    startTestStatus("Preparing Chrome built-in AI");
+    const result = await ai.prewarmBuiltInModel();
+    stopTestTicker();
+    setTestStatus(
+      result && result.ok
+        ? "Chrome built-in AI is ready."
+        : `Chrome built-in AI is not ready: ${result ? result.error : "no response"}`,
+      result && result.ok ? "ok" : "error"
+    );
+    showSaveFeedback(
+      result && result.ok
+        ? "βœ“ Saved β€” Chrome built-in AI is ready."
+        : `⚠ Saved, but Chrome built-in AI is not ready: ${result ? result.error : "no response"}`,
+      result && result.ok ? "success" : "warning"
+    );
+    return;
+  }
+
+  if (mode === ai.AI_MODES.server) {
+    const serverUrl = serverUrlEl.value.trim() || ai.DEFAULT_SERVER_URL;
+    await ai.saveSettings({ mode, serverUrl });
+    const granted = await ai.requestOriginPermission(serverUrl);
     showSaveFeedback(
       granted
-        ? "βœ“ Saved β€” Server AI enabled. 5 free exports every 24 hours."
+        ? "βœ“ Saved β€” Server AI enabled for the configured backend."
         : "⚠ Saved, but server permission not granted β€” allow it to use AI summaries.",
       granted ? "success" : "warning"
     );
@@ -239,33 +329,89 @@ saveAiButton.addEventListener("click", async () => {
   );
 });
 
+let testTicker = null;
+
+function stopTestTicker() {
+  if (testTicker) {
+    clearInterval(testTicker);
+    testTicker = null;
+  }
+  testBuiltInButton.disabled = false;
+  testAiButton.disabled = false;
+}
+
 function setTestStatus(message, state) {
+  stopTestTicker();
   testStatusEl.textContent = message;
   testStatusEl.className = "test-status" + (state ? ` ${state}` : "");
   testStatusEl.hidden = !message;
 }
 
+// A model round trip can take a while. Animated ellipses plus an elapsed second
+// counter are what tell the user the popup is waiting rather than wedged.
+function startTestStatus(message) {
+  stopTestTicker();
+  const startedAt = Date.now();
+  testBuiltInButton.disabled = true;
+  testAiButton.disabled = true;
+  testStatusEl.className = "test-status pending";
+  testStatusEl.hidden = false;
+
+  function paint() {
+    const elapsedMs = Date.now() - startedAt;
+    const dots = ".".repeat(1 + (Math.floor(elapsedMs / 400) % 3));
+    const seconds = Math.round(elapsedMs / 1000);
+    testStatusEl.textContent = `${message}${dots}${seconds >= 2 ? ` (${seconds}s)` : ""}`;
+  }
+
+  paint();
+  testTicker = setInterval(paint, 400);
+}
+
+testBuiltInButton.addEventListener("click", async () => {
+  try {
+    await ai.saveSettings({ mode: ai.AI_MODES.builtin });
+    startTestStatus("Testing Chrome built-in AI");
+    const result = await ai.testConnection();
+    if (result && result.ok) {
+      setTestStatus("Chrome built-in AI is ready.", "ok");
+    } else {
+      setTestStatus(`Test failed: ${result ? result.error : "no response"}`, "error");
+    }
+  } catch (error) {
+    setTestStatus(`Test failed: ${error?.message || String(error)}`, "error");
+  }
+});
+
+window.addEventListener("continueIt:builtinStatus", (event) => {
+  renderBuiltInStatus(event.detail || null);
+});
+
 testAiButton.addEventListener("click", async () => {
-  const mode = selectedAiMode();
-  if (mode === ai.AI_MODES.byok) {
-    const baseUrl = byokBaseUrlEl.value.trim();
-    if (!baseUrl || !byokModelEl.value.trim() || !byokApiKeyEl.value.trim()) {
-      setTestStatus("Enter a base URL, model, and API key first.", "error");
-      return;
+  try {
+    const mode = selectedAiMode();
+    if (mode === ai.AI_MODES.byok) {
+      const baseUrl = byokBaseUrlEl.value.trim();
+      if (!baseUrl || !byokModelEl.value.trim() || !byokApiKeyEl.value.trim()) {
+        setTestStatus("Enter a base URL, model, and API key first.", "error");
+        return;
+      }
+      await persistByok();
+      const granted = await ai.requestOriginPermission(baseUrl);
+      if (!granted) {
+        setTestStatus(`Access to ${baseUrl} was not granted. Allow it to test.`, "error");
+        return;
+      }
     }
-    await persistByok();
-    const granted = await ai.requestOriginPermission(baseUrl);
-    if (!granted) {
-      setTestStatus(`Access to ${baseUrl} was not granted. Allow it to test.`, "error");
-      return;
+    startTestStatus("Testing connection");
+    const result = await ai.testConnection();
+    if (result && result.ok) {
+      setTestStatus("Connection works. AI summaries are ready.", "ok");
+    } else {
+      setTestStatus(`Test failed: ${result ? result.error : "no response"}`, "error");
     }
-  }
-  setTestStatus("Testing connection…", "");
-  const result = await ai.testConnection();
-  if (result && result.ok) {
-    setTestStatus("Connection works. AI summaries are ready.", "ok");
-  } else {
-    setTestStatus(`Test failed: ${result ? result.error : "no response"}`, "error");
+  } catch (error) {
+    setTestStatus(`Test failed: ${error?.message || String(error)}`, "error");
   }
 });
 
diff --git a/server/server.js b/server/server.js
index 3da19a7..d8c55f1 100644
--- a/server/server.js
+++ b/server/server.js
@@ -8,11 +8,19 @@ const PORT = Number(process.env.PORT || 8787);
 // --- Upstream provider (owner-funded "Server AI" tier) ----------------------
 // Preferred: any OpenAI-compatible provider via OPENAI_BASE_URL + OPENAI_API_KEY.
 // Falls back to the legacy Sarvam configuration if that is all that is set.
-const OPENAI_BASE_URL = (process.env.OPENAI_BASE_URL || "").replace(/\/$/, "");
-const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
-const OPENAI_MODEL = process.env.OPENAI_MODEL || "";
-const SARVAM_API_KEY = process.env.SARVAM_API_KEY || "";
-const SARVAM_MODEL = process.env.SARVAM_MODEL || "sarvam-30b";
+const OPENAI_BASE_URL = (process.env.OPENAI_BASE_URL || "").trim().replace(/\/$/, "");
+const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || "").trim();
+const OPENAI_MODEL = (process.env.OPENAI_MODEL || "").trim();
+const SARVAM_API_KEY = (process.env.SARVAM_API_KEY || "").trim();
+const SARVAM_MODEL = (process.env.SARVAM_MODEL || "sarvam-30b").trim();
+
+function configWarnings() {
+  const warnings = [];
+  if (OPENAI_BASE_URL.includes("openrouter.ai") && OPENAI_API_KEY && !OPENAI_API_KEY.startsWith("sk-or-")) {
+    warnings.push("OPENAI_BASE_URL points to OpenRouter, but OPENAI_API_KEY does not look like an OpenRouter key. Use an OpenRouter key from https://openrouter.ai/keys, or change OPENAI_BASE_URL/model to match your provider.");
+  }
+  return warnings;
+}
 
 // --- Free-tier quota --------------------------------------------------------
 const DAILY_LIMIT = Number(process.env.DAILY_LIMIT || 5);
@@ -78,6 +86,17 @@ app.set("trust proxy", true);
 app.use(cors({ exposedHeaders: ["x-continue-it-quota"] }));
 app.use(express.json({ limit: "2mb" }));
 
+app.get("/", (req, res) => {
+  res.type("text/plain").send("Continue It backend is running. Use GET /health or POST /api/summarize.\n");
+});
+
+// Chrome DevTools may probe this localhost path for automatic workspace setup.
+// This backend does not expose a browser workspace, so answer quietly instead of
+// letting Express generate a CSP-protected 404 page in local development.
+app.get("/.well-known/appspecific/com.chrome.devtools.json", (req, res) => {
+  res.status(204).end();
+});
+
 function clientKeys(req) {
   const clientId = String(req.header("x-continue-it-client") || "").slice(0, 128);
   const ip = req.ip || req.socket?.remoteAddress || "unknown";
@@ -135,6 +154,7 @@ app.get("/health", (req, res) => {
     configured: Boolean(provider),
     provider: provider?.name || null,
     model: provider?.model || null,
+    warnings: configWarnings(),
     dailyLimit: DAILY_LIMIT,
     windowHours: WINDOW_MS / (60 * 60 * 1000)
   });
@@ -147,8 +167,8 @@ app.post("/api/summarize", async (req, res) => {
     return;
   }
 
-  const { source, mode, heuristicSummary, compactConversation } = req.body || {};
-  if (!source || !mode || !heuristicSummary || !compactConversation) {
+  const { source, mode, compactConversation } = req.body || {};
+  if (!source || !mode || !compactConversation) {
     res.status(400).json({ ok: false, error: "Missing required summarize fields." });
     return;
   }
@@ -179,13 +199,15 @@ app.post("/api/summarize", async (req, res) => {
         model: provider.model,
         temperature: 0.2,
         max_tokens: maxTokens,
-        messages: buildMessages({ source, mode, heuristicSummary, compactConversation })
+        messages: buildMessages({ source, mode, compactConversation })
       })
     });
 
     if (!response.ok) {
       const text = await response.text();
-      res.status(502).json({ ok: false, error: `Upstream provider failed: ${response.status} ${text.slice(0, 400)}`, quota });
+      const warningText = configWarnings().join(" ");
+      const hint = response.status === 401 && warningText ? ` ${warningText}` : "";
+      res.status(502).json({ ok: false, error: `Upstream provider failed: ${response.status} ${text.slice(0, 400)}${hint}`, quota });
       return;
     }
 
diff --git a/shared-ai.js b/shared-ai.js
index 4b97870..15ad6b9 100644
--- a/shared-ai.js
+++ b/shared-ai.js
@@ -12,18 +12,21 @@
     byokModel: "continueIt.ai.byokModel",
     byokApiKey: "continueIt.ai.byokApiKey",
     clientId: "continueIt.clientId",
-    lastQuota: "continueIt.ai.lastQuota"
+    lastQuota: "continueIt.ai.lastQuota",
+    chromeBuiltInStatus: "continueIt.ai.builtinStatus"
   };
 
   // Provider mode for the AI summary. Not to be confused with the "summary detail
   // mode" (short/medium/detailed) used elsewhere.
   const AI_MODES = {
     none: "none", // Local heuristic only. Free, private, offline.
+    builtin: "builtin", // Chrome built-in Gemini Nano. Free, private, on-device when available.
     server: "server", // Shared backend. Rate limited to 5 exports / 24h.
     byok: "byok" // Bring your own OpenAI-compatible key. Unlimited.
   };
   const DEFAULT_MODE = AI_MODES.none;
   const DEFAULT_SERVER_URL = "http://localhost:8787";
+  let cachedSettings = null;
 
   // Curated list of free / no-card OpenAI-compatible providers. Base URLs and
   // limits verified 2026-07. Always double-check current limits on each site.
@@ -32,7 +35,7 @@
       id: "openrouter",
       name: "OpenRouter",
       baseUrl: "https://openrouter.ai/api/v1",
-      model: "meta-llama/llama-3.3-70b-instruct:free",
+      model: "google/gemma-4-26b-a4b-it:free",
       keyUrl: "https://openrouter.ai/keys",
       note: "20+ free models (pick one ending in :free). ~50 requests/day free, no card."
     },
@@ -127,12 +130,30 @@
     return PROVIDER_PRESETS.find((preset) => preset.id === id) || null;
   }
 
+  function emptySettings(mode = DEFAULT_MODE) {
+    return {
+      mode,
+      serverUrl: DEFAULT_SERVER_URL,
+      byok: {
+        provider: "openrouter",
+        baseUrl: "",
+        model: "",
+        apiKey: ""
+      },
+      lastQuota: null
+    };
+  }
+
+  function normalizeServerUrl(url) {
+    return (url || DEFAULT_SERVER_URL).trim().replace(/\/$/, "");
+  }
+
   async function getSettings() {
     const result = await getStorage(Object.values(STORAGE_KEYS));
     const mode = AI_MODES[result[STORAGE_KEYS.mode]] || DEFAULT_MODE;
-    return {
+    cachedSettings = {
       mode,
-      serverUrl: DEFAULT_SERVER_URL,
+      serverUrl: result[STORAGE_KEYS.serverUrl] || DEFAULT_SERVER_URL,
       byok: {
         provider: result[STORAGE_KEYS.byokProvider] || "openrouter",
         baseUrl: result[STORAGE_KEYS.byokBaseUrl] || "",
@@ -141,6 +162,7 @@
       },
       lastQuota: result[STORAGE_KEYS.lastQuota] || null
     };
+    return cachedSettings;
   }
 
   async function saveSettings(settings) {
@@ -148,6 +170,16 @@
     if (settings.mode !== undefined) {
       payload[STORAGE_KEYS.mode] = AI_MODES[settings.mode] || DEFAULT_MODE;
     }
+    if (settings.serverUrl !== undefined) {
+      const currentServerUrl = cachedSettings
+        ? cachedSettings.serverUrl
+        : (await getStorage([STORAGE_KEYS.serverUrl]))[STORAGE_KEYS.serverUrl] || DEFAULT_SERVER_URL;
+      const nextServerUrl = settings.serverUrl || DEFAULT_SERVER_URL;
+      payload[STORAGE_KEYS.serverUrl] = nextServerUrl;
+      if (normalizeServerUrl(nextServerUrl) !== normalizeServerUrl(currentServerUrl)) {
+        payload[STORAGE_KEYS.lastQuota] = null;
+      }
+    }
     if (settings.byok) {
       if (settings.byok.provider !== undefined) {
         payload[STORAGE_KEYS.byokProvider] = settings.byok.provider || "openrouter";
@@ -163,6 +195,7 @@
       }
     }
     await setStorage(payload);
+    cachedSettings = null;
   }
 
   async function saveLastQuota(quota) {
@@ -172,6 +205,11 @@
     await setStorage({ [STORAGE_KEYS.lastQuota]: quota });
   }
 
+  async function getBuiltInStatus() {
+    const result = await getStorage([STORAGE_KEYS.chromeBuiltInStatus]);
+    return result[STORAGE_KEYS.chromeBuiltInStatus] || null;
+  }
+
   function maxTokensForMode(mode) {
     return mode === "short" ? 800 : mode === "detailed" ? 2500 : 1500;
   }
@@ -249,7 +287,47 @@
     });
   }
 
+  if (chrome.storage && chrome.storage.onChanged) {
+    chrome.storage.onChanged.addListener((changes, areaName) => {
+      if (areaName !== "local") {
+        return;
+      }
+      if (Object.values(STORAGE_KEYS).some((key) => changes[key])) {
+        cachedSettings = null;
+      }
+      if (changes[STORAGE_KEYS.chromeBuiltInStatus] && typeof globalScope.dispatchEvent === "function") {
+        globalScope.dispatchEvent(new CustomEvent("continueIt:builtinStatus", {
+          detail: changes[STORAGE_KEYS.chromeBuiltInStatus].newValue || null
+        }));
+      }
+    });
+  }
+
+  if (chrome.runtime && chrome.runtime.onMessage) {
+    chrome.runtime.onMessage.addListener((message) => {
+      if (message?.type === "continueIt.builtinStatus" && typeof globalScope.dispatchEvent === "function") {
+        globalScope.dispatchEvent(new CustomEvent("continueIt:builtinStatus", {
+          detail: message.status || null
+        }));
+      }
+      return false;
+    });
+  }
+
   // Main entry point used during export. `mode` here is the summary detail level.
+  async function prewarmBuiltInModel() {
+    if (cachedSettings && cachedSettings.mode !== AI_MODES.builtin) {
+      return { ok: false, error: "Chrome built-in AI is not selected." };
+    }
+    if (!cachedSettings) {
+      const settings = await getSettings();
+      if (settings.mode !== AI_MODES.builtin) {
+        return { ok: false, error: "Chrome built-in AI is not selected." };
+      }
+    }
+    return sendToWorker({ type: "continueIt.prewarmBuiltIn", payload: { aiMode: AI_MODES.builtin } });
+  }
+
   async function summarizeConversation({ source, messages, mode, shared }) {
     const settings = await getSettings();
     if (settings.mode === AI_MODES.none) {
@@ -279,7 +357,8 @@
       used: true,
       summary: response && response.summary ? response.summary : null,
       error: response && response.error ? response.error : null,
-      quota: response ? response.quota || null : null
+      quota: response ? response.quota || null : null,
+      warnings: response ? response.warnings || [] : []
     };
   }
 
@@ -287,7 +366,7 @@
   async function testConnection() {
     const settings = await getSettings();
     if (settings.mode === AI_MODES.none) {
-      return { ok: false, error: "Select Server AI or your own API key first." };
+      return { ok: false, error: "Select Chrome built-in AI, Server AI, or your own API key first." };
     }
     const clientId = await getClientId();
     return sendToWorker({ type: "continueIt.test", payload: { aiMode: settings.mode, clientId } });
@@ -304,11 +383,13 @@
     getSettings,
     saveSettings,
     saveLastQuota,
+    getBuiltInStatus,
     buildCompactConversation,
     maxTokensForMode,
     originPatternFor,
     hasOriginPermission,
     requestOriginPermission,
+    prewarmBuiltInModel,
     summarizeConversation,
     testConnection
   };
diff --git a/shared-handoff.js b/shared-handoff.js
index c6889b7..987278f 100644
--- a/shared-handoff.js
+++ b/shared-handoff.js
@@ -535,6 +535,7 @@
     ].join("\n");
 
     const isAISummary = handoff.summarySource === "Server AI (backend API)" ||
+      handoff.summarySource === "Chrome built-in AI" ||
       handoff.summarySource === "Your own API key";
 
     const conciseSummary = isAISummary
@@ -851,4 +852,4 @@
     copyText,
     downloadTextFile
   };
-})();
\ No newline at end of file
+})();
diff --git a/shared-ui.js b/shared-ui.js
index 7babecb..12e8606 100644
--- a/shared-ui.js
+++ b/shared-ui.js
@@ -172,6 +172,34 @@
       }
       .continue-it-launcher {
         box-shadow: 0 8px 20px rgba(0,0,0,0.18);
+        position: relative;
+        overflow: hidden;
+      }
+      .continue-it-launcher-label {
+        position: relative;
+        z-index: 1;
+      }
+      /* Progress fill inside the launcher button itself, so the button doubles
+         as a progress bar while a long export is running. */
+      .continue-it-launcher.is-busy::before {
+        content: "";
+        position: absolute;
+        left: 0;
+        top: 0;
+        bottom: 0;
+        width: var(--ci-progress, 0%);
+        background: linear-gradient(90deg, #4f46e5, #8b5cf6);
+        transition: width 0.3s ease, background 0.3s ease;
+      }
+      .continue-it-launcher.is-busy {
+        background: #312e81;
+        color: #ffffff;
+      }
+      .continue-it-launcher.is-busy.is-success::before {
+        background: linear-gradient(90deg, #067647, #12b76a);
+      }
+      .continue-it-launcher.is-busy.is-error::before {
+        background: linear-gradient(90deg, #b42318, #f04438);
       }
       .continue-it-menu {
         position: absolute;
@@ -196,6 +224,201 @@
       .continue-it-menu button:last-child {
         margin-bottom: 0;
       }
+
+      /* --- Long-running work indicator ------------------------------------
+         Three cooperating layers, all pointer-events: none so the page stays
+         usable: a desaturating scrim, a progress "light" tracing the viewport
+         edge, and a status chip with the current phase and elapsed time. */
+      /* Host box only β€” kept out of the page flow so no site rule targeting
+         direct children of body can give it size or margins. */
+      .continue-it-progress {
+        position: fixed;
+        inset: 0;
+        z-index: 2147483644;
+        pointer-events: none;
+      }
+      .continue-it-progress-scrim {
+        position: fixed;
+        inset: 0;
+        z-index: 0;
+        pointer-events: none;
+        /* Flat colour on purpose: a backdrop-filter here would promote the whole
+           page to a composited layer while we are programmatically scrolling a
+           long transcript, which is exactly when we can least afford the jank. */
+        background: rgba(100, 116, 139, 0.22);
+        opacity: 0;
+        transition: opacity 0.25s ease;
+      }
+      .continue-it-progress-ring {
+        position: fixed;
+        inset: 0;
+        z-index: 1;
+        pointer-events: none;
+        opacity: 0;
+        transition: opacity 0.25s ease;
+      }
+      .continue-it-progress-ring svg {
+        display: block;
+        width: 100%;
+        height: 100%;
+      }
+      .ci-ring-track {
+        fill: none;
+        stroke: rgba(99, 102, 241, 0.22);
+        stroke-width: 3;
+      }
+      .ci-ring-beam {
+        fill: none;
+        stroke: #6366f1;
+        stroke-width: 3;
+        stroke-linecap: round;
+        filter: drop-shadow(0 0 6px rgba(99, 102, 241, 0.85));
+        transition: stroke-dasharray 0.35s ease, stroke 0.3s ease, opacity 0.2s ease;
+        animation: ci-beam-pulse 1.8s ease-in-out infinite;
+      }
+      .continue-it-progress.is-success .ci-ring-beam {
+        stroke: #12b76a;
+        filter: drop-shadow(0 0 6px rgba(18, 183, 106, 0.85));
+        animation: none;
+      }
+      .continue-it-progress.is-error .ci-ring-beam {
+        stroke: #f04438;
+        filter: drop-shadow(0 0 6px rgba(240, 68, 56, 0.85));
+        animation: none;
+      }
+      .continue-it-progress-chip {
+        position: fixed;
+        right: 20px;
+        bottom: 72px;
+        z-index: 2;
+        box-sizing: border-box;
+        width: 300px;
+        max-width: calc(100vw - 40px);
+        padding: 12px 14px;
+        border-radius: 12px;
+        border: 1px solid var(--ci-border);
+        background: var(--ci-bg);
+        color: var(--ci-text);
+        box-shadow: 0 16px 40px rgba(15, 23, 42, 0.24);
+        font: 13px/1.45 Arial, sans-serif;
+        pointer-events: none;
+        opacity: 0;
+        transform: translateY(10px);
+        transition: opacity 0.22s ease, transform 0.22s ease;
+      }
+      .ci-chip-live {
+        position: absolute;
+        width: 1px;
+        height: 1px;
+        margin: -1px;
+        padding: 0;
+        border: 0;
+        overflow: hidden;
+        clip: rect(0 0 0 0);
+        white-space: nowrap;
+      }
+      .continue-it-progress.is-visible .continue-it-progress-scrim,
+      .continue-it-progress.is-visible .continue-it-progress-ring,
+      .continue-it-progress.is-visible .continue-it-progress-chip {
+        opacity: 1;
+      }
+      .continue-it-progress.is-visible .continue-it-progress-chip {
+        transform: translateY(0);
+      }
+      .ci-chip-top {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+      .ci-chip-spinner {
+        flex: 0 0 auto;
+        width: 14px;
+        height: 14px;
+        border-radius: 50%;
+        border: 2px solid rgba(99, 102, 241, 0.28);
+        border-top-color: #6366f1;
+        animation: ci-spin 0.8s linear infinite;
+      }
+      .continue-it-progress.is-success .ci-chip-spinner,
+      .continue-it-progress.is-error .ci-chip-spinner {
+        border: 0;
+        animation: none;
+        font-size: 14px;
+        line-height: 14px;
+        text-align: center;
+      }
+      .continue-it-progress.is-success .ci-chip-spinner::before {
+        content: "βœ“";
+        color: #067647;
+        font-weight: 700;
+      }
+      .continue-it-progress.is-error .ci-chip-spinner::before {
+        content: "βœ•";
+        color: #b42318;
+        font-weight: 700;
+      }
+      .ci-chip-label {
+        font-weight: 600;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .ci-chip-elapsed {
+        margin-left: auto;
+        flex: 0 0 auto;
+        font-variant-numeric: tabular-nums;
+        color: var(--ci-muted);
+        font-size: 12px;
+      }
+      .ci-chip-bar {
+        height: 6px;
+        margin-top: 10px;
+        border-radius: 999px;
+        background: var(--ci-secondary);
+        overflow: hidden;
+      }
+      .ci-chip-bar-fill {
+        width: 0%;
+        height: 100%;
+        border-radius: 999px;
+        background: linear-gradient(90deg, #4f46e5, #8b5cf6);
+        transition: width 0.35s ease, background 0.3s ease;
+      }
+      .continue-it-progress.is-success .ci-chip-bar-fill {
+        background: linear-gradient(90deg, #067647, #12b76a);
+      }
+      .continue-it-progress.is-error .ci-chip-bar-fill {
+        background: linear-gradient(90deg, #b42318, #f04438);
+      }
+      .ci-chip-detail {
+        margin-top: 8px;
+        color: var(--ci-muted);
+        font-size: 12px;
+      }
+      .ci-chip-detail:empty {
+        display: none;
+      }
+      @keyframes ci-spin {
+        to { transform: rotate(360deg); }
+      }
+      @keyframes ci-beam-pulse {
+        0%, 100% { opacity: 1; }
+        50% { opacity: 0.65; }
+      }
+      @media (prefers-reduced-motion: reduce) {
+        .ci-chip-spinner,
+        .ci-ring-beam {
+          animation: none;
+        }
+        .continue-it-progress-scrim,
+        .continue-it-progress-ring,
+        .continue-it-progress-chip,
+        .ci-chip-bar-fill,
+        .ci-ring-beam,
+        .continue-it-launcher.is-busy::before {
+          transition: none;
+        }
+      }
     `;
     document.head.appendChild(style);
   }
@@ -227,9 +450,10 @@
     wrap.id = id;
     wrap.className = "continue-it-launcher-wrap";
     wrap.innerHTML = `
-      <button type="button" class="continue-it-launcher">${label}</button>
+      <button type="button" class="continue-it-launcher"><span class="continue-it-launcher-label">${label}</span></button>
       <div class="continue-it-menu"></div>
     `;
+    wrap.dataset.idleLabel = label;
 
     const button = wrap.querySelector(".continue-it-launcher");
     const menu = wrap.querySelector(".continue-it-menu");
@@ -329,10 +553,310 @@
     return { overlay, content, close };
   }
 
+  const PROGRESS_ID = "continue-it-progress";
+  const PROGRESS_TICK_MS = 400;
+  // Per-tick share of the remaining distance to crawl through when a phase has
+  // no measurable progress (an in-flight API call). Slow enough that a minute
+  // long request keeps visibly moving instead of parking at the phase end.
+  const CREEP_RATE = 0.02;
+
+  function clamp01(value) {
+    if (!Number.isFinite(value)) {
+      return 0;
+    }
+    return value < 0 ? 0 : value > 1 ? 1 : value;
+  }
+
+  function formatElapsed(ms) {
+    const totalSeconds = Math.round(ms / 1000);
+    if (totalSeconds < 100) {
+      return `${totalSeconds}s`;
+    }
+    return `${Math.floor(totalSeconds / 60)}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
+  }
+
+  // Turns the floating launcher button into its own progress bar. Kept separate
+  // from createProgress so a caller can drive the button on its own if needed.
+  function setLauncherBusy(launcherId, state) {
+    if (!launcherId) {
+      return;
+    }
+    const wrap = document.getElementById(launcherId);
+    const button = wrap && wrap.querySelector(".continue-it-launcher");
+    if (!button) {
+      return;
+    }
+    const labelEl = button.querySelector(".continue-it-launcher-label") || button;
+    if (!wrap.dataset.idleLabel) {
+      wrap.dataset.idleLabel = labelEl.textContent;
+    }
+
+    if (!state || state.busy === false) {
+      button.classList.remove("is-busy", "is-success", "is-error");
+      button.style.removeProperty("--ci-progress");
+      button.removeAttribute("aria-busy");
+      labelEl.textContent = wrap.dataset.idleLabel;
+      return;
+    }
+
+    button.classList.add("is-busy");
+    button.classList.toggle("is-success", state.tone === "success");
+    button.classList.toggle("is-error", state.tone === "error");
+    button.setAttribute("aria-busy", state.tone ? "false" : "true");
+    button.style.setProperty("--ci-progress", `${Math.round(clamp01(state.fraction) * 100)}%`);
+    if (state.label) {
+      labelEl.textContent = state.label;
+    }
+  }
+
+  let activeProgress = null;
+
+  /**
+   * Persistent progress indicator for work that can take a long time (DOM
+   * scanning plus an LLM round trip). Without it a slow API call is
+   * indistinguishable from a frozen extension.
+   *
+   * Phases map onto slices of one overall bar, so the caller never has to think
+   * about global percentages:
+   *
+   *   const progress = ui.createProgress({ launcherId, busyLabel: "Exporting" });
+   *   progress.phase({ label: "Scanning", from: 0, to: 0.55 });
+   *   progress.set(0.4, "120 messages found");
+   *   progress.phase({ label: "Summarizing", from: 0.55, to: 0.95, creep: true });
+   *   progress.succeed("Done");
+   */
+  function createProgress({ launcherId = null, busyLabel = "Working", label = "Working" } = {}) {
+    ensureUIStyles();
+
+    // Shut the previous one down properly β€” dropping the node alone would leave
+    // its tick interval running against detached elements.
+    if (activeProgress) {
+      activeProgress.close();
+    }
+    const orphan = document.getElementById(PROGRESS_ID);
+    if (orphan) {
+      orphan.remove();
+    }
+
+    const host = document.createElement("div");
+    host.id = PROGRESS_ID;
+    host.className = "continue-it-progress";
+    host.innerHTML = `
+      <div class="continue-it-progress-scrim"></div>
+      <div class="continue-it-progress-ring" aria-hidden="true">
+        <svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
+          <rect class="ci-ring-track" pathLength="100"></rect>
+          <rect class="ci-ring-beam" pathLength="100"></rect>
+        </svg>
+      </div>
+      <div class="continue-it-progress-chip">
+        <div class="ci-chip-top">
+          <span class="ci-chip-spinner" aria-hidden="true"></span>
+          <span class="ci-chip-label"></span>
+          <span class="ci-chip-elapsed"></span>
+        </div>
+        <div class="ci-chip-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
+          <div class="ci-chip-bar-fill"></div>
+        </div>
+        <div class="ci-chip-detail"></div>
+        <div class="ci-chip-live" role="status" aria-live="polite" aria-atomic="true"></div>
+      </div>
+    `;
+
+    const svg = host.querySelector(".continue-it-progress-ring svg");
+    const rings = Array.from(host.querySelectorAll(".ci-ring-track, .ci-ring-beam"));
+    const beam = host.querySelector(".ci-ring-beam");
+    const labelEl = host.querySelector(".ci-chip-label");
+    const elapsedEl = host.querySelector(".ci-chip-elapsed");
+    const detailEl = host.querySelector(".ci-chip-detail");
+    const liveEl = host.querySelector(".ci-chip-live");
+    const barEl = host.querySelector(".ci-chip-bar");
+    const barFillEl = host.querySelector(".ci-chip-bar-fill");
+
+    const state = {
+      label,
+      detail: "",
+      value: 0,
+      from: 0,
+      to: 1,
+      creep: false,
+      dots: 0,
+      phaseStartedAt: Date.now(),
+      slowHintAfter: 0,
+      slowHint: "",
+      terminal: false
+    };
+    let closed = false;
+    let terminalTimeout = null;
+
+    function sizeRing() {
+      const width = Math.max(window.innerWidth || 0, 1);
+      const height = Math.max(window.innerHeight || 0, 1);
+      svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+      rings.forEach((ring) => {
+        ring.setAttribute("x", "3");
+        ring.setAttribute("y", "3");
+        ring.setAttribute("width", String(Math.max(width - 6, 1)));
+        ring.setAttribute("height", String(Math.max(height - 6, 1)));
+        ring.setAttribute("rx", "10");
+      });
+    }
+
+    function render() {
+      const percent = clamp01(state.value) * 100;
+      beam.setAttribute("stroke-dasharray", `${percent.toFixed(2)} 100`);
+      beam.style.opacity = percent <= 0 ? "0" : "1";
+      barFillEl.style.width = `${percent.toFixed(1)}%`;
+      barEl.setAttribute("aria-valuenow", String(Math.round(percent)));
+
+      const elapsed = Date.now() - state.phaseStartedAt;
+      labelEl.textContent = state.terminal ? state.label : `${state.label}${".".repeat(state.dots)}`;
+      elapsedEl.textContent = !state.terminal && elapsed >= 2000 ? formatElapsed(elapsed) : "";
+
+      const showSlowHint = !state.terminal && state.slowHintAfter && elapsed >= state.slowHintAfter;
+      detailEl.textContent = showSlowHint ? state.slowHint : state.detail;
+
+      setLauncherBusy(launcherId, {
+        busy: true,
+        fraction: state.value,
+        label: state.terminal ? state.label : `${busyLabel}${".".repeat(state.dots)}`,
+        tone: state.terminal || null
+      });
+    }
+
+    function announce() {
+      liveEl.textContent = state.detail ? `${state.label}. ${state.detail}` : state.label;
+    }
+
+    function tick() {
+      if (state.terminal) {
+        return;
+      }
+      if (state.creep) {
+        state.value += (state.to - state.value) * CREEP_RATE;
+      }
+      state.dots = (state.dots + 1) % 4;
+      render();
+    }
+
+    let timer = setInterval(tick, PROGRESS_TICK_MS);
+    window.addEventListener("resize", sizeRing);
+
+    document.body.appendChild(host);
+    sizeRing();
+    render();
+    announce();
+    // Next frame, so the fade-in transition actually runs.
+    requestAnimationFrame(() => host.classList.add("is-visible"));
+
+    function close() {
+      if (closed) {
+        return;
+      }
+      closed = true;
+      // Any later phase()/set()/succeed() call on this controller is a no-op, so
+      // a superseded export cannot repaint the shared launcher button.
+      state.terminal = state.terminal || "closed";
+      if (timer) {
+        clearInterval(timer);
+        timer = null;
+      }
+      if (terminalTimeout) {
+        clearTimeout(terminalTimeout);
+        terminalTimeout = null;
+      }
+      window.removeEventListener("resize", sizeRing);
+      setLauncherBusy(launcherId, { busy: false });
+      host.classList.remove("is-visible");
+      setTimeout(() => host.remove(), 300);
+      if (activeProgress === controller) {
+        activeProgress = null;
+      }
+    }
+
+    function phase({ label: phaseLabel, detail = "", from, to, creep = false, slowHintAfter = 0, slowHint = "" }) {
+      if (state.terminal || closed) {
+        return;
+      }
+      if (phaseLabel) {
+        state.label = phaseLabel;
+      }
+      state.detail = detail;
+      state.from = Number.isFinite(from) ? clamp01(from) : state.to;
+      state.to = Number.isFinite(to) ? clamp01(to) : state.to;
+      // Never walk the bar backwards β€” a retreating bar reads as a bug.
+      state.value = Math.max(state.value, state.from);
+      state.creep = Boolean(creep);
+      state.phaseStartedAt = Date.now();
+      state.slowHintAfter = slowHintAfter;
+      state.slowHint = slowHint;
+      state.dots = 0;
+      render();
+      announce();
+    }
+
+    // `fraction` is progress within the current phase, 0..1.
+    function set(fraction, detail) {
+      if (state.terminal || closed) {
+        return;
+      }
+      const target = state.from + (state.to - state.from) * clamp01(fraction);
+      state.value = Math.max(state.value, target);
+      if (typeof detail === "string") {
+        state.detail = detail;
+      }
+      render();
+    }
+
+    function setDetail(detail) {
+      if (state.terminal || closed) {
+        return;
+      }
+      state.detail = detail || "";
+      render();
+    }
+
+    function finish(tone, message, detail, holdMs) {
+      if (state.terminal || closed) {
+        return;
+      }
+      state.terminal = tone;
+      state.creep = false;
+      state.label = message;
+      state.detail = detail || "";
+      state.slowHintAfter = 0;
+      if (tone === "success") {
+        state.value = 1;
+      }
+      host.classList.add(tone === "success" ? "is-success" : "is-error");
+      if (timer) {
+        clearInterval(timer);
+        timer = null;
+      }
+      render();
+      announce();
+      terminalTimeout = setTimeout(close, holdMs);
+    }
+
+    const controller = {
+      phase,
+      set,
+      setDetail,
+      succeed: (message = "Done", detail = "") => finish("success", message, detail, 1400),
+      fail: (message = "Failed", detail = "") => finish("error", message, detail, 4000),
+      close
+    };
+
+    activeProgress = controller;
+    return controller;
+  }
+
   window.ContinueItUI = {
     ensureUIStyles,
     toast,
     mountLauncher,
-    createModal
+    createModal,
+    setLauncherBusy,
+    createProgress
   };
-})();
\ No newline at end of file
+})();
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..4251b21
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,35 @@
+{
+  "version": 1,
+  "skills": {
+    "built-in-ai": {
+      "source": "googlechromelabs/web-ai-demos",
+      "sourceType": "github",
+      "skillPath": "built-in-ai-skills-md-agent-md/SKILL.md",
+      "computedHash": "2a79383534f6b459a8278f19c68526a9096a44f1d5cdff1771d004e1ecad708f"
+    },
+    "chrome-extensions": {
+      "source": "GoogleChrome/modern-web-guidance",
+      "sourceType": "github",
+      "skillPath": "skills/chrome-extensions/SKILL.md",
+      "computedHash": "1373f573799adad2a98c158b0c1a068e9f3bdef07c728b55b2be3f1c8ae0c2b0"
+    },
+    "handoff": {
+      "source": "mattpocock/skills",
+      "sourceType": "github",
+      "skillPath": "skills/productivity/handoff/SKILL.md",
+      "computedHash": "ad03e8d4ea3cbbff66420eb7ba3cc375b5cbe1821a2449b53e863256cf5b5cde"
+    },
+    "strategic-compact": {
+      "source": "affaan-m/ecc",
+      "sourceType": "github",
+      "skillPath": "skills/strategic-compact/SKILL.md",
+      "computedHash": "7d3f106c49d412cef6dc3b3541b282b5c3a427c3056f68e80365c2f3d3701fe7"
+    },
+    "to-spec": {
+      "source": "mattpocock/skills",
+      "sourceType": "github",
+      "skillPath": "skills/engineering/to-spec/SKILL.md",
+      "computedHash": "7e07d4cfabd1a4f61627ebe2705601784fae7b9aca7e13af3e201ace75738200"
+    }
+  }
+}