From f56ea317a5e0b79fada88bd70bf362c7ef03bd9a Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Fri, 17 Apr 2026 23:39:08 -0400 Subject: [PATCH 01/25] [integrations] Chrome capture extension for Claude/ChatGPT/Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome MV3 extension that captures AI conversations into Open Brain via the REST API. First-run config screen collects API URL and key (stored in chrome.storage.local). All ExoCortex-specific hardcoded Supabase project URLs removed — extension is fully configurable. Runtime host permissions model documented. --- .../chrome-capture-extension/.gitignore | 20 + .../chrome-capture-extension/README.md | 194 +++++ .../background/service-worker.js | 668 ++++++++++++++++++ .../content-scripts/bridge.js | 61 ++ .../content-scripts/extractor-chatgpt.js | 151 ++++ .../content-scripts/extractor-claude.js | 249 +++++++ .../content-scripts/extractor-gemini.js | 156 ++++ .../data/sensitivity-patterns.json | 19 + .../docs/screenshots/README.md | 10 + .../chrome-capture-extension/icons/README.md | 12 + .../lib/api-client.js | 102 +++ .../chrome-capture-extension/lib/config.js | 238 +++++++ .../lib/fingerprint.js | 31 + .../lib/sensitivity.js | 95 +++ .../lib/sync-chatgpt.js | 353 +++++++++ .../lib/sync-claude.js | 311 ++++++++ .../chrome-capture-extension/manifest.json | 57 ++ .../chrome-capture-extension/metadata.json | 20 + .../popup/config.html | 55 ++ .../chrome-capture-extension/popup/config.js | 133 ++++ .../chrome-capture-extension/popup/popup.css | 460 ++++++++++++ .../chrome-capture-extension/popup/popup.html | 181 +++++ .../chrome-capture-extension/popup/popup.js | 471 ++++++++++++ 23 files changed, 4047 insertions(+) create mode 100644 integrations/chrome-capture-extension/.gitignore create mode 100644 integrations/chrome-capture-extension/README.md create mode 100644 integrations/chrome-capture-extension/background/service-worker.js create mode 100644 integrations/chrome-capture-extension/content-scripts/bridge.js create mode 100644 integrations/chrome-capture-extension/content-scripts/extractor-chatgpt.js create mode 100644 integrations/chrome-capture-extension/content-scripts/extractor-claude.js create mode 100644 integrations/chrome-capture-extension/content-scripts/extractor-gemini.js create mode 100644 integrations/chrome-capture-extension/data/sensitivity-patterns.json create mode 100644 integrations/chrome-capture-extension/docs/screenshots/README.md create mode 100644 integrations/chrome-capture-extension/icons/README.md create mode 100644 integrations/chrome-capture-extension/lib/api-client.js create mode 100644 integrations/chrome-capture-extension/lib/config.js create mode 100644 integrations/chrome-capture-extension/lib/fingerprint.js create mode 100644 integrations/chrome-capture-extension/lib/sensitivity.js create mode 100644 integrations/chrome-capture-extension/lib/sync-chatgpt.js create mode 100644 integrations/chrome-capture-extension/lib/sync-claude.js create mode 100644 integrations/chrome-capture-extension/manifest.json create mode 100644 integrations/chrome-capture-extension/metadata.json create mode 100644 integrations/chrome-capture-extension/popup/config.html create mode 100644 integrations/chrome-capture-extension/popup/config.js create mode 100644 integrations/chrome-capture-extension/popup/popup.css create mode 100644 integrations/chrome-capture-extension/popup/popup.html create mode 100644 integrations/chrome-capture-extension/popup/popup.js diff --git a/integrations/chrome-capture-extension/.gitignore b/integrations/chrome-capture-extension/.gitignore new file mode 100644 index 00000000..2792f605 --- /dev/null +++ b/integrations/chrome-capture-extension/.gitignore @@ -0,0 +1,20 @@ +# Runtime captures and local dev artifacts +data/captures/ +data/logs/ + +# Chrome Web Store build artifacts +*.zip +*.crx +*.pem +dist/ +build/ + +# Editor and OS noise +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Secrets — never commit +.env +.env.local diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md new file mode 100644 index 00000000..84ed11dd --- /dev/null +++ b/integrations/chrome-capture-extension/README.md @@ -0,0 +1,194 @@ +# Chrome Capture Extension + +> Chrome MV3 extension that captures conversations from Claude, ChatGPT, and Gemini into your Open Brain via the REST API gateway. + +## What It Does + +A client-side Chrome (or Chromium-based browser) extension that sits on top of Claude.ai, chatgpt.com, and gemini.google.com. When you finish an interesting exchange, click the extension icon and the extension extracts the latest user + assistant turn from the page DOM, runs local sensitivity and duplicate filters, and POSTs the result to your Open Brain REST API gateway. It also supports bulk backfill from Claude and ChatGPT using their internal conversation APIs so you can import your existing chat history in one pass. + +This is a **client-side** integration — unlike the other integrations in this repo (Slack, Discord, email capture) which deploy as Supabase Edge Functions, a Chrome extension runs entirely in the user's browser. It does **not** register as an MCP server. All it does is call the REST API gateway's `/ingest` endpoint with standard `x-brain-key` auth. Every user installs it locally against their own Open Brain. + +## Screenshots + +Placeholder. See [`docs/screenshots/README.md`](docs/screenshots/README.md) for the expected filenames. The four targets are: + +- First-run Configure screen (URL + API key entry) +- Popup on a Claude tab with Capture Current Response visible +- Activity log showing a successful capture plus a duplicate/skipped one +- Sync tab with Claude full/incremental sync controls + +## Prerequisites + +- Working Open Brain setup ([guide](../../docs/01-getting-started.md)) +- The [REST API gateway integration](../rest-api/) deployed and reachable — the extension POSTs to `/open-brain-rest/ingest` and pings `/open-brain-rest/health` +- An `MCP_ACCESS_KEY` (or equivalent `x-brain-key` token) issued by your Open Brain for this device +- Chrome 120+, or any Chromium-based browser that supports MV3 (Edge 120+, Brave, Arc, Opera) + +## Credential Tracker + +Copy this block into a text editor and fill it in as you go. + +```text +CHROME CAPTURE EXTENSION -- CREDENTIAL TRACKER +-------------------------------------- + +FROM YOUR OPEN BRAIN SETUP + REST API base URL: ____________ + (Supabase example: https://YOUR_PROJECT_REF.supabase.co/functions/v1 + Self-hosted example: https://brain.example.com) + x-brain-key API key: ____________ + +BROWSER INFO + Browser + version: ____________ + Extension ID (after install): ____________ + +-------------------------------------- +``` + +## Installation + +1. Download or clone this repository to your machine +2. Open your Chromium-based browser and go to `chrome://extensions` +3. Toggle **Developer mode** on (top-right) +4. Click **Load unpacked** and pick the `integrations/chrome-capture-extension/` folder +5. Pin the extension icon to the toolbar so you can reach it quickly +6. A new tab opens automatically on first install — the Configure Open Brain screen (see below) + +## First-Run Config + +The extension ships with **no hardcoded server URLs**. On first install it opens `popup/config.html` and asks for two things: + +1. **Open Brain REST API URL** — the base URL of your REST API gateway. Examples: + - Supabase-hosted: `https://your-project-ref.supabase.co/functions/v1` + - Self-hosted: `https://brain.example.com` +2. **API Key** — the `x-brain-key` (`MCP_ACCESS_KEY`) you configured when deploying the REST API integration + +When you click **Save & Grant Permission**, Chrome shows a native permission prompt asking whether the extension may access the specific origin you entered. Approve it. This is a one-time grant — Chrome remembers it and the extension can now talk to your Open Brain without asking again. You can revoke the grant any time from `chrome://extensions → Open Brain Capture → Details → Site access`. + +**Storage details:** +- API key → `chrome.storage.local` (per-device only, **never** synced across Chrome profiles) +- API URL + platform toggles + thresholds → `chrome.storage.sync` (follows your Google account across devices) + +## Usage + +**Manual capture (primary workflow):** + +1. Open a conversation on Claude.ai, chatgpt.com, or gemini.google.com +2. Click the extension icon in the toolbar +3. Click **Capture Current Response** +4. Watch the Activity log on the Overview tab — you should see `captured` and the sent counter tick up +5. Confirm the thought arrived in your Open Brain (query `search_thoughts` or peek at your database's `thoughts` table) + +**Bulk backfill (Claude and ChatGPT):** + +Switch to the Sync tab and click **Sync All** under the platform you want to import. The extension walks the platform's internal conversation API using your existing logged-in session and funnels every conversation through the capture pipeline. Dedup is handled automatically via SHA-256 content fingerprints — running Sync All twice is safe. Incremental **Sync New** only imports conversations whose `updated_at` has changed since the last run. Optionally turn on **Auto-sync every 15 min** to keep new conversations flowing in hands-free. + +## Supported Sites + +| Site | Manual capture | Bulk sync | Notes | +|------|---------------|-----------|-------| +| `claude.ai` | Yes | Yes | Uses Claude's internal `/api/organizations/.../chat_conversations` endpoint for bulk sync. DOM extractor walks open shadow roots to survive UI refactors. | +| `chatgpt.com`, `chat.openai.com` | Yes | Yes | Uses ChatGPT's `/backend-api/conversations` for bulk sync and `data-message-author-role` selectors for manual capture. | +| `gemini.google.com` | Yes (best-effort) | No | Google exposes no conversation API. Manual capture only. Selectors target `` and `` Web Components — Google rewrites this UI frequently, so extractor fragility is expected. | + +## Architecture + +``` +┌──────────────────────────┐ +│ claude.ai / chatgpt.com │ +│ / gemini.google.com tab │ +└──────────┬───────────────┘ + │ content script (bridge.js + extractor-.js) + │ extracts last user+assistant turn from DOM + ▼ +┌──────────────────────────┐ +│ background/service- │ +│ worker.js │ +│ - sensitivity filter │ +│ - SHA-256 fingerprint │ +│ - retry queue (5 tries, │ +│ exponential backoff) │ +└──────────┬───────────────┘ + │ fetch() with x-brain-key header + ▼ +┌──────────────────────────┐ +│ Open Brain REST API │ +│ /open-brain-rest/ingest │ +│ (Supabase Edge Function) │ +└──────────────────────────┘ +``` + +The service worker is the only network caller. Content scripts never touch the network — they only extract DOM text and hand it over via `chrome.runtime.sendMessage`. This keeps the API key out of every page's origin and makes the permission model reviewable. + +## Host Permissions Approach + +This extension uses **`optional_host_permissions` + runtime `chrome.permissions.request()`**, not `` at install time. Trade-off analysis: + +| Approach | Pros | Cons | +|----------|------|------| +| `host_permissions: [""]` | One-line manifest, no prompt flow | Chrome Web Store flags it as a high-risk permission, install-time prompt scares users, extension can hit any site | +| `optional_host_permissions` + runtime request (chosen) | Minimum-viable permissions, user sees exactly which origin they're granting, survives Chrome Web Store review | Requires a Configure screen + one extra click during setup | + +The extension declares `optional_host_permissions: ["https://*/*", "http://*/*"]` in the manifest. On the Configure screen it parses the user's URL, derives an origin pattern like `https://your-project-ref.supabase.co/*`, and calls `chrome.permissions.request({ origins: [origin] })`. The user approves once; Chrome persists the grant; the service worker can now `fetch()` that origin. Nothing else. + +The `content_scripts` entries for `claude.ai`, `chatgpt.com`, and `gemini.google.com` remain as normal `host_permissions` because the content scripts inject at `document_idle` on page load — they can't wait for a runtime prompt. Those three origins are scoped narrowly and visible in the install dialog. + +## Security + +- **API key storage.** The `x-brain-key` lives in `chrome.storage.local`. Chrome encrypts local storage on disk with OS-level keys, and the key is **never** written to `chrome.storage.sync` — meaning it does not propagate to your other Chrome profiles on the same Google account. Rotate by reopening the Configure screen and saving a new value. Uninstalling the extension removes the key along with it. +- **Client-side sensitivity filtering.** `data/sensitivity-patterns.json` holds regex patterns for SSNs, passports, bank accounts, API keys, credit cards, passwords-in-URLs, and medical/financial markers. Anything matching a `restricted` pattern is blocked locally before the request is even built — the text never leaves the browser. `personal` matches are logged but allowed through. Patterns compile once per session and are tested with `String.prototype.match` regex semantics. +- **Outbound requests.** Only the service worker calls `fetch()`, and only to the user-configured origin. No telemetry, no analytics, no third-party hosts. +- **Retry queue integrity.** Failed captures live in `chrome.storage.local` with the full payload and a `nextRetryAt` timestamp. Retries honour exponential backoff (1, 2, 4, 8, 16 minutes, capped at 60), max 5 attempts, then a dead-letter entry in the activity log. Fingerprints live across retries so a retry-then-manual-retry doesn't produce duplicates in Open Brain. +- **CSP.** Manifest V3 service workers run under a strict CSP that forbids `eval` and remote script loading. The lib scripts are all local. + +## Publishing to Chrome Web Store + +**Status: future work.** This contribution is currently distributed as an unpacked/developer-mode install. To publish to the Chrome Web Store, a maintainer will need to: + +1. Provide a 1.0.0-ready icon set (16/32/48/128 PNGs — see [`icons/README.md`](icons/README.md)) +2. Fill in the store listing: description, category (Productivity), screenshots, privacy policy URL +3. Draft the **permission justifications** — the store review team requires a paragraph per declared permission. Suggested text: + - `storage` — "Persists user-supplied Open Brain API URL, API key, and per-platform capture toggles." + - `alarms` — "Scheduled retry of failed ingests and optional 15-minute auto-sync from Claude/ChatGPT." + - `activeTab`, `tabs` — "Resolves the active conversation tab when the user clicks Capture." + - `cookies` — "Reads the `lastActiveOrg` cookie on claude.ai and the session cookie on chatgpt.com to bulk-fetch conversations via each platform's internal API using the user's own session." + - Host permissions for `claude.ai`, `chatgpt.com`, `chat.openai.com`, `gemini.google.com` — "Content scripts extract the latest conversation turn from the page DOM when the user clicks Capture." + - `optional_host_permissions` — "Runtime-granted by the user to reach their specific Open Brain API URL." +4. Pay the $5 one-time developer registration fee +5. Submit for review (typically 3–7 business days) + +Alternatively, host the packed `.crx` on a maintainer-owned update URL and let users sideload without going through the store at all. + +## Known Limitations + +- **DOM extraction is fragile.** Claude, ChatGPT, and Gemini all ship UI rewrites without notice. When a platform shuffles its selectors, manual capture returns "No conversation turns found" until the extractor is updated. The Gemini extractor is especially exposed — Google ships new Gemini UIs every few months. Expect occasional maintenance PRs. Bulk sync (Claude + ChatGPT) uses stable internal JSON APIs and is far less fragile than DOM extraction. +- **No passive/ambient capture yet.** The extension only captures when the user explicitly clicks Capture or runs Sync. A previous "observe every turn" design was retired because keeping up with selector churn on every render was not sustainable. Re-introducing ambient capture is tracked as future work. +- **Gemini has no bulk sync.** Google does not expose a conversation history API outside the Gemini UI. Manual capture is the only option. +- **Large conversations.** The REST API `/ingest` endpoint accepts a single payload per request. A 400-turn Claude thread becomes one very large POST. If your gateway has a request size cap (Supabase default is 10MB), Sync All may dead-letter the longest conversations. Check the activity log and trim in your dashboard if that happens. +- **Sensitivity filter is regex-only.** It's deliberately conservative — false negatives are possible. Treat it as a guardrail, not a vault. For truly sensitive content, don't paste it into an AI chat in the first place. + +## Troubleshooting + +**Issue: Extension icon has a yellow `!` badge and captures fail** +Solution: The extension is not configured. Click the icon, then click **Open Configure screen** in the yellow banner, and supply your Open Brain REST API URL + API key. + +**Issue: "Missing x-brain-key API key" error when I click Capture** +Solution: Either the API key was never saved, or Chrome's local storage got cleared (this can happen after a browser profile reset). Open the Settings tab → **Reconfigure API URL & Key** and re-enter. + +**Issue: "Cannot reach the page" error when capturing** +Solution: The content script isn't loaded on this tab. Refresh the tab and retry. If the page is still on the same URL family that the manifest declares (`claude.ai/*`, `chatgpt.com/*`, etc.), the refresh will re-inject the script. If the error persists, disable and re-enable the extension from `chrome://extensions`. + +**Issue: "No conversation turns found" on Claude / ChatGPT / Gemini** +Solution: The site DOM has changed and the extractor selectors are stale. Check the repo for a newer version of the extension; if there isn't one yet, open an issue with a sample of the current DOM and the `chrome://extensions → errors` output. + +**Issue: Sync All reports every conversation as `existing` but your Open Brain is empty** +Solution: The SHA-256 fingerprint cache is populated but the ingest POSTs are silently rejected. Open the Activity log on the Overview tab and look for `queued_retry` or `dead_letter` entries — those will show the actual API error. Common cause: the REST API gateway is deployed but `MCP_ACCESS_KEY` was rotated and you didn't update the extension. + +**Issue: I configured the extension but Test Connection says "fetch failed"** +Solution: Your browser doesn't have host permission for that origin. Open the Configure screen and save again — Chrome will re-prompt. If it still fails, verify the URL is reachable from your browser (paste it directly into the address bar, expect a 401 or similar from the gateway). + +## Tool Surface Area + +This integration is a **capture source**, not an MCP server — it doesn't expose any tools to your AI. It only writes into Open Brain. The AI-facing tool count of your setup is unchanged by installing this extension. + +If you're weighing whether to add more MCP-exposing extensions on top, see the [MCP Tool Audit & Optimization Guide](../../docs/05-tool-audit.md) for how to keep your tool count manageable as your Open Brain grows. diff --git a/integrations/chrome-capture-extension/background/service-worker.js b/integrations/chrome-capture-extension/background/service-worker.js new file mode 100644 index 00000000..a018dd61 --- /dev/null +++ b/integrations/chrome-capture-extension/background/service-worker.js @@ -0,0 +1,668 @@ +importScripts( + '../lib/config.js', + '../lib/api-client.js', + '../lib/fingerprint.js', + '../lib/sensitivity.js', + '../lib/sync-claude.js', + '../lib/sync-chatgpt.js' +); + +const RETRY_ALARM_NAME = 'ob_capture_retry_queue'; +const SYNC_ALARM_NAME = 'ob_capture_sync'; +const CHATGPT_SYNC_ALARM_NAME = 'ob_capture_chatgpt_sync'; +const MAX_CAPTURE_LOG = 100; +const MAX_RETRY_ATTEMPTS = 5; +const MAX_SEEN_FINGERPRINTS = 100000; + +let _storageLock = Promise.resolve(); +const processingFingerprints = new Set(); + +let sessionMetrics = { + queued: 0, + sent: 0, + skipped: 0, + failed: 0, + lastError: '' +}; + +const REDACTED_RESTRICTED_PREVIEW = '[restricted content blocked locally]'; +const NOT_CONFIGURED_ERROR = 'Open Brain is not configured. Click the extension icon and complete the Configure screen.'; + +function withStorageLock(fn) { + _storageLock = _storageLock.then(fn, fn); + return _storageLock; +} + +function createStateDefaults() { + return { + [OBConfig.STORAGE_KEYS.captureLog]: [], + [OBConfig.STORAGE_KEYS.retryQueue]: [], + [OBConfig.STORAGE_KEYS.seenFingerprints]: [] + }; +} + +async function getLocalState() { + return chrome.storage.local.get(createStateDefaults()); +} + +function readCaptureLog(state) { + return state[OBConfig.STORAGE_KEYS.captureLog] || []; +} + +function readRetryQueue(state) { + return state[OBConfig.STORAGE_KEYS.retryQueue] || []; +} + +function readSeenFingerprints(state) { + return state[OBConfig.STORAGE_KEYS.seenFingerprints] || []; +} + +async function appendCaptureLog(entry) { + return withStorageLock(async () => { + const state = await getLocalState(); + const nextLog = [...readCaptureLog(state), entry].slice(-MAX_CAPTURE_LOG); + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.captureLog]: nextLog + }); + return nextLog; + }); +} + +async function clearCaptureLog() { + return withStorageLock(async () => { + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.captureLog]: [] + }); + }); +} + +async function getRetryQueue() { + const state = await getLocalState(); + return readRetryQueue(state); +} + +async function hasKnownFingerprint(fingerprint) { + const state = await getLocalState(); + const seen = readSeenFingerprints(state); + const queue = readRetryQueue(state); + return processingFingerprints.has(fingerprint) || + seen.includes(fingerprint) || + queue.some((entry) => entry.fingerprint === fingerprint); +} + +async function rememberFingerprint(fingerprint) { + return withStorageLock(async () => { + const state = await getLocalState(); + const seen = readSeenFingerprints(state); + if (seen.includes(fingerprint)) { + return false; + } + + const nextSeen = [...seen, fingerprint].slice(-MAX_SEEN_FINGERPRINTS); + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.seenFingerprints]: nextSeen + }); + return true; + }); +} + +function updateBadge(config) { + // Show "!" badge when unconfigured, sent count when working, clear otherwise. + if (config && !OBConfig.isConfigured(config)) { + chrome.action.setBadgeText({ text: '!' }); + chrome.action.setBadgeBackgroundColor({ color: '#d6a53d' }); + return; + } + const badgeText = sessionMetrics.sent > 0 ? String(sessionMetrics.sent) : ''; + chrome.action.setBadgeText({ text: badgeText }); + chrome.action.setBadgeBackgroundColor({ color: '#27784c' }); +} + +async function refreshBadge() { + try { + const config = await OBConfig.getConfig(); + updateBadge(config); + } catch (err) { + console.error('[Open Brain Capture] Failed to refresh badge', err); + } +} + +function buildPreview(text) { + return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 120); +} + +function buildRetryDelayMinutes(attempts) { + const clampedAttempts = Math.max(1, attempts); + return Math.min(Math.pow(2, clampedAttempts - 1), 60); +} + +async function queueRetry(item, errorMessage) { + return withStorageLock(async () => { + const state = await getLocalState(); + const queue = [...readRetryQueue(state)]; + const nextAttempts = Number(item.attempts || 0) + 1; + const retryEntry = { + ...item, + attempts: nextAttempts, + lastError: errorMessage, + nextRetryAt: new Date(Date.now() + buildRetryDelayMinutes(nextAttempts) * 60 * 1000).toISOString() + }; + + if (nextAttempts >= MAX_RETRY_ATTEMPTS) { + const nextLog = [...readCaptureLog(state), { + timestamp: new Date().toISOString(), + platform: retryEntry.platform || 'unknown', + status: 'dead_letter', + preview: retryEntry.preview, + detail: errorMessage, + fingerprint: String(retryEntry.fingerprint || '').slice(0, 16) + }].slice(-MAX_CAPTURE_LOG); + + sessionMetrics.failed += 1; + sessionMetrics.queued = queue.length; + sessionMetrics.lastError = errorMessage; + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.captureLog]: nextLog + }); + await refreshBadge(); + return { deadLettered: true, queueLength: queue.length }; + } + + const existingIndex = queue.findIndex((entry) => entry.fingerprint === retryEntry.fingerprint); + if (existingIndex >= 0) { + queue[existingIndex] = retryEntry; + } else { + queue.push(retryEntry); + } + + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.retryQueue]: queue + }); + sessionMetrics.queued = queue.length; + sessionMetrics.lastError = errorMessage; + await refreshBadge(); + return { deadLettered: false, queueLength: queue.length }; + }); +} + +function normalizeCaptureRequest(message) { + const platform = String(message.platform || '').trim().toLowerCase(); + const text = String(message.text || message.content || '').trim(); + const captureMode = String(message.captureMode || 'ambient').trim().toLowerCase(); + const sourceType = String(message.sourceType || '').trim() || OBConfig.getSourceType(platform, captureMode); + const sourceLabel = String(message.sourceLabel || `${platform || 'unknown'}:${captureMode}`); + const sourceMetadata = message.sourceMetadata && typeof message.sourceMetadata === 'object' + ? message.sourceMetadata + : {}; + + return { + platform, + text, + captureMode, + sourceType, + sourceLabel, + sourceMetadata, + autoExecute: message.autoExecute !== false, + assistantLength: Number(message.assistantLength || message.textLength || text.length || 0), + preview: buildPreview(message.preview || text) + }; +} + +async function processCaptureRequest(message) { + const capture = normalizeCaptureRequest(message); + const config = await OBConfig.getConfig(); + + if (!capture.text) { + throw new Error('Capture request is missing text'); + } + + if (!OBConfig.isConfigured(config)) { + throw new Error(NOT_CONFIGURED_ERROR); + } + + if (capture.platform && config.enabledPlatforms[capture.platform] === false) { + sessionMetrics.skipped += 1; + return { ok: true, status: 'disabled_platform' }; + } + + if (config.captureMode === 'manual' && capture.captureMode === 'ambient') { + sessionMetrics.skipped += 1; + return { ok: true, status: 'manual_mode' }; + } + + if (capture.assistantLength < config.minResponseLength && capture.captureMode === 'ambient') { + sessionMetrics.skipped += 1; + return { ok: true, status: 'too_short' }; + } + + const sensitivity = await OBSensitivity.detectSensitivity(capture.text); + if (sensitivity.tier === 'restricted') { + sessionMetrics.skipped += 1; + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: capture.platform || 'unknown', + status: 'restricted_blocked', + preview: REDACTED_RESTRICTED_PREVIEW, + detail: sensitivity.labels.join(', ') + }); + return { ok: true, status: 'restricted_blocked', labels: sensitivity.labels }; + } + + const fingerprint = await OBFingerprint.compute(capture.text); + if (await hasKnownFingerprint(fingerprint)) { + sessionMetrics.skipped += 1; + return { ok: true, status: 'duplicate_fingerprint', fingerprint }; + } + processingFingerprints.add(fingerprint); + + const payload = { + text: capture.text, + source_label: capture.sourceLabel, + source_type: capture.sourceType, + auto_execute: capture.autoExecute, + source_metadata: { + ...capture.sourceMetadata, + extension_capture_mode: capture.captureMode, + extension_platform: capture.platform, + content_fingerprint: fingerprint + } + }; + + try { + const result = await OBApiClient.ingestDocument(payload, { + apiKey: config.apiKey, + endpoint: config.apiEndpoint + }); + + await rememberFingerprint(fingerprint); + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: capture.platform || 'unknown', + status: result && result.status ? result.status : 'captured', + preview: capture.preview, + detail: result && result.message ? result.message : '', + fingerprint: fingerprint.slice(0, 16) + }); + + if (result && result.status === 'existing') { + sessionMetrics.skipped += 1; + } else { + sessionMetrics.sent += 1; + } + sessionMetrics.lastError = ''; + await refreshBadge(); + + return { + ok: true, + status: result && result.status ? result.status : 'captured', + result, + fingerprint + }; + } catch (error) { + const retryItem = { + platform: capture.platform || 'unknown', + preview: capture.preview, + payload, + fingerprint, + attempts: 0, + queuedAt: new Date().toISOString() + }; + + await queueRetry(retryItem, error.message); + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: capture.platform || 'unknown', + status: 'queued_retry', + preview: capture.preview, + detail: error.message, + fingerprint: fingerprint.slice(0, 16) + }); + + return { + ok: false, + status: 'queued_retry', + error: error.message, + fingerprint + }; + } finally { + processingFingerprints.delete(fingerprint); + } +} + +async function claimRetryQueueItems(forceAll) { + return withStorageLock(async () => { + const state = await getLocalState(); + const queue = readRetryQueue(state); + + if (queue.length === 0) { + sessionMetrics.queued = 0; + await refreshBadge(); + return { dueItems: [], remainingCount: 0 }; + } + + const now = Date.now(); + const dueItems = []; + const remaining = []; + + for (const item of queue) { + const nextRetryAt = item.nextRetryAt ? Date.parse(item.nextRetryAt) : 0; + if (!forceAll && nextRetryAt && nextRetryAt > now) { + remaining.push(item); + } else { + dueItems.push(item); + } + } + + await chrome.storage.local.set({ + [OBConfig.STORAGE_KEYS.retryQueue]: remaining + }); + sessionMetrics.queued = remaining.length; + await refreshBadge(); + + return { dueItems, remainingCount: remaining.length }; + }); +} + +async function processRetryQueue(forceAll) { + const config = await OBConfig.getConfig(); + if (!OBConfig.isConfigured(config)) { + return { ok: false, error: NOT_CONFIGURED_ERROR }; + } + + const { dueItems, remainingCount } = await claimRetryQueueItems(forceAll); + if (dueItems.length === 0) { + return { ok: true, processed: 0, remaining: remainingCount }; + } + + let processed = 0; + + for (const item of dueItems) { + processingFingerprints.add(item.fingerprint); + try { + const result = await OBApiClient.ingestDocument(item.payload, { + apiKey: config.apiKey, + endpoint: config.apiEndpoint + }); + + processed += 1; + await rememberFingerprint(item.fingerprint); + const resultStatus = result && result.status ? result.status : 'captured'; + const logStatus = resultStatus === 'existing' ? 'retry_existing' : 'retry_sent'; + if (resultStatus === 'existing') { + sessionMetrics.skipped += 1; + } else { + sessionMetrics.sent += 1; + } + sessionMetrics.lastError = ''; + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: item.platform || 'unknown', + status: logStatus, + preview: item.preview, + detail: result && result.message ? result.message : 'Retry queue delivery succeeded', + fingerprint: String(item.fingerprint || '').slice(0, 16) + }); + } catch (error) { + await queueRetry(item, error.message); + } finally { + processingFingerprints.delete(item.fingerprint); + } + } + + const finalQueue = await getRetryQueue(); + sessionMetrics.queued = finalQueue.length; + await refreshBadge(); + + return { + ok: true, + processed, + remaining: finalQueue.length + }; +} + +async function getStatus() { + const config = await OBConfig.getConfig(); + const queue = await getRetryQueue(); + return { + ok: true, + configured: OBConfig.isConfigured(config), + settings: { + apiEndpoint: config.apiEndpoint, + apiKeyConfigured: Boolean(config.apiKey), + enabledPlatforms: config.enabledPlatforms, + captureMode: config.captureMode, + minResponseLength: config.minResponseLength + }, + sessionMetrics: { + ...sessionMetrics, + queued: queue.length + } + }; +} + +async function captureActiveTab() { + const config = await OBConfig.getConfig(); + if (!OBConfig.isConfigured(config)) { + throw new Error(NOT_CONFIGURED_ERROR); + } + + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab || !activeTab.url) { + throw new Error('No active tab found.'); + } + + const platform = OBConfig.resolvePlatformFromUrl(activeTab.url); + if (!platform) { + throw new Error('This page is not a supported platform. Navigate to a Claude, ChatGPT, or Gemini conversation first.'); + } + + if (config.enabledPlatforms[platform] === false) { + throw new Error(`${platform} capture is disabled in settings.`); + } + + let extraction; + try { + extraction = await chrome.tabs.sendMessage(activeTab.id, { type: 'EXTRACT_VISIBLE_RESPONSE' }); + } catch (err) { + throw new Error(`Cannot reach the page. Try refreshing the tab and retrying.`); + } + + if (!extraction || !extraction.ok) { + throw new Error(extraction?.error || 'Extraction returned no data.'); + } + + return processCaptureRequest(extraction.capture); +} + +async function getSyncState() { + const state = await OBClaudeSync.loadSyncState(); + return { ok: true, syncState: state }; +} + +async function setAutoSync(enabled, intervalMinutes) { + const state = await OBClaudeSync.loadSyncState(); + state.autoSyncEnabled = Boolean(enabled); + if (typeof intervalMinutes === 'number' && intervalMinutes > 0) { + state.autoSyncIntervalMinutes = intervalMinutes; + } + await OBClaudeSync.saveSyncState(state); + + if (state.autoSyncEnabled) { + chrome.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: state.autoSyncIntervalMinutes }); + } else { + chrome.alarms.clear(SYNC_ALARM_NAME); + } + + return { ok: true, syncState: state }; +} + +async function ensureSyncAlarm() { + const state = await OBClaudeSync.loadSyncState(); + if (state.autoSyncEnabled) { + chrome.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: state.autoSyncIntervalMinutes || 15 }); + } +} + +async function getChatGPTSyncState() { + const state = await OBChatGPTSync.loadSyncState(); + return { ok: true, syncState: state }; +} + +async function setChatGPTAutoSync(enabled, intervalMinutes) { + const state = await OBChatGPTSync.loadSyncState(); + state.autoSyncEnabled = Boolean(enabled); + if (typeof intervalMinutes === 'number' && intervalMinutes > 0) { + state.autoSyncIntervalMinutes = intervalMinutes; + } + await OBChatGPTSync.saveSyncState(state); + + if (state.autoSyncEnabled) { + chrome.alarms.create(CHATGPT_SYNC_ALARM_NAME, { periodInMinutes: state.autoSyncIntervalMinutes }); + } else { + chrome.alarms.clear(CHATGPT_SYNC_ALARM_NAME); + } + + return { ok: true, syncState: state }; +} + +async function ensureChatGPTSyncAlarm() { + const state = await OBChatGPTSync.loadSyncState(); + if (state.autoSyncEnabled) { + chrome.alarms.create(CHATGPT_SYNC_ALARM_NAME, { periodInMinutes: state.autoSyncIntervalMinutes || 15 }); + } +} + +async function handleMessage(message) { + switch (message.type) { + case 'GET_STATUS': + return getStatus(); + case 'GET_CONFIG': + return { ok: true, config: await OBConfig.getConfig() }; + case 'SAVE_CONFIG': { + const saved = await OBConfig.setConfig(message.config || {}); + await refreshBadge(); + return { ok: true, config: saved }; + } + case 'TEST_CONNECTION': { + const incoming = message.config || message.settings || {}; + const current = await OBConfig.getConfig(); + const merged = OBConfig.mergeSettings({ ...current, ...incoming }); + if (!OBConfig.isConfigured(merged)) { + return { ok: false, error: NOT_CONFIGURED_ERROR }; + } + const result = await OBApiClient.healthCheck({ + apiKey: merged.apiKey, + endpoint: merged.apiEndpoint + }); + sessionMetrics.lastError = ''; + return { ok: true, result }; + } + case 'QUEUE_CAPTURE': + return processCaptureRequest(message.capture || {}); + case 'CAPTURE_ACTIVE_TAB': + return captureActiveTab(); + case 'FLUSH_RETRY_QUEUE': + return processRetryQueue(true); + case 'CLEAR_ACTIVITY_LOG': + await clearCaptureLog(); + return { ok: true }; + case 'SYNC_ALL': + return OBClaudeSync.syncAll({ + captureHandler: processCaptureRequest, + onProgress: null + }); + case 'SYNC_INCREMENTAL': + return OBClaudeSync.syncIncremental({ + captureHandler: processCaptureRequest, + onProgress: null + }); + case 'GET_SYNC_STATE': + return getSyncState(); + case 'SET_AUTO_SYNC': + return setAutoSync(message.enabled, message.intervalMinutes); + case 'CHATGPT_SYNC_ALL': + return OBChatGPTSync.syncAll({ + captureHandler: processCaptureRequest, + onProgress: null + }); + case 'CHATGPT_SYNC_INCREMENTAL': + return OBChatGPTSync.syncIncremental({ + captureHandler: processCaptureRequest, + onProgress: null + }); + case 'GET_CHATGPT_SYNC_STATE': + return getChatGPTSyncState(); + case 'SET_CHATGPT_AUTO_SYNC': + return setChatGPTAutoSync(message.enabled, message.intervalMinutes); + default: + return { ok: false, error: `Unknown message type: ${message.type}` }; + } +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + handleMessage(message) + .then((response) => sendResponse(response)) + .catch((error) => { + sessionMetrics.lastError = error.message; + sendResponse({ ok: false, error: error.message }); + }); + + return true; +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === RETRY_ALARM_NAME) { + processRetryQueue(false).catch((error) => { + console.error('[Open Brain Capture] Retry queue processing failed', error); + }); + } + if (alarm.name === SYNC_ALARM_NAME) { + OBClaudeSync.syncIncremental({ + captureHandler: processCaptureRequest, + onProgress: null + }).then((result) => { + console.log(`[Open Brain Capture] Claude auto-sync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`); + }).catch((error) => { + console.error('[Open Brain Capture] Claude auto-sync failed', error); + }); + } + if (alarm.name === CHATGPT_SYNC_ALARM_NAME) { + OBChatGPTSync.syncIncremental({ + captureHandler: processCaptureRequest, + onProgress: null + }).then((result) => { + console.log(`[Open Brain Capture] ChatGPT auto-sync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`); + }).catch((error) => { + console.error('[Open Brain Capture] ChatGPT auto-sync failed', error); + }); + } +}); + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create(RETRY_ALARM_NAME, { periodInMinutes: 5 }); + ensureSyncAlarm(); + ensureChatGPTSyncAlarm(); + refreshBadge(); + + // On first install, open the config page so the user is immediately + // prompted to supply their Open Brain API URL and key. + OBConfig.getConfig().then((config) => { + if (!OBConfig.isConfigured(config)) { + chrome.tabs.create({ url: chrome.runtime.getURL('popup/config.html') }); + } + }).catch((err) => console.error('[Open Brain Capture] Install config check failed', err)); +}); + +chrome.runtime.onStartup.addListener(() => { + chrome.alarms.create(RETRY_ALARM_NAME, { periodInMinutes: 5 }); + ensureSyncAlarm(); + ensureChatGPTSyncAlarm(); + sessionMetrics = { + queued: 0, + sent: 0, + skipped: 0, + failed: 0, + lastError: '' + }; + refreshBadge(); +}); diff --git a/integrations/chrome-capture-extension/content-scripts/bridge.js b/integrations/chrome-capture-extension/content-scripts/bridge.js new file mode 100644 index 00000000..a0355c53 --- /dev/null +++ b/integrations/chrome-capture-extension/content-scripts/bridge.js @@ -0,0 +1,61 @@ +/** + * Open Brain Capture — content-script bridge. + * + * Listens for messages from the service worker and dispatches extraction + * requests to the platform-specific extractor loaded alongside this script. + * Each extractor registers itself via OBBridge.registerExtractor(name, handler). + * + * Message contract: + * Worker -> content script: { type: 'EXTRACT_VISIBLE_RESPONSE' } + * Content script -> worker: { ok: true, capture: { ... } } or { ok: false, error: '...' } + */ +(function () { + 'use strict'; + + const extractors = {}; + + const OBBridge = { + registerExtractor(name, handler) { + if (typeof handler !== 'function') { + console.error(`[Open Brain Capture Bridge] Extractor "${name}" must be a function`); + return; + } + extractors[name] = handler; + } + }; + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type !== 'EXTRACT_VISIBLE_RESPONSE') { + return false; + } + + const extractorNames = Object.keys(extractors); + if (extractorNames.length === 0) { + sendResponse({ ok: false, error: 'No extractor registered for this page' }); + return false; + } + + // Run the first registered extractor (one per content script bundle) + const handler = extractors[extractorNames[0]]; + + try { + const result = handler(); + + if (result && typeof result.then === 'function') { + result + .then((capture) => sendResponse(capture)) + .catch((err) => sendResponse({ ok: false, error: err.message || String(err) })); + return true; // keep channel open for async + } + + sendResponse(result); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + + return false; + }); + + // Expose for same-context extractor scripts + self.__OBBridge = OBBridge; +})(); diff --git a/integrations/chrome-capture-extension/content-scripts/extractor-chatgpt.js b/integrations/chrome-capture-extension/content-scripts/extractor-chatgpt.js new file mode 100644 index 00000000..ff12725b --- /dev/null +++ b/integrations/chrome-capture-extension/content-scripts/extractor-chatgpt.js @@ -0,0 +1,151 @@ +/** + * Open Brain Capture — ChatGPT extractor. + * + * DOM-only extraction of the most recent user/assistant message pair from the + * chatgpt.com / chat.openai.com conversation view. Used for manual capture + * (popup button click), not passive interception. + * + * ChatGPT's DOM has historically used `data-message-author-role` on each + * message wrapper ("user" / "assistant") and `.markdown` inside the + * assistant content. Both are best-effort — if OpenAI rewrites the + * conversation UI the selectors below may need updating. + */ +(function () { + 'use strict'; + + const DOCUMENT_POSITION_PRECEDING = globalThis.Node?.DOCUMENT_POSITION_PRECEDING || 2; + const DOCUMENT_POSITION_FOLLOWING = globalThis.Node?.DOCUMENT_POSITION_FOLLOWING || 4; + const USER_SELECTOR = [ + '[data-message-author-role="user"]', + '[data-testid^="conversation-turn-"] [data-message-author-role="user"]' + ].join(', '); + const ASSISTANT_SELECTOR = [ + '[data-message-author-role="assistant"]', + '[data-testid^="conversation-turn-"] [data-message-author-role="assistant"]' + ].join(', '); + const MESSAGE_BODY_SELECTOR = `${USER_SELECTOR}, ${ASSISTANT_SELECTOR}`; + + function getElementText(el) { + return String(el?.innerText || el?.textContent || '').trim(); + } + + function isWithinComposer(el) { + return Boolean( + el.closest?.( + 'form, textarea, [contenteditable="true"], [data-testid*="composer"], [data-testid*="prompt"], footer' + ) + ); + } + + function sortByDocumentOrder(elements) { + return [...elements].sort((a, b) => { + if (a === b) return 0; + const position = a.compareDocumentPosition(b); + if (position & DOCUMENT_POSITION_PRECEDING) return 1; + if (position & DOCUMENT_POSITION_FOLLOWING) return -1; + return 0; + }); + } + + function collectAllMessages() { + const messages = []; + const userNodes = sortByDocumentOrder(Array.from(document.querySelectorAll(USER_SELECTOR))); + const assistantNodes = sortByDocumentOrder(Array.from(document.querySelectorAll(ASSISTANT_SELECTOR))); + + for (const el of userNodes) { + if (!isWithinComposer(el) && getElementText(el)) { + messages.push({ role: 'user', el }); + } + } + for (const el of assistantNodes) { + if (!isWithinComposer(el) && getElementText(el)) { + messages.push({ role: 'assistant', el }); + } + } + + return messages.sort((a, b) => { + if (a.el === b.el) return 0; + const position = a.el.compareDocumentPosition(b.el); + if (position & DOCUMENT_POSITION_PRECEDING) return 1; + if (position & DOCUMENT_POSITION_FOLLOWING) return -1; + return 0; + }); + } + + function extractMessageText(el) { + // Prefer the dedicated markdown container if present — it strips chrome. + const markdown = el.querySelector?.('.markdown, [class*="markdown"]'); + if (markdown) { + return getElementText(markdown); + } + const clone = el.cloneNode(true); + clone.querySelectorAll?.('button, [role="toolbar"], [data-testid*="copy"], .sr-only').forEach((n) => n.remove()); + return getElementText(clone); + } + + function extractConversationId() { + const match = window.location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/); + return match ? match[1] : null; + } + + function extractVisibleResponse() { + const messages = collectAllMessages(); + if (messages.length === 0) { + return { + ok: false, + error: 'No ChatGPT messages found on this page. OpenAI may have changed its DOM; refresh the tab and retry.' + }; + } + + let lastAssistant = null; + let lastUser = null; + for (let i = messages.length - 1; i >= 0; i--) { + const { role, el } = messages[i]; + if (!lastAssistant && role === 'assistant') { + lastAssistant = el; + continue; + } + if (lastAssistant && !lastUser && role === 'user') { + lastUser = el; + break; + } + } + + if (!lastAssistant) { + return { ok: false, error: 'No assistant response found in the conversation.' }; + } + + const assistantText = extractMessageText(lastAssistant); + if (!assistantText) { + return { ok: false, error: 'Assistant response is empty (may still be streaming).' }; + } + + const userText = lastUser ? extractMessageText(lastUser) : null; + const captureText = userText + ? `USER: ${userText}\n\nASSISTANT: ${assistantText}` + : `ASSISTANT: ${assistantText}`; + const conversationId = extractConversationId(); + + return { + ok: true, + capture: { + platform: 'chatgpt', + captureMode: 'manual', + text: captureText, + assistantLength: assistantText.length, + sourceLabel: 'chatgpt:manual', + sourceMetadata: { + page_url: window.location.href, + page_title: document.title, + ...(conversationId ? { conversation_id: conversationId } : {}) + } + } + }; + } + + if (self.__OBBridge) { + self.__OBBridge.registerExtractor('chatgpt', extractVisibleResponse); + } else { + console.error('[Open Brain Capture] Bridge not loaded before extractor-chatgpt.js'); + } +})(); diff --git a/integrations/chrome-capture-extension/content-scripts/extractor-claude.js b/integrations/chrome-capture-extension/content-scripts/extractor-claude.js new file mode 100644 index 00000000..a469d656 --- /dev/null +++ b/integrations/chrome-capture-extension/content-scripts/extractor-claude.js @@ -0,0 +1,249 @@ +/** + * Open Brain Capture — Claude.ai extractor. + * + * DOM-only extraction of the most recent user/assistant message pair + * from the Claude.ai conversation view. Used for manual capture + * (popup button click), not passive interception. + * + * Selector strategy: + * 1. Conversation turn wrappers when Claude exposes them + * 2. Direct message-body selectors as a fallback for newer DOM layouts + * 3. Open shadow-root traversal so manual capture survives UI refactors + */ +(function () { + 'use strict'; + + const DOCUMENT_POSITION_PRECEDING = globalThis.Node?.DOCUMENT_POSITION_PRECEDING || 2; + const DOCUMENT_POSITION_FOLLOWING = globalThis.Node?.DOCUMENT_POSITION_FOLLOWING || 4; + const TURN_SELECTORS = [ + '[data-testid^="conversation-turn-"]', + '[data-testid*="conversation-turn"]', + 'article[data-scroll-anchor]', + '[class*="ConversationTurn"]', + '[class*="message-row"]', + '[class*="MessageRow"]' + ]; + const HUMAN_MESSAGE_SELECTOR = [ + '[data-testid="user-message"]', + '[data-testid*="user-message"]', + '.font-user-message', + '[data-testid*="human-message"]', + '[data-testid*="human-turn"]' + ].join(', '); + const ASSISTANT_MESSAGE_SELECTOR = [ + '.font-claude-response', + '.font-claude-response-body', + '[data-testid="chat-message-text"]', + '[data-testid*="chat-message-text"]', + '[data-testid*="assistant-message"]', + '[data-testid*="assistant-turn"]' + ].join(', '); + const MESSAGE_BODY_SELECTOR = `${ASSISTANT_MESSAGE_SELECTOR}, ${HUMAN_MESSAGE_SELECTOR}`; + + function dedupeElements(elements) { + return Array.from(new Set(elements.filter(Boolean))); + } + + function sortByDocumentOrder(elements) { + return [...elements].sort((a, b) => { + if (a === b) return 0; + const position = a.compareDocumentPosition(b); + if (position & DOCUMENT_POSITION_PRECEDING) return 1; + if (position & DOCUMENT_POSITION_FOLLOWING) return -1; + return 0; + }); + } + + function collectSearchRoots(root = document) { + const roots = [root]; + const visited = new Set([root]); + const elements = root.querySelectorAll ? root.querySelectorAll('*') : []; + + for (const el of elements) { + if (el.shadowRoot && !visited.has(el.shadowRoot)) { + visited.add(el.shadowRoot); + roots.push(el.shadowRoot); + } + } + + return roots; + } + + function queryAllDeep(selector, root = document) { + const matches = []; + + for (const searchRoot of collectSearchRoots(root)) { + if (searchRoot !== document && searchRoot.matches && searchRoot.matches(selector)) { + matches.push(searchRoot); + } + if (searchRoot.querySelectorAll) { + matches.push(...searchRoot.querySelectorAll(selector)); + } + } + + return dedupeElements(matches); + } + + function getElementText(el) { + return String(el?.innerText || el?.textContent || '').trim(); + } + + function isWithinComposer(el) { + return Boolean( + el.closest?.( + 'form, textarea, [contenteditable="true"], [data-testid*="composer"], [data-testid*="input"], footer' + ) + ); + } + + function isMessageTextNode(el) { + return Boolean(el?.matches?.(MESSAGE_BODY_SELECTOR)); + } + + function findTurnContainers() { + for (const selector of TURN_SELECTORS) { + const turns = sortByDocumentOrder( + queryAllDeep(selector).filter((el) => !isWithinComposer(el) && getElementText(el)) + ); + if (turns.length > 0) { + return turns; + } + } + + return []; + } + + function classifyTurn(el) { + const testId = el.getAttribute('data-testid') || ''; + if (/human|user/i.test(testId)) return 'human'; + if (/assistant|ai/i.test(testId)) return 'assistant'; + + const cls = el.className || ''; + if (/human|user-message/i.test(cls)) return 'human'; + if (/assistant|claude-response/i.test(cls)) return 'assistant'; + + if (queryAllDeep(HUMAN_MESSAGE_SELECTOR, el).length > 0) return 'human'; + if (queryAllDeep(ASSISTANT_MESSAGE_SELECTOR, el).length > 0) return 'assistant'; + + const srOnly = el.querySelector?.('.sr-only, [class*="sr-only"]'); + if (srOnly) { + const srText = getElementText(srOnly).toLowerCase(); + if (srText.includes('human') || srText.includes('you')) return 'human'; + if (srText.includes('assistant') || srText.includes('claude')) return 'assistant'; + } + + return 'unknown'; + } + + function extractTurnText(el) { + if (isMessageTextNode(el)) { + return getElementText(el); + } + + const messageText = queryAllDeep(MESSAGE_BODY_SELECTOR, el)[0]; + if (messageText) { + return getElementText(messageText); + } + + const prose = queryAllDeep('.prose, [class*="markdown"], [class*="Message"], .font-claude-response-body', el)[0]; + if (prose) { + return getElementText(prose); + } + + const clone = el.cloneNode(true); + clone + .querySelectorAll?.('button, [role="toolbar"], [class*="action"], [class*="timestamp"], .sr-only') + .forEach((child) => child.remove()); + return getElementText(clone); + } + + function findDirectMessageCandidates() { + return [ + ...queryAllDeep(HUMAN_MESSAGE_SELECTOR).map((el) => ({ role: 'human', el })), + ...queryAllDeep(ASSISTANT_MESSAGE_SELECTOR).map((el) => ({ role: 'assistant', el })) + ] + .filter(({ el }) => !isWithinComposer(el) && getElementText(el)) + .filter(({ el }, index, all) => all.findIndex((entry) => entry.el === el) === index) + .sort((a, b) => { + if (a.el === b.el) return 0; + const position = a.el.compareDocumentPosition(b.el); + if (position & DOCUMENT_POSITION_PRECEDING) return 1; + if (position & DOCUMENT_POSITION_FOLLOWING) return -1; + return 0; + }); + } + + function extractConversationId() { + const match = window.location.pathname.match(/\/chat\/([a-f0-9-]+)/i); + return match ? match[1] : null; + } + + function extractVisibleResponse() { + const turnCandidates = findTurnContainers() + .map((el) => ({ role: classifyTurn(el), el })) + .filter(({ role, el }) => role !== 'unknown' && getElementText(el)); + const candidates = turnCandidates.some(({ role }) => role === 'assistant') + ? turnCandidates + : findDirectMessageCandidates(); + + if (candidates.length === 0) { + return { + ok: false, + error: 'No conversation turns found on this page. Claude may have changed its DOM; refresh the tab and retry.' + }; + } + + let lastAssistant = null; + let lastHuman = null; + + for (let i = candidates.length - 1; i >= 0; i--) { + const { role, el } = candidates[i]; + + if (!lastAssistant && role === 'assistant') { + lastAssistant = el; + continue; + } + if (lastAssistant && !lastHuman && role === 'human') { + lastHuman = el; + break; + } + } + + if (!lastAssistant) { + return { ok: false, error: 'No assistant response found in the conversation.' }; + } + + const assistantText = extractTurnText(lastAssistant); + if (!assistantText) { + return { ok: false, error: 'Assistant response is empty (may still be streaming).' }; + } + + const humanText = lastHuman ? extractTurnText(lastHuman) : null; + const captureText = humanText + ? `USER: ${humanText}\n\nASSISTANT: ${assistantText}` + : `ASSISTANT: ${assistantText}`; + const conversationId = extractConversationId(); + + return { + ok: true, + capture: { + platform: 'claude', + captureMode: 'manual', + text: captureText, + assistantLength: assistantText.length, + sourceLabel: 'claude:manual', + sourceMetadata: { + page_url: window.location.href, + page_title: document.title, + ...(conversationId ? { conversation_id: conversationId } : {}) + } + } + }; + } + + if (self.__OBBridge) { + self.__OBBridge.registerExtractor('claude', extractVisibleResponse); + } else { + console.error('[Open Brain Capture] Bridge not loaded before extractor-claude.js'); + } +})(); diff --git a/integrations/chrome-capture-extension/content-scripts/extractor-gemini.js b/integrations/chrome-capture-extension/content-scripts/extractor-gemini.js new file mode 100644 index 00000000..4c6dcf77 --- /dev/null +++ b/integrations/chrome-capture-extension/content-scripts/extractor-gemini.js @@ -0,0 +1,156 @@ +/** + * Open Brain Capture — Gemini extractor. + * + * DOM-only extraction of the most recent user/assistant message pair from + * gemini.google.com. Used for manual capture (popup button click). + * + * Gemini's conversation UI uses Angular Material components; the user turn + * lives in and the model response in . These + * are Web Components with an open shadow root-free content projection, so + * standard `querySelector` works. + * + * NOTE: Google rewrites Gemini's UI frequently — the selectors below have + * been stable through the Gemini 1.x / 2.x transitions but may drift. The + * extractor degrades gracefully: if neither selector matches, it returns + * a clear "DOM changed" error rather than silently producing bad data. + */ +(function () { + 'use strict'; + + const DOCUMENT_POSITION_PRECEDING = globalThis.Node?.DOCUMENT_POSITION_PRECEDING || 2; + const DOCUMENT_POSITION_FOLLOWING = globalThis.Node?.DOCUMENT_POSITION_FOLLOWING || 4; + const USER_SELECTORS = [ + 'user-query', + '[data-test-id="user-query"]', + '[aria-label*="user message" i]' + ].join(', '); + const ASSISTANT_SELECTORS = [ + 'model-response', + '[data-test-id="model-response"]', + '[aria-label*="model response" i]', + '.model-response-text' + ].join(', '); + + function getElementText(el) { + return String(el?.innerText || el?.textContent || '').trim(); + } + + function isWithinComposer(el) { + return Boolean( + el.closest?.( + 'form, textarea, [contenteditable="true"], [data-test-id*="input"], footer, .input-container' + ) + ); + } + + function sortByDocumentOrder(elements) { + return [...elements].sort((a, b) => { + if (a === b) return 0; + const position = a.compareDocumentPosition(b); + if (position & DOCUMENT_POSITION_PRECEDING) return 1; + if (position & DOCUMENT_POSITION_FOLLOWING) return -1; + return 0; + }); + } + + function collectMessages() { + const out = []; + const userNodes = Array.from(document.querySelectorAll(USER_SELECTORS)); + const modelNodes = Array.from(document.querySelectorAll(ASSISTANT_SELECTORS)); + + for (const el of userNodes) { + if (!isWithinComposer(el) && getElementText(el)) { + out.push({ role: 'user', el }); + } + } + for (const el of modelNodes) { + if (!isWithinComposer(el) && getElementText(el)) { + out.push({ role: 'assistant', el }); + } + } + + return sortByDocumentOrder(out.map((entry) => entry.el)) + .map((el) => out.find((entry) => entry.el === el)) + .filter(Boolean); + } + + function extractMessageText(el) { + const prose = el.querySelector?.( + '.message-content, .markdown, message-content, [class*="response-content"], [class*="prose"]' + ); + if (prose) { + return getElementText(prose); + } + const clone = el.cloneNode(true); + clone + .querySelectorAll?.('button, [role="toolbar"], [aria-hidden="true"], .sr-only, [class*="thinking"]') + .forEach((n) => n.remove()); + return getElementText(clone); + } + + function extractConversationId() { + const match = window.location.pathname.match(/\/app\/([a-zA-Z0-9-]+)/); + return match ? match[1] : null; + } + + function extractVisibleResponse() { + const messages = collectMessages(); + if (messages.length === 0) { + return { + ok: false, + error: 'No Gemini messages found on this page. Google may have changed the Gemini DOM; refresh the tab and retry.' + }; + } + + let lastAssistant = null; + let lastUser = null; + for (let i = messages.length - 1; i >= 0; i--) { + const { role, el } = messages[i]; + if (!lastAssistant && role === 'assistant') { + lastAssistant = el; + continue; + } + if (lastAssistant && !lastUser && role === 'user') { + lastUser = el; + break; + } + } + + if (!lastAssistant) { + return { ok: false, error: 'No Gemini model response found in the conversation.' }; + } + + const assistantText = extractMessageText(lastAssistant); + if (!assistantText) { + return { ok: false, error: 'Gemini response is empty (may still be generating).' }; + } + + const userText = lastUser ? extractMessageText(lastUser) : null; + const captureText = userText + ? `USER: ${userText}\n\nASSISTANT: ${assistantText}` + : `ASSISTANT: ${assistantText}`; + const conversationId = extractConversationId(); + + return { + ok: true, + capture: { + platform: 'gemini', + captureMode: 'manual', + text: captureText, + assistantLength: assistantText.length, + sourceLabel: 'gemini:manual', + sourceMetadata: { + page_url: window.location.href, + page_title: document.title, + ...(conversationId ? { conversation_id: conversationId } : {}) + } + } + }; + } + + if (self.__OBBridge) { + self.__OBBridge.registerExtractor('gemini', extractVisibleResponse); + } else { + console.error('[Open Brain Capture] Bridge not loaded before extractor-gemini.js'); + } +})(); diff --git a/integrations/chrome-capture-extension/data/sensitivity-patterns.json b/integrations/chrome-capture-extension/data/sensitivity-patterns.json new file mode 100644 index 00000000..e480c118 --- /dev/null +++ b/integrations/chrome-capture-extension/data/sensitivity-patterns.json @@ -0,0 +1,19 @@ +{ + "restricted": [ + { "pattern": "\\b\\d{3}-?\\d{2}-?\\d{4}\\b", "flags": "", "label": "ssn_pattern" }, + { "pattern": "\\b[A-Z]{1,2}\\d{6,9}\\b", "flags": "", "label": "passport_pattern" }, + { "pattern": "\\b\\d{8,17}\\b.*\\b(account|routing|iban)\\b", "flags": "i", "label": "bank_account" }, + { "pattern": "\\b(account|routing)\\b.*\\b\\d{8,17}\\b", "flags": "i", "label": "bank_account" }, + { "pattern": "\\b(sk-|pk_live_|sk_live_|ghp_|gho_|AKIA)[A-Za-z0-9]{10,}", "flags": "i", "label": "api_key" }, + { "pattern": "\\bpassword\\s*[:=]\\s*\\S+", "flags": "i", "label": "password_value" }, + { "pattern": "\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b", "flags": "", "label": "credit_card" } + ], + "personal": [ + { "pattern": "\\b\\d+\\s*mg\\b(?!\\s*\\/\\s*(dL|kg|L|ml))", "flags": "i", "label": "medication_dosage" }, + { "pattern": "\\b(pregabalin|metoprolol|losartan|lisinopril|aspirin|atorvastatin|sertraline|metformin|gabapentin|prednisone|insulin|warfarin)\\b", "flags": "i", "label": "drug_name" }, + { "pattern": "\\b(glucose|a1c|cholesterol|blood pressure|bp|hrv|bmi)\\b.*\\b\\d+", "flags": "i", "label": "health_measurement" }, + { "pattern": "\\b(diagnosed|diagnosis|prediabetic|diabetic|arrhythmia|ablation)\\b", "flags": "i", "label": "medical_condition" }, + { "pattern": "\\b(salary|income|net worth|401k|ira|portfolio)\\b.*\\b\\$?\\d", "flags": "i", "label": "financial_detail" }, + { "pattern": "\\b\\$\\d{3,}[,\\d]*\\b", "flags": "i", "label": "financial_amount" } + ] +} diff --git a/integrations/chrome-capture-extension/docs/screenshots/README.md b/integrations/chrome-capture-extension/docs/screenshots/README.md new file mode 100644 index 00000000..6f6a4650 --- /dev/null +++ b/integrations/chrome-capture-extension/docs/screenshots/README.md @@ -0,0 +1,10 @@ +# Screenshots + +Placeholder. Drop PNG screenshots here when ready: + +- `01-first-run-config.png` — the Configure Open Brain screen (popup/config.html) on first install +- `02-popup-capture.png` — the main popup showing the Capture Current Response button on a Claude tab +- `03-activity-log.png` — the overview tab with a few successful + skipped captures +- `04-sync-tab.png` — the Claude/ChatGPT sync controls + +Each screenshot should be under 500KB. Reference them from `../../README.md`. diff --git a/integrations/chrome-capture-extension/icons/README.md b/integrations/chrome-capture-extension/icons/README.md new file mode 100644 index 00000000..a72679f7 --- /dev/null +++ b/integrations/chrome-capture-extension/icons/README.md @@ -0,0 +1,12 @@ +# Icons + +Manifest icons are intentionally not bundled in this contribution. Chrome will show the default puzzle-piece glyph until a maintainer (or you) adds branded icons here. + +To add icons later, drop these files in this folder and register them in `manifest.json` under `icons` and `action.default_icon`: + +- `icon16.png` +- `icon32.png` +- `icon48.png` +- `icon128.png` + +All four sizes must be square PNGs on a transparent background. Keep each file well under 500KB to stay within the OB1 binary-blob policy. diff --git a/integrations/chrome-capture-extension/lib/api-client.js b/integrations/chrome-capture-extension/lib/api-client.js new file mode 100644 index 00000000..8a97e17f --- /dev/null +++ b/integrations/chrome-capture-extension/lib/api-client.js @@ -0,0 +1,102 @@ +(function (global) { + 'use strict'; + + const REQUEST_TIMEOUT_MS = 15000; + + function parseErrorBody(text) { + if (!text) return 'Unknown error'; + try { + const parsed = JSON.parse(text); + return parsed.error || parsed.message || text; + } catch { + return text; + } + } + + async function apiFetch(path, options) { + const opts = options || {}; + const apiKey = String(opts.apiKey || '').trim(); + if (!apiKey) { + throw new Error('Missing x-brain-key API key. Open the extension popup and complete the Configure screen.'); + } + + const baseUrl = global.OBConfig.buildRestBase(opts.endpoint); + const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), opts.timeoutMs || REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: opts.method || 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-brain-key': apiKey, + ...(opts.headers || {}) + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + signal: controller.signal + }); + + const responseText = await response.text().catch(() => ''); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${parseErrorBody(responseText)}`); + } + + if (!responseText) { + return null; + } + + try { + return JSON.parse(responseText); + } catch { + return responseText; + } + } finally { + clearTimeout(timeoutId); + } + } + + async function healthCheck(options) { + return apiFetch('/health', { + apiKey: options.apiKey, + endpoint: options.endpoint, + method: 'GET' + }); + } + + async function ingestDocument(payload, options) { + return apiFetch('/ingest', { + apiKey: options.apiKey, + endpoint: options.endpoint, + method: 'POST', + body: payload + }); + } + + async function captureThought(payload, options) { + return apiFetch('/capture', { + apiKey: options.apiKey, + endpoint: options.endpoint, + method: 'POST', + body: payload + }); + } + + async function searchThoughts(payload, options) { + return apiFetch('/search', { + apiKey: options.apiKey, + endpoint: options.endpoint, + method: 'POST', + body: payload + }); + } + + global.OBApiClient = { + REQUEST_TIMEOUT_MS, + apiFetch, + healthCheck, + ingestDocument, + captureThought, + searchThoughts + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js new file mode 100644 index 00000000..2045de5e --- /dev/null +++ b/integrations/chrome-capture-extension/lib/config.js @@ -0,0 +1,238 @@ +(function (global) { + 'use strict'; + + // Open Brain Capture — configuration module + // + // All user-specific values (API base URL, API key, per-platform toggles, etc.) + // live in chrome.storage. There is deliberately NO hardcoded Supabase project + // URL in this extension — the user supplies their own Open Brain REST API + // gateway URL on the first-run config screen. Until configured, the service + // worker refuses to make outbound requests and the popup surfaces a + // "Configure Open Brain" call to action. + + const STORAGE_KEYS = { + settings: 'ob_capture_settings', + apiKey: 'ob_capture_api_key', + captureLog: 'ob_capture_log', + retryQueue: 'ob_capture_retry_queue', + seenFingerprints: 'ob_capture_seen_fingerprints', + syncTimestamps: 'ob_capture_sync_timestamps', + syncState: 'ob_capture_sync_state', + syncTimestampsChatGPT: 'ob_capture_sync_timestamps_chatgpt', + syncStateChatGPT: 'ob_capture_sync_state_chatgpt' + }; + + // No default endpoint. Users MUST supply their own Open Brain REST API URL. + // Shape example (Supabase-hosted): + // https://.supabase.co/functions/v1 + // Self-hosted alternative: + // https://brain.example.com + const DEFAULT_SETTINGS = { + apiEndpoint: '', + apiKey: '', + enabledPlatforms: { + chatgpt: true, + claude: true, + gemini: true + }, + captureMode: 'auto', + minResponseLength: 100, + autoSyncEnabled: false, + autoSyncIntervalMinutes: 15 + }; + + const PLATFORM_DEFINITIONS = { + chatgpt: { + id: 'chatgpt', + label: 'ChatGPT', + sourceTypes: { + ambient: 'chatgpt_ambient', + backfill: 'chatgpt_backfill', + manual: 'chatgpt_manual' + }, + matches: ['https://chatgpt.com/*', 'https://chat.openai.com/*'] + }, + claude: { + id: 'claude', + label: 'Claude', + sourceTypes: { + ambient: 'claude_ambient', + backfill: 'claude_backfill', + manual: 'claude_manual' + }, + matches: ['https://claude.ai/*'] + }, + gemini: { + id: 'gemini', + label: 'Gemini', + sourceTypes: { + ambient: 'gemini_ambient', + backfill: 'gemini_backfill', + manual: 'gemini_manual' + }, + matches: ['https://gemini.google.com/*'] + } + }; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function mergeSettings(raw) { + const merged = clone(DEFAULT_SETTINGS); + const incoming = raw && typeof raw === 'object' ? raw : {}; + + if (typeof incoming.apiEndpoint === 'string' && incoming.apiEndpoint.trim()) { + merged.apiEndpoint = incoming.apiEndpoint.trim(); + } + if (typeof incoming.apiKey === 'string') { + merged.apiKey = incoming.apiKey.trim(); + } + if (incoming.enabledPlatforms && typeof incoming.enabledPlatforms === 'object') { + merged.enabledPlatforms = { + ...merged.enabledPlatforms, + ...incoming.enabledPlatforms + }; + } + if (incoming.captureMode === 'manual' || incoming.captureMode === 'auto') { + merged.captureMode = incoming.captureMode; + } + if (Number.isFinite(Number(incoming.minResponseLength))) { + merged.minResponseLength = Math.max(0, Number(incoming.minResponseLength)); + } + + return merged; + } + + function buildRestBase(endpoint) { + const trimmed = String(endpoint || '').replace(/\/+$/, ''); + if (!trimmed) { + throw new Error( + 'Open Brain API URL is not configured. Click the extension icon and complete the Configure Open Brain screen.' + ); + } + return trimmed.endsWith('/open-brain-rest') ? trimmed : `${trimmed}/open-brain-rest`; + } + + function getPlatformDefinition(platformId) { + return PLATFORM_DEFINITIONS[platformId] || null; + } + + function getSourceType(platformId, captureMode) { + const platform = getPlatformDefinition(platformId); + if (!platform) { + return `${platformId || 'unknown'}_${captureMode || 'ambient'}`; + } + return platform.sourceTypes[captureMode] || `${platform.id}_${captureMode}`; + } + + function resolvePlatformFromUrl(url) { + if (!url) return null; + for (const [id, def] of Object.entries(PLATFORM_DEFINITIONS)) { + for (const pattern of def.matches) { + const prefix = pattern.replace(/\*$/, ''); + if (url.startsWith(prefix)) return id; + } + } + return null; + } + + async function safe(label, fn, fallbackValue) { + try { + return await fn(); + } catch (error) { + console.error(`[Open Brain Capture] ${label}`, error); + return fallbackValue; + } + } + + /** + * Read the full merged configuration from chrome.storage. + * + * The API key lives in chrome.storage.local (NOT chrome.storage.sync — sync + * would replicate the key across every Chrome profile on the user's Google + * account, which is a footgun). All non-secret settings live in + * chrome.storage.sync so platform toggles, endpoint, and capture-mode + * choices follow the user between devices. + */ + async function getConfig() { + const [syncStored, localStored] = await Promise.all([ + chrome.storage.sync.get({ + [STORAGE_KEYS.settings]: DEFAULT_SETTINGS + }), + chrome.storage.local.get({ + [STORAGE_KEYS.apiKey]: '' + }) + ]); + + const syncSettings = mergeSettings(syncStored[STORAGE_KEYS.settings]); + const localApiKey = String(localStored[STORAGE_KEYS.apiKey] || '').trim(); + + // Migrate legacy installs that may have left the API key in sync storage. + if (!localApiKey && syncSettings.apiKey) { + await Promise.all([ + chrome.storage.local.set({ + [STORAGE_KEYS.apiKey]: syncSettings.apiKey + }), + chrome.storage.sync.set({ + [STORAGE_KEYS.settings]: { + ...syncSettings, + apiKey: '' + } + }) + ]); + } + + return mergeSettings({ + ...syncSettings, + apiKey: localApiKey || syncSettings.apiKey || '' + }); + } + + /** + * Persist a configuration update. Splits the secret apiKey into + * chrome.storage.local and everything else into chrome.storage.sync. + */ + async function setConfig(partial) { + const current = await getConfig(); + const merged = mergeSettings({ ...current, ...(partial || {}) }); + + await Promise.all([ + chrome.storage.sync.set({ + [STORAGE_KEYS.settings]: { + ...merged, + apiKey: '' + } + }), + chrome.storage.local.set({ + [STORAGE_KEYS.apiKey]: merged.apiKey + }) + ]); + + return merged; + } + + /** + * Returns true if the extension has enough configuration to make outbound + * requests. Both the API base URL and the API key must be present. + */ + function isConfigured(config) { + if (!config) return false; + return Boolean(String(config.apiEndpoint || '').trim() && String(config.apiKey || '').trim()); + } + + global.OBConfig = { + DEFAULT_SETTINGS, + PLATFORM_DEFINITIONS, + STORAGE_KEYS, + mergeSettings, + buildRestBase, + getPlatformDefinition, + getSourceType, + resolvePlatformFromUrl, + safe, + getConfig, + setConfig, + isConfigured + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/fingerprint.js b/integrations/chrome-capture-extension/lib/fingerprint.js new file mode 100644 index 00000000..055e7ceb --- /dev/null +++ b/integrations/chrome-capture-extension/lib/fingerprint.js @@ -0,0 +1,31 @@ +(function (global) { + 'use strict'; + + function normalize(content) { + return String(content || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + async function sha256(value) { + const data = new TextEncoder().encode(value); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashBytes = Array.from(new Uint8Array(hashBuffer)); + return hashBytes.map((byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + async function compute(content) { + const canonical = normalize(content); + if (!canonical) { + throw new Error('Fingerprint content must be a non-empty string'); + } + return sha256(canonical); + } + + global.OBFingerprint = { + normalize, + sha256, + compute + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/sensitivity.js b/integrations/chrome-capture-extension/lib/sensitivity.js new file mode 100644 index 00000000..51b09b2f --- /dev/null +++ b/integrations/chrome-capture-extension/lib/sensitivity.js @@ -0,0 +1,95 @@ +(function (global) { + 'use strict'; + + const EMPTY_PATTERNS = { + restricted: [], + personal: [] + }; + + let compiledPatternsPromise = null; + + function getPatternsUrl() { + if (global.chrome && global.chrome.runtime && typeof global.chrome.runtime.getURL === 'function') { + return global.chrome.runtime.getURL('data/sensitivity-patterns.json'); + } + return 'data/sensitivity-patterns.json'; + } + + function compileGroup(entries) { + return (Array.isArray(entries) ? entries : []) + .map((entry) => { + if (!entry || typeof entry.pattern !== 'string') { + return null; + } + + try { + return { + label: String(entry.label || '').trim() || 'pattern', + regex: new RegExp(entry.pattern, entry.flags || '') + }; + } catch (error) { + console.warn('[Open Brain Capture] Invalid sensitivity pattern skipped', entry, error); + return null; + } + }) + .filter(Boolean); + } + + async function loadPatterns() { + if (!compiledPatternsPromise) { + compiledPatternsPromise = fetch(getPatternsUrl()) + .then((response) => { + if (!response.ok) { + throw new Error(`Unable to load bundled sensitivity patterns (${response.status})`); + } + return response.json(); + }) + .then((raw) => ({ + restricted: compileGroup(raw.restricted), + personal: compileGroup(raw.personal) + })) + .catch((error) => { + console.error('[Open Brain Capture] Falling back to empty sensitivity patterns', error); + return EMPTY_PATTERNS; + }); + } + + return compiledPatternsPromise; + } + + async function detectSensitivity(text) { + const patterns = await loadPatterns(); + const value = String(text || ''); + const restrictedMatches = patterns.restricted.filter((entry) => entry.regex.test(value)).map((entry) => entry.label); + if (restrictedMatches.length > 0) { + return { + tier: 'restricted', + labels: restrictedMatches + }; + } + + const personalMatches = patterns.personal.filter((entry) => entry.regex.test(value)).map((entry) => entry.label); + if (personalMatches.length > 0) { + return { + tier: 'personal', + labels: personalMatches + }; + } + + return { + tier: 'standard', + labels: [] + }; + } + + async function containsRestrictedContent(text) { + const result = await detectSensitivity(text); + return result.tier === 'restricted'; + } + + global.OBSensitivity = { + loadPatterns, + detectSensitivity, + containsRestrictedContent + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/sync-chatgpt.js b/integrations/chrome-capture-extension/lib/sync-chatgpt.js new file mode 100644 index 00000000..0c854ebf --- /dev/null +++ b/integrations/chrome-capture-extension/lib/sync-chatgpt.js @@ -0,0 +1,353 @@ +/** + * Open Brain Capture — ChatGPT sync module. + * + * Fetches conversations from ChatGPT's internal API using the browser's + * existing session, formats them as transcripts, and sends each through the + * capture pipeline on the Open Brain REST API. + * + * API details discovered via: + * - https://github.com/pionxzh/chatgpt-exporter + * - https://github.com/gin337/ChatGPTReversed + * + * Endpoints: + * GET /api/auth/session -> { accessToken } + * GET /backend-api/conversations?offset=0&limit=28&order=updated -> { items, has_more } + * GET /backend-api/conversation/{id} -> { mapping, title, create_time, update_time, current_node } + */ +(function (global) { + 'use strict'; + + const BATCH_DELAY_MS = 200; + const MAX_BACKOFF_MS = 30000; + const PAGE_SIZE = 28; + const MIN_CONVERSATION_LENGTH = 50; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function backoffDelay(attempt) { + const base = Math.min(Math.pow(2, attempt) * 500, MAX_BACKOFF_MS); + const jitter = Math.random() * 200; + return base + jitter; + } + + async function fetchWithRetry(url, options, maxAttempts) { + const attempts = maxAttempts || 3; + for (let i = 0; i < attempts; i++) { + const response = await fetch(url, options); + if (response.ok) { + return response; + } + const isRetryable = response.status === 429 || response.status >= 500; + if (!isRetryable || i === attempts - 1) { + const body = await response.text().catch(() => ''); + throw new Error(`ChatGPT API ${response.status}: ${body.slice(0, 200)}`); + } + console.warn(`[Open Brain Capture] ChatGPT API returned ${response.status}, retrying in ${Math.round(backoffDelay(i))}ms (attempt ${i + 1}/${attempts})`); + await sleep(backoffDelay(i)); + } + } + + async function getAccessToken() { + const response = await fetchWithRetry('https://chatgpt.com/api/auth/session', { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (!data.accessToken) { + throw new Error('Could not get ChatGPT access token. Are you logged in to chatgpt.com?'); + } + return data.accessToken; + } + + async function listConversations(accessToken) { + const all = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const url = `https://chatgpt.com/backend-api/conversations?offset=${offset}&limit=${PAGE_SIZE}&order=updated`; + const response = await fetchWithRetry(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + const data = await response.json(); + const items = data.items || []; + + for (const conv of items) { + all.push({ + id: conv.id, + title: conv.title || '(untitled)', + create_time: conv.create_time, + update_time: conv.update_time + }); + } + + hasMore = data.has_more === true; + offset += items.length; + + if (hasMore) { + await sleep(BATCH_DELAY_MS); + } + } + + return all; + } + + async function getConversation(accessToken, conversationId) { + const url = `https://chatgpt.com/backend-api/conversation/${conversationId}`; + const response = await fetchWithRetry(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + return response.json(); + } + + function flattenMessageTree(mapping, currentNode) { + if (!mapping || !currentNode) return []; + + const chain = []; + let nodeId = currentNode; + const visited = new Set(); + + while (nodeId && mapping[nodeId] && !visited.has(nodeId)) { + visited.add(nodeId); + const node = mapping[nodeId]; + if (node.message && node.message.content) { + chain.push(node.message); + } + nodeId = node.parent; + } + + chain.reverse(); + return chain; + } + + function extractMessageText(message) { + if (!message || !message.content) return ''; + + const content = message.content; + if (content.content_type === 'text' && Array.isArray(content.parts)) { + return content.parts + .filter((part) => typeof part === 'string') + .join('\n') + .trim(); + } + + if (Array.isArray(content.parts)) { + return content.parts + .filter((part) => typeof part === 'string') + .join('\n') + .trim(); + } + + return ''; + } + + function unixToISO(timestamp) { + if (!timestamp || typeof timestamp !== 'number') return ''; + return new Date(timestamp * 1000).toISOString(); + } + + function formatForIngest(conversation) { + const title = conversation.title || '(untitled)'; + const createdAt = unixToISO(conversation.create_time); + const convId = conversation.conversation_id || conversation.id || ''; + + const messages = flattenMessageTree(conversation.mapping, conversation.current_node); + const lines = [ + `Conversation title: ${title}`, + createdAt ? `Conversation created at: ${createdAt}` : '', + '' + ]; + + for (const msg of messages) { + const role = msg.author?.role; + if (!role || role === 'system' || role === 'tool') continue; + + const label = role === 'user' ? 'USER' : 'ASSISTANT'; + const text = extractMessageText(msg); + if (text) { + lines.push(`${label}: ${text}`); + lines.push(''); + } + } + + const fullText = lines.filter((l) => l !== undefined).join('\n').trim(); + + return { + text: fullText, + platform: 'chatgpt', + captureMode: 'sync', + sourceType: 'chatgpt_import', + sourceLabel: 'chatgpt:sync', + sourceMetadata: { + conversation_id: convId, + conversation_title: title, + page_url: `https://chatgpt.com/c/${convId}`, + capture_mode: 'sync', + export_tool: 'open_brain_capture_extension_sync' + }, + autoExecute: true + }; + } + + async function loadSyncTimestamps() { + const key = OBConfig.STORAGE_KEYS.syncTimestampsChatGPT; + const result = await chrome.storage.local.get({ [key]: {} }); + return result[key] || {}; + } + + async function saveSyncTimestamps(timestamps) { + const key = OBConfig.STORAGE_KEYS.syncTimestampsChatGPT; + await chrome.storage.local.set({ [key]: timestamps }); + } + + async function loadSyncState() { + const key = OBConfig.STORAGE_KEYS.syncStateChatGPT; + const result = await chrome.storage.local.get({ + [key]: { + lastSyncAt: null, + autoSyncEnabled: false, + autoSyncIntervalMinutes: 15 + } + }); + return result[key]; + } + + async function saveSyncState(state) { + const key = OBConfig.STORAGE_KEYS.syncStateChatGPT; + await chrome.storage.local.set({ [key]: state }); + } + + async function processOneConversation(accessToken, conv, captureHandler) { + const fullConv = await getConversation(accessToken, conv.id); + const formatted = formatForIngest(fullConv); + + if (!formatted.text || formatted.text.length < MIN_CONVERSATION_LENGTH) { + return { status: 'skipped', reason: 'too_short' }; + } + + return captureHandler(formatted); + } + + async function syncAll(options) { + const { captureHandler, onProgress } = options; + + const accessToken = await getAccessToken(); + const conversations = await listConversations(accessToken); + const total = conversations.length; + let synced = 0; + let skipped = 0; + let errors = 0; + const timestamps = {}; + + for (let i = 0; i < total; i++) { + const conv = conversations[i]; + + if (onProgress) { + onProgress(i + 1, total, conv.title || '(untitled)'); + } + + try { + const result = await processOneConversation(accessToken, conv, captureHandler); + if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || + result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + skipped++; + } else { + synced++; + } + timestamps[conv.id] = String(conv.update_time); + } catch (err) { + console.error(`[Open Brain Capture] Failed to sync ChatGPT conversation "${conv.title}":`, err); + errors++; + } + + if (i + 1 < total) { + await sleep(BATCH_DELAY_MS); + } + } + + await saveSyncTimestamps(timestamps); + const syncState = await loadSyncState(); + syncState.lastSyncAt = new Date().toISOString(); + await saveSyncState(syncState); + + return { total, synced, skipped, errors }; + } + + async function syncIncremental(options) { + const { captureHandler, onProgress } = options; + + const accessToken = await getAccessToken(); + const conversations = await listConversations(accessToken); + const savedTimestamps = await loadSyncTimestamps(); + + const changed = conversations.filter((conv) => { + const lastSynced = savedTimestamps[conv.id]; + if (!lastSynced) return true; + return String(conv.update_time) !== lastSynced; + }); + + const total = changed.length; + let synced = 0; + let skipped = 0; + let errors = 0; + const updatedTimestamps = { ...savedTimestamps }; + + for (let i = 0; i < total; i++) { + const conv = changed[i]; + + if (onProgress) { + onProgress(i + 1, total, conv.title || '(untitled)'); + } + + try { + const result = await processOneConversation(accessToken, conv, captureHandler); + if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || + result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + skipped++; + } else { + synced++; + } + updatedTimestamps[conv.id] = String(conv.update_time); + } catch (err) { + console.error(`[Open Brain Capture] Failed to sync ChatGPT conversation "${conv.title}":`, err); + errors++; + } + + if (i + 1 < total) { + await sleep(BATCH_DELAY_MS); + } + } + + await saveSyncTimestamps(updatedTimestamps); + const syncState = await loadSyncState(); + syncState.lastSyncAt = new Date().toISOString(); + await saveSyncState(syncState); + + return { total, synced, skipped, errors }; + } + + global.OBChatGPTSync = { + getAccessToken, + listConversations, + getConversation, + flattenMessageTree, + formatForIngest, + syncAll, + syncIncremental, + loadSyncState, + saveSyncState + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/sync-claude.js b/integrations/chrome-capture-extension/lib/sync-claude.js new file mode 100644 index 00000000..52e0d9df --- /dev/null +++ b/integrations/chrome-capture-extension/lib/sync-claude.js @@ -0,0 +1,311 @@ +(function (global) { + 'use strict'; + + const BATCH_SIZE = 30; + const BATCH_DELAY_MS = 100; + const MAX_BACKOFF_MS = 30000; + + /** + * Sleep for a given number of milliseconds. + */ + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Exponential backoff delay for retryable errors (429, 5xx). + * Returns delay in ms: min(2^attempt * 500, MAX_BACKOFF_MS) + jitter. + */ + function backoffDelay(attempt) { + const base = Math.min(Math.pow(2, attempt) * 500, MAX_BACKOFF_MS); + const jitter = Math.random() * 200; + return base + jitter; + } + + /** + * Fetch with retry on 429 and 5xx errors. Max 3 attempts. + */ + async function fetchWithRetry(url, options, maxAttempts) { + const attempts = maxAttempts || 3; + for (let i = 0; i < attempts; i++) { + const response = await fetch(url, options); + if (response.ok) { + return response; + } + const isRetryable = response.status === 429 || response.status >= 500; + if (!isRetryable || i === attempts - 1) { + const body = await response.text().catch(() => ''); + throw new Error(`Claude API ${response.status}: ${body.slice(0, 200)}`); + } + console.warn(`[Open Brain Capture] Claude API returned ${response.status}, retrying in ${Math.round(backoffDelay(i))}ms (attempt ${i + 1}/${attempts})`); + await sleep(backoffDelay(i)); + } + } + + /** + * Get the organization ID from the lastActiveOrg cookie on claude.ai. + */ + async function getOrgId() { + const cookie = await chrome.cookies.get({ + url: 'https://claude.ai', + name: 'lastActiveOrg' + }); + if (!cookie || !cookie.value) { + throw new Error('Could not find lastActiveOrg cookie. Are you logged in to claude.ai?'); + } + return decodeURIComponent(cookie.value); + } + + async function listConversations(orgId) { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations`; + const response = await fetchWithRetry(url, { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + + if (!Array.isArray(data)) { + throw new Error('Unexpected response format from Claude conversations API'); + } + + return data.map((conv) => ({ + uuid: conv.uuid, + name: conv.name || '(untitled)', + created_at: conv.created_at, + updated_at: conv.updated_at + })); + } + + async function getConversation(orgId, uuid) { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations/${uuid}?tree=True&rendering_mode=messages&render_all_tools=true`; + const response = await fetchWithRetry(url, { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); + return response.json(); + } + + function extractMessageText(content) { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + return content + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); + } + + function flattenMessages(conversation) { + const messages = conversation.chat_messages || []; + const sorted = [...messages].sort((a, b) => { + if (typeof a.index === 'number' && typeof b.index === 'number') { + return a.index - b.index; + } + return (a.created_at || '').localeCompare(b.created_at || ''); + }); + return sorted; + } + + function formatForIngest(conversation) { + const name = conversation.name || '(untitled)'; + const createdAt = conversation.created_at || ''; + const uuid = conversation.uuid || ''; + + const messages = flattenMessages(conversation); + const lines = [`Conversation title: ${name}`, `Conversation created at: ${createdAt}`, '']; + + for (const msg of messages) { + const role = msg.sender === 'human' ? 'USER' : 'ASSISTANT'; + const text = extractMessageText(msg.content || msg.text || ''); + if (text.trim()) { + lines.push(`${role}: ${text}`); + lines.push(''); + } + } + + const fullText = lines.join('\n').trim(); + + return { + text: fullText, + platform: 'claude', + captureMode: 'sync', + sourceType: 'claude_import', + sourceLabel: `claude:sync`, + sourceMetadata: { + conversation_id: uuid, + conversation_title: name, + page_url: `https://claude.ai/chat/${uuid}`, + capture_mode: 'sync', + export_tool: 'open_brain_capture_extension_sync' + }, + autoExecute: true + }; + } + + async function loadSyncTimestamps() { + const key = OBConfig.STORAGE_KEYS.syncTimestamps; + const result = await chrome.storage.local.get({ [key]: {} }); + return result[key] || {}; + } + + async function saveSyncTimestamps(timestamps) { + const key = OBConfig.STORAGE_KEYS.syncTimestamps; + await chrome.storage.local.set({ [key]: timestamps }); + } + + async function loadSyncState() { + const key = OBConfig.STORAGE_KEYS.syncState; + const result = await chrome.storage.local.get({ + [key]: { + lastSyncAt: null, + autoSyncEnabled: false, + autoSyncIntervalMinutes: 15 + } + }); + return result[key]; + } + + async function saveSyncState(state) { + const key = OBConfig.STORAGE_KEYS.syncState; + await chrome.storage.local.set({ [key]: state }); + } + + async function processOneConversation(orgId, conv, captureHandler) { + const fullConv = await getConversation(orgId, conv.uuid); + const formatted = formatForIngest(fullConv); + + if (!formatted.text || formatted.text.length < 50) { + return { status: 'skipped', reason: 'too_short' }; + } + + const result = await captureHandler(formatted); + return result; + } + + async function syncAll(options) { + const { captureHandler, onProgress } = options; + + let orgId; + try { + orgId = await getOrgId(); + } catch (err) { + throw new Error(`Cannot sync: ${err.message}`); + } + + const conversations = await listConversations(orgId); + const total = conversations.length; + let synced = 0; + let skipped = 0; + let errors = 0; + const timestamps = {}; + + for (let i = 0; i < total; i++) { + const conv = conversations[i]; + + if (onProgress) { + onProgress(i + 1, total, conv.name || '(untitled)'); + } + + try { + const result = await processOneConversation(orgId, conv, captureHandler); + if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + skipped++; + } else { + synced++; + } + timestamps[conv.uuid] = conv.updated_at; + } catch (err) { + console.error(`[Open Brain Capture] Failed to sync conversation "${conv.name}":`, err); + errors++; + } + + if (i + 1 < total) { + await sleep(BATCH_DELAY_MS); + } + if ((i + 1) % BATCH_SIZE === 0 && i + 1 < total) { + await sleep(BATCH_DELAY_MS); + } + } + + await saveSyncTimestamps(timestamps); + const syncState = await loadSyncState(); + syncState.lastSyncAt = new Date().toISOString(); + await saveSyncState(syncState); + + return { total, synced, skipped, errors }; + } + + async function syncIncremental(options) { + const { captureHandler, onProgress } = options; + + let orgId; + try { + orgId = await getOrgId(); + } catch (err) { + throw new Error(`Cannot sync: ${err.message}`); + } + + const conversations = await listConversations(orgId); + const savedTimestamps = await loadSyncTimestamps(); + + const changed = conversations.filter((conv) => { + const lastSynced = savedTimestamps[conv.uuid]; + if (!lastSynced) return true; + return conv.updated_at !== lastSynced; + }); + + const total = changed.length; + let synced = 0; + let skipped = 0; + let errors = 0; + const updatedTimestamps = { ...savedTimestamps }; + + for (let i = 0; i < total; i++) { + const conv = changed[i]; + + if (onProgress) { + onProgress(i + 1, total, conv.name || '(untitled)'); + } + + try { + const result = await processOneConversation(orgId, conv, captureHandler); + if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + skipped++; + } else { + synced++; + } + updatedTimestamps[conv.uuid] = conv.updated_at; + } catch (err) { + console.error(`[Open Brain Capture] Failed to sync conversation "${conv.name}":`, err); + errors++; + } + + if (i + 1 < total) { + await sleep(BATCH_DELAY_MS); + } + } + + await saveSyncTimestamps(updatedTimestamps); + const syncState = await loadSyncState(); + syncState.lastSyncAt = new Date().toISOString(); + await saveSyncState(syncState); + + return { total, synced, skipped, errors }; + } + + global.OBClaudeSync = { + getOrgId, + listConversations, + getConversation, + formatForIngest, + syncAll, + syncIncremental, + loadSyncState, + saveSyncState + }; +})(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/manifest.json b/integrations/chrome-capture-extension/manifest.json new file mode 100644 index 00000000..18073a1c --- /dev/null +++ b/integrations/chrome-capture-extension/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": 3, + "name": "Open Brain Capture", + "version": "0.4.0", + "description": "Capture AI conversations from Claude, ChatGPT, and Gemini into your Open Brain via the REST API gateway.", + "permissions": [ + "storage", + "alarms", + "activeTab", + "tabs", + "cookies" + ], + "optional_host_permissions": [ + "https://*/*", + "http://*/*" + ], + "host_permissions": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://claude.ai/*", + "https://gemini.google.com/*" + ], + "background": { + "service_worker": "background/service-worker.js" + }, + "action": { + "default_title": "Open Brain Capture", + "default_popup": "popup/popup.html" + }, + "content_scripts": [ + { + "matches": ["https://claude.ai/*"], + "js": ["content-scripts/bridge.js", "content-scripts/extractor-claude.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://chatgpt.com/*", "https://chat.openai.com/*"], + "js": ["content-scripts/bridge.js", "content-scripts/extractor-chatgpt.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://gemini.google.com/*"], + "js": ["content-scripts/bridge.js", "content-scripts/extractor-gemini.js"], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ + { + "resources": [ + "data/sensitivity-patterns.json" + ], + "matches": [ + "" + ] + } + ] +} diff --git a/integrations/chrome-capture-extension/metadata.json b/integrations/chrome-capture-extension/metadata.json new file mode 100644 index 00000000..04b552ef --- /dev/null +++ b/integrations/chrome-capture-extension/metadata.json @@ -0,0 +1,20 @@ +{ + "name": "Chrome Capture Extension", + "description": "Chrome MV3 extension that captures Claude, ChatGPT, and Gemini conversations into Open Brain via the REST API", + "category": "integrations", + "author": { + "name": "Alan Shurafa", + "github": "alanshurafa" + }, + "version": "1.0.0", + "requires": { + "open_brain": true, + "services": ["REST API gateway (PR #201)"], + "tools": ["Chrome 120+ or Chromium-based browser"] + }, + "tags": ["chrome-extension", "capture", "claude", "chatgpt", "gemini", "client-side"], + "difficulty": "intermediate", + "estimated_time": "20 minutes", + "created": "2026-04-17", + "updated": "2026-04-17" +} diff --git a/integrations/chrome-capture-extension/popup/config.html b/integrations/chrome-capture-extension/popup/config.html new file mode 100644 index 00000000..d21957a9 --- /dev/null +++ b/integrations/chrome-capture-extension/popup/config.html @@ -0,0 +1,55 @@ + + + + + + Configure Open Brain Capture + + + +
+

Configure Open Brain

+

First-run setup

+ +
+ Paste the base URL of your Open Brain REST API gateway and the API key you generated when deploying the + rest-api integration. Nothing leaves this browser until both fields are filled in and you + save this form — the extension refuses outbound requests while unconfigured. +
+ +
+ + +

+ Supabase-hosted example: https://your-project-ref.supabase.co/functions/v1
+ Self-hosted example: https://brain.example.com +

+
+ +
+ + +

+ Stored in chrome.storage.local on this device only. The key is NOT synced across Chrome + profiles. Rotate by coming back to this screen and re-entering. +

+
+ +
+ Host permission: after saving, Chrome will ask you to grant the extension permission to + talk to this specific origin. That one-time grant is how we avoid requesting access to every website on + install. You can revoke it any time under chrome://extensions. +
+ +
+ + +
+
+
+ + + + + + diff --git a/integrations/chrome-capture-extension/popup/config.js b/integrations/chrome-capture-extension/popup/config.js new file mode 100644 index 00000000..b8c6df36 --- /dev/null +++ b/integrations/chrome-capture-extension/popup/config.js @@ -0,0 +1,133 @@ +(function () { + 'use strict'; + + const endpointInput = document.getElementById('cfg-api-endpoint'); + const keyInput = document.getElementById('cfg-api-key'); + const saveBtn = document.getElementById('cfg-save-btn'); + const testBtn = document.getElementById('cfg-test-btn'); + const result = document.getElementById('cfg-result'); + + function showResult(message, kind) { + result.textContent = message; + result.className = `result ${kind || ''}`.trim(); + } + + function normalizeEndpoint(value) { + const trimmed = String(value || '').trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + if (!/^https?:\/\//i.test(trimmed)) { + throw new Error('API URL must start with http:// or https://'); + } + return trimmed; + } + + /** + * Request runtime host permission for the user-supplied origin. + * + * Why this is necessary: we ship with zero host permissions for third-party + * origins at install time — the user could put their brain anywhere (Supabase, + * self-hosted, custom domain, localhost). Rather than ask for up + * front (which is a red flag in the Chrome Web Store review queue and a + * meaningful privacy risk), we declare the same pattern as optional_host_permissions + * and request it dynamically once we know the URL. + * + * The prompt is a native Chrome dialog; the user must click "Allow". + */ + async function ensureHostPermission(endpoint) { + let origin; + try { + const url = new URL(endpoint); + origin = `${url.protocol}//${url.host}/*`; + } catch (err) { + throw new Error(`Invalid URL: ${err.message}`); + } + + const already = await chrome.permissions.contains({ origins: [origin] }); + if (already) return true; + + const granted = await chrome.permissions.request({ origins: [origin] }); + if (!granted) { + throw new Error('Permission denied. Open Brain Capture needs access to this origin to send captures.'); + } + return true; + } + + async function loadExistingConfig() { + const config = await OBConfig.getConfig(); + endpointInput.value = config.apiEndpoint || ''; + keyInput.value = config.apiKey || ''; + } + + async function saveConfig() { + saveBtn.disabled = true; + showResult('Saving...', ''); + + try { + const endpoint = normalizeEndpoint(endpointInput.value); + const apiKey = String(keyInput.value || '').trim(); + + if (!endpoint) { + throw new Error('Enter your Open Brain REST API URL.'); + } + if (!apiKey) { + throw new Error('Enter your x-brain-key API key.'); + } + + await ensureHostPermission(endpoint); + + const response = await chrome.runtime.sendMessage({ + type: 'SAVE_CONFIG', + config: { + apiEndpoint: endpoint, + apiKey + } + }); + + if (!response || !response.ok) { + throw new Error(response?.error || 'Failed to save configuration'); + } + + showResult('Saved. You can close this tab and use the extension popup.', 'success'); + } catch (err) { + showResult(err.message, 'error'); + } finally { + saveBtn.disabled = false; + } + } + + async function testConnection() { + testBtn.disabled = true; + showResult('Testing...', ''); + + try { + const endpoint = normalizeEndpoint(endpointInput.value); + const apiKey = String(keyInput.value || '').trim(); + if (!endpoint || !apiKey) { + throw new Error('Fill in both fields before testing.'); + } + + await ensureHostPermission(endpoint); + + const response = await chrome.runtime.sendMessage({ + type: 'TEST_CONNECTION', + config: { apiEndpoint: endpoint, apiKey } + }); + if (!response || !response.ok) { + throw new Error(response?.error || 'Health check failed'); + } + showResult(`Connected: ${response.result?.service || 'open-brain-rest'} is healthy`, 'success'); + } catch (err) { + showResult(err.message, 'error'); + } finally { + testBtn.disabled = false; + } + } + + saveBtn.addEventListener('click', saveConfig); + testBtn.addEventListener('click', testConnection); + + loadExistingConfig().catch((err) => { + console.error('[Open Brain Capture] Config page init failed', err); + showResult(err.message, 'error'); + }); +})(); diff --git a/integrations/chrome-capture-extension/popup/popup.css b/integrations/chrome-capture-extension/popup/popup.css new file mode 100644 index 00000000..e06bdc88 --- /dev/null +++ b/integrations/chrome-capture-extension/popup/popup.css @@ -0,0 +1,460 @@ +* { + box-sizing: border-box; +} + +body { + width: 400px; + min-height: 520px; + margin: 0; + background: #171a24; + color: #edf0f7; + font: 13px/1.45 'Segoe UI', system-ui, sans-serif; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid #2b3140; +} + +h1, +h2, +p { + margin: 0; +} + +.subtitle { + margin-top: 2px; + color: #8f99b2; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #5c667d; +} + +.status-dot.connected { + background: #37b36b; + box-shadow: 0 0 10px rgba(55, 179, 107, 0.65); +} + +.status-dot.error { + background: #ef5d67; + box-shadow: 0 0 10px rgba(239, 93, 103, 0.65); +} + +.config-missing { + background: #2a2214; + border-left: 4px solid #d6a53d; + color: #f5d77a; + padding: 14px 16px; +} + +.config-missing h2 { + font-size: 14px; + margin-bottom: 6px; +} + +.config-missing p { + margin-bottom: 10px; +} + +.tabs { + display: flex; + gap: 8px; + padding: 8px 16px 0; + border-bottom: 1px solid #2b3140; +} + +.tab { + border: 0; + background: transparent; + color: #8f99b2; + padding: 10px 0; + cursor: pointer; + border-bottom: 2px solid transparent; + font-weight: 600; +} + +.tab.active { + color: #edf0f7; + border-bottom-color: #52a3ff; +} + +.tab-panel { + display: none; + padding: 14px 16px 16px; +} + +.tab-panel.active { + display: block; +} + +.callout, +.hint { + padding: 10px 12px; + border-radius: 10px; + background: #22283a; + color: #b7c1d8; + margin-bottom: 14px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 14px; +} + +.stat-card { + padding: 12px; + border-radius: 12px; + background: #202636; + border: 1px solid #2f374a; +} + +.stat-label { + display: block; + color: #8f99b2; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.stat-value { + display: block; + margin-top: 8px; + font-size: 22px; + font-weight: 700; +} + +.overview-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid #232a3c; +} + +.overview-label { + color: #8f99b2; +} + +.pill { + padding: 2px 8px; + border-radius: 999px; + background: #263958; + color: #cfe2ff; + font-size: 11px; +} + +.platform-summary, +.endpoint-summary { + text-align: right; + max-width: 220px; + color: #edf0f7; + word-break: break-all; +} + +.capture-action { + margin-bottom: 14px; +} + +.btn-capture { + width: 100%; + padding: 11px 12px; + font-weight: 600; + background: #27784c; +} + +.btn-capture:hover:not(:disabled) { + background: #2d8a57; +} + +.actions, +.settings-footer { + margin-top: 14px; +} + +.actions { + display: flex; + gap: 8px; +} + +.btn, +.text-button, +select, +input[type='text'], +input[type='password'] { + font: inherit; +} + +.btn { + border: 0; + border-radius: 10px; + background: #3c78d8; + color: #fff; + padding: 9px 12px; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.btn-secondary { + background: #30384d; +} + +.result { + min-height: 18px; + margin-top: 10px; + color: #8f99b2; +} + +.result.success { + color: #6fd39b; +} + +.result.error { + color: #ef7d86; +} + +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 18px; + margin-bottom: 8px; +} + +.text-button { + border: 0; + background: transparent; + color: #8f99b2; + cursor: pointer; +} + +.capture-log { + max-height: 190px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.empty-state { + color: #76809a; + padding: 20px 0; + text-align: center; +} + +.log-item { + background: #202636; + border: 1px solid #2f374a; + border-left: 4px solid #4b5670; + border-radius: 10px; + padding: 10px 12px; +} + +.log-item.complete, +.log-item.captured, +.log-item.retry_sent { + border-left-color: #37b36b; +} + +.log-item.existing, +.log-item.duplicate_fingerprint, +.log-item.restricted_blocked, +.log-item.manual_mode, +.log-item.too_short { + border-left-color: #d6a53d; +} + +.log-item.dead_letter, +.log-item.queued_retry, +.log-item.error { + border-left-color: #ef5d67; +} + +.log-line { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8f99b2; +} + +.log-preview { + color: #edf0f7; +} + +.log-detail { + margin-top: 4px; + color: #8f99b2; + font-size: 11px; +} + +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + color: #b7c1d8; +} + +input[type='text'], +input[type='password'], +input[type='url'], +select { + width: 100%; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid #30384d; + background: #202636; + color: #edf0f7; +} + +input[type='range'] { + width: 100%; + accent-color: #52a3ff; +} + +.toggle-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.toggle { + display: flex; + align-items: center; + gap: 6px; + background: #202636; + border: 1px solid #30384d; + border-radius: 10px; + padding: 10px 8px; + color: #edf0f7; +} + +.settings-footer { + color: #8f99b2; + font-size: 11px; +} + +.sync-status-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + margin-bottom: 12px; + border-bottom: 1px solid #232a3c; +} + +.sync-actions { + margin-bottom: 14px; +} + +.sync-progress-area { + margin-bottom: 14px; +} + +.sync-progress-bar-track { + width: 100%; + height: 8px; + background: #202636; + border-radius: 999px; + border: 1px solid #2f374a; + overflow: hidden; +} + +.sync-progress-bar { + height: 100%; + background: #3c78d8; + border-radius: 999px; + transition: width 0.3s ease; +} + +.sync-progress-text { + margin-top: 6px; + color: #8f99b2; + font-size: 11px; +} + +.sync-auto-row { + margin: 14px 0; +} + +.sync-toggle { + width: 100%; +} + +/* Config page layout (popup/config.html renders in a full tab). */ +.config-page { + max-width: 560px; + margin: 40px auto; + padding: 24px; + background: #1c2030; + border-radius: 16px; + border: 1px solid #2b3140; +} + +.config-page h1 { + font-size: 20px; + margin-bottom: 6px; +} + +.config-page .subtitle { + margin-bottom: 18px; +} + +.config-page .hint-block { + background: #202636; + border-radius: 10px; + padding: 10px 12px; + color: #b7c1d8; + margin-bottom: 18px; + font-size: 12px; + line-height: 1.5; +} + +.config-page .hint-block code { + background: #171a24; + padding: 1px 6px; + border-radius: 6px; + font-size: 11px; +} + +.config-page .permission-note { + margin-top: 10px; + font-size: 12px; + color: #8f99b2; +} + +.config-page .config-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-thumb { + background: #3b445a; + border-radius: 999px; +} diff --git a/integrations/chrome-capture-extension/popup/popup.html b/integrations/chrome-capture-extension/popup/popup.html new file mode 100644 index 00000000..096963c9 --- /dev/null +++ b/integrations/chrome-capture-extension/popup/popup.html @@ -0,0 +1,181 @@ + + + + + + Open Brain Capture + + + +
+
+

Open Brain Capture

+

Capture Claude / ChatGPT / Gemini

+
+
+
+ + + + + + +
+
+ +

Open a Claude, ChatGPT, or Gemini conversation tab and click Capture to send the latest user + assistant exchange to Open Brain.

+
+
+ +
+
+ Sent This Session + 0 +
+
+ Queued Retries + 0 +
+
+ Skipped + 0 +
+
+ Failures + 0 +
+
+ +
+ Capture mode + auto +
+
+ Platforms + ChatGPT, Claude, Gemini +
+
+ Minimum response + 100 chars +
+
+ API endpoint + (not configured) +
+ +
+ + +
+
+ +
+

Activity

+ +
+
+

No extension activity yet.

+
+
+ +
+

Claude

+
+ Last synced + Never +
+
+ + +
+
+ +
+ +

ChatGPT

+
+ Last synced + Never +
+
+ + +
+
+ +
+ +

Gemini

+

Google does not currently expose a conversation API for Gemini. Manual capture from an active tab is supported; bulk sync is not.

+ + +
+ +

Sync Log

+
+

No sync activity yet.

+
+
+ +
+
+ +

Opens the first-run Configure screen where you can change the Open Brain REST API URL and API key. Values are stored in chrome.storage.local on this device.

+
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ Client-side sensitivity filtering runs before any payload leaves the browser. The API key stays in device-local extension storage (chrome.storage.local) and is never synced across Chrome profiles. +
+ + +
+ + + + + + diff --git a/integrations/chrome-capture-extension/popup/popup.js b/integrations/chrome-capture-extension/popup/popup.js new file mode 100644 index 00000000..8fcebbea --- /dev/null +++ b/integrations/chrome-capture-extension/popup/popup.js @@ -0,0 +1,471 @@ +(function () { + 'use strict'; + + const settingsKey = OBConfig.STORAGE_KEYS.settings; + const apiKeyStorageKey = OBConfig.STORAGE_KEYS.apiKey; + + const statusDot = document.getElementById('status-dot'); + const configMissing = document.getElementById('config-missing'); + const openConfigBtn = document.getElementById('open-config-btn'); + const reconfigureBtn = document.getElementById('reconfigure-btn'); + const tabs = Array.from(document.querySelectorAll('.tab')); + const panels = Array.from(document.querySelectorAll('.tab-panel')); + const sentCount = document.getElementById('sent-count'); + const queuedCount = document.getElementById('queued-count'); + const skippedCount = document.getElementById('skipped-count'); + const failedCount = document.getElementById('failed-count'); + const captureModeSummary = document.getElementById('capture-mode-summary'); + const platformSummary = document.getElementById('platform-summary'); + const minLengthSummary = document.getElementById('min-length-summary'); + const endpointSummary = document.getElementById('endpoint-summary'); + const captureLog = document.getElementById('capture-log'); + const captureModeSelect = document.getElementById('capture-mode'); + const enabledChatgpt = document.getElementById('enabled-chatgpt'); + const enabledClaude = document.getElementById('enabled-claude'); + const enabledGemini = document.getElementById('enabled-gemini'); + const minLengthInput = document.getElementById('min-length'); + const minLengthValue = document.getElementById('min-length-value'); + const captureCurrentButton = document.getElementById('capture-current'); + const captureResult = document.getElementById('capture-result'); + const testConnectionButton = document.getElementById('test-connection'); + const flushRetryButton = document.getElementById('flush-retry'); + const clearHistoryButton = document.getElementById('clear-history'); + const testResult = document.getElementById('test-result'); + + // Sync tab elements (Claude) + const syncLastTime = document.getElementById('sync-last-time'); + const syncAllBtn = document.getElementById('sync-all-btn'); + const syncIncrementalBtn = document.getElementById('sync-incremental-btn'); + const syncProgressArea = document.getElementById('sync-progress-area'); + const syncProgressBar = document.getElementById('sync-progress-bar'); + const syncProgressText = document.getElementById('sync-progress-text'); + const syncResult = document.getElementById('sync-result'); + const syncAutoToggle = document.getElementById('sync-auto-toggle'); + const syncLog = document.getElementById('sync-log'); + + // Sync tab elements (ChatGPT) + const chatgptSyncLastTime = document.getElementById('chatgpt-sync-last-time'); + const chatgptSyncAllBtn = document.getElementById('chatgpt-sync-all-btn'); + const chatgptSyncIncrementalBtn = document.getElementById('chatgpt-sync-incremental-btn'); + const chatgptSyncAutoToggle = document.getElementById('chatgpt-sync-auto-toggle'); + + function setStatusDot(connected, errored) { + statusDot.className = 'status-dot'; + if (errored) { + statusDot.classList.add('error'); + statusDot.title = 'Configuration or API error'; + return; + } + if (connected) { + statusDot.classList.add('connected'); + statusDot.title = 'Open Brain API configured'; + return; + } + statusDot.classList.add('disconnected'); + statusDot.title = 'Open Brain not configured'; + } + + function showResult(message, kind) { + testResult.textContent = message; + testResult.className = `result ${kind || ''}`.trim(); + } + + function formatTime(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + function formatPlatformSummary(enabledPlatforms) { + return Object.entries(enabledPlatforms) + .filter((entry) => entry[1]) + .map((entry) => OBConfig.getPlatformDefinition(entry[0])?.label || entry[0]) + .join(', ') || 'None enabled'; + } + + function openConfigPage() { + chrome.tabs.create({ url: chrome.runtime.getURL('popup/config.html') }); + } + + async function saveMutableSettings() { + // NOTE: API URL and key are only editable on the config page. Here we + // only persist toggles and thresholds so accidental popup edits can't + // nuke the user's configured credentials. + const current = await OBConfig.getConfig(); + const merged = OBConfig.mergeSettings({ + ...current, + enabledPlatforms: { + chatgpt: enabledChatgpt.checked, + claude: enabledClaude.checked, + gemini: enabledGemini.checked + }, + captureMode: captureModeSelect.value, + minResponseLength: Number(minLengthInput.value) + }); + + await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config: merged }); + renderSettings(merged); + } + + function renderSettings(config) { + captureModeSelect.value = config.captureMode; + enabledChatgpt.checked = Boolean(config.enabledPlatforms.chatgpt); + enabledClaude.checked = Boolean(config.enabledPlatforms.claude); + enabledGemini.checked = Boolean(config.enabledPlatforms.gemini); + minLengthInput.value = config.minResponseLength; + minLengthValue.textContent = String(config.minResponseLength); + + captureModeSummary.textContent = config.captureMode; + platformSummary.textContent = formatPlatformSummary(config.enabledPlatforms); + minLengthSummary.textContent = `${config.minResponseLength} chars`; + endpointSummary.textContent = config.apiEndpoint || '(not configured)'; + + const isConfigured = OBConfig.isConfigured(config); + configMissing.hidden = isConfigured; + setStatusDot(isConfigured, false); + } + + async function loadStatus() { + const status = await chrome.runtime.sendMessage({ type: 'GET_STATUS' }); + if (!status || !status.ok) { + setStatusDot(false, true); + return; + } + + const metrics = status.sessionMetrics || {}; + sentCount.textContent = String(metrics.sent || 0); + queuedCount.textContent = String(metrics.queued || 0); + skippedCount.textContent = String(metrics.skipped || 0); + failedCount.textContent = String(metrics.failed || 0); + + if (!status.configured) { + configMissing.hidden = false; + setStatusDot(false, false); + return; + } + + configMissing.hidden = true; + setStatusDot(true, Boolean(metrics.lastError)); + } + + async function loadActivityLog() { + const result = await chrome.storage.local.get({ + [OBConfig.STORAGE_KEYS.captureLog]: [] + }); + const log = result[OBConfig.STORAGE_KEYS.captureLog] || []; + + if (log.length === 0) { + captureLog.innerHTML = ''; + const emptyState = document.createElement('p'); + emptyState.className = 'empty-state'; + emptyState.textContent = 'No extension activity yet.'; + captureLog.appendChild(emptyState); + return; + } + + captureLog.innerHTML = ''; + [...log].reverse().forEach((entry) => { + const item = document.createElement('div'); + item.className = `log-item ${entry.status || 'info'}`; + + const line = document.createElement('div'); + line.className = 'log-line'; + + const status = document.createElement('span'); + status.className = 'log-status'; + status.textContent = entry.status || 'info'; + line.appendChild(status); + + const time = document.createElement('span'); + time.className = 'log-time'; + time.textContent = formatTime(entry.timestamp); + line.appendChild(time); + + const preview = document.createElement('div'); + preview.className = 'log-preview'; + preview.textContent = entry.preview || '(no preview)'; + + const detail = document.createElement('div'); + detail.className = 'log-detail'; + detail.textContent = entry.detail || ''; + + item.appendChild(line); + item.appendChild(preview); + item.appendChild(detail); + captureLog.appendChild(item); + }); + } + + async function refresh() { + const config = await OBConfig.getConfig(); + renderSettings(config); + await loadStatus(); + await loadActivityLog(); + await loadSyncStates(); + } + + tabs.forEach((tab) => { + tab.addEventListener('click', () => { + tabs.forEach((candidate) => candidate.classList.remove('active')); + panels.forEach((candidate) => candidate.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); + }); + }); + + [captureModeSelect, enabledChatgpt, enabledClaude, enabledGemini, minLengthInput].forEach((element) => { + element.addEventListener('input', saveMutableSettings); + element.addEventListener('change', saveMutableSettings); + }); + + minLengthInput.addEventListener('input', () => { + minLengthValue.textContent = minLengthInput.value; + }); + + openConfigBtn.addEventListener('click', openConfigPage); + reconfigureBtn.addEventListener('click', openConfigPage); + + function showCaptureResult(message, kind) { + captureResult.textContent = message; + captureResult.className = `result ${kind || ''}`.trim(); + } + + captureCurrentButton.addEventListener('click', async () => { + captureCurrentButton.disabled = true; + showCaptureResult('Capturing...', ''); + + try { + const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ACTIVE_TAB' }); + + if (!response || !response.ok) { + throw new Error(response?.error || 'Capture failed'); + } + + const status = response.status || 'captured'; + if (status === 'duplicate_fingerprint') { + showCaptureResult('Already captured (duplicate).', 'success'); + } else if (status === 'restricted_blocked') { + showCaptureResult('Blocked: contains restricted content.', 'error'); + } else if (status === 'queued_retry') { + showCaptureResult('Network error — queued for retry.', 'error'); + } else { + showCaptureResult('Captured successfully!', 'success'); + } + + await refresh(); + } catch (error) { + showCaptureResult(error.message, 'error'); + } finally { + captureCurrentButton.disabled = false; + } + }); + + testConnectionButton.addEventListener('click', async () => { + testConnectionButton.disabled = true; + showResult('Testing connection...', ''); + + try { + const config = await OBConfig.getConfig(); + if (!OBConfig.isConfigured(config)) { + throw new Error('Open Brain is not configured. Click "Reconfigure API URL & Key" on the Settings tab.'); + } + const response = await chrome.runtime.sendMessage({ + type: 'TEST_CONNECTION', + config + }); + + if (!response || !response.ok) { + throw new Error(response?.error || 'Connection test failed'); + } + + showResult(`Connected: ${response.result?.service || 'open-brain-rest'} is healthy`, 'success'); + setStatusDot(true, false); + } catch (error) { + showResult(error.message, 'error'); + setStatusDot(false, true); + } finally { + testConnectionButton.disabled = false; + } + }); + + flushRetryButton.addEventListener('click', async () => { + flushRetryButton.disabled = true; + showResult('Processing retry queue...', ''); + + try { + const response = await chrome.runtime.sendMessage({ type: 'FLUSH_RETRY_QUEUE' }); + if (!response || !response.ok) { + throw new Error(response?.error || 'Retry flush failed'); + } + showResult(`Processed ${response.processed} queued item(s), ${response.remaining} remaining`, 'success'); + await refresh(); + } catch (error) { + showResult(error.message, 'error'); + } finally { + flushRetryButton.disabled = false; + } + }); + + clearHistoryButton.addEventListener('click', async () => { + await chrome.runtime.sendMessage({ type: 'CLEAR_ACTIVITY_LOG' }); + await loadActivityLog(); + }); + + // --- Sync tab logic --- + + function showSyncResult(message, kind) { + syncResult.textContent = message; + syncResult.className = `result ${kind || ''}`.trim(); + } + + function formatSyncTime(isoString) { + if (!isoString) return 'Never'; + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return 'Never'; + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + async function loadSyncStates() { + try { + const response = await chrome.runtime.sendMessage({ type: 'GET_SYNC_STATE' }); + if (response && response.ok && response.syncState) { + syncLastTime.textContent = formatSyncTime(response.syncState.lastSyncAt); + syncAutoToggle.checked = Boolean(response.syncState.autoSyncEnabled); + } + } catch (err) { + console.error('[Open Brain Capture] Failed to load Claude sync state', err); + } + try { + const response = await chrome.runtime.sendMessage({ type: 'GET_CHATGPT_SYNC_STATE' }); + if (response && response.ok && response.syncState) { + chatgptSyncLastTime.textContent = formatSyncTime(response.syncState.lastSyncAt); + chatgptSyncAutoToggle.checked = Boolean(response.syncState.autoSyncEnabled); + } + } catch (err) { + console.error('[Open Brain Capture] Failed to load ChatGPT sync state', err); + } + } + + function addSyncLogEntry(message) { + const emptyState = syncLog.querySelector('.empty-state'); + if (emptyState) emptyState.remove(); + + const item = document.createElement('div'); + item.className = 'log-item captured'; + const line = document.createElement('div'); + line.className = 'log-line'; + const time = document.createElement('span'); + time.className = 'log-time'; + time.textContent = formatTime(new Date().toISOString()); + line.appendChild(time); + const detail = document.createElement('div'); + detail.className = 'log-preview'; + detail.textContent = message; + item.appendChild(line); + item.appendChild(detail); + + syncLog.prepend(item); + while (syncLog.children.length > 10) { + syncLog.removeChild(syncLog.lastChild); + } + } + + async function runSync(type, platform) { + const prefix = platform === 'chatgpt' ? 'CHATGPT_' : ''; + const messageType = type === 'all' ? `${prefix}SYNC_ALL` : `${prefix}SYNC_INCREMENTAL`; + const platformLabel = platform === 'chatgpt' ? 'ChatGPT' : 'Claude'; + const label = `${platformLabel} ${type === 'all' ? 'full sync' : 'incremental sync'}`; + + syncAllBtn.disabled = true; + syncIncrementalBtn.disabled = true; + chatgptSyncAllBtn.disabled = true; + chatgptSyncIncrementalBtn.disabled = true; + syncProgressArea.style.display = 'block'; + syncProgressBar.style.width = '0%'; + syncProgressText.textContent = `Starting ${label.toLowerCase()}...`; + showSyncResult('', ''); + + try { + const response = await chrome.runtime.sendMessage({ type: messageType }); + + syncProgressBar.style.width = '100%'; + + if (!response || response.error) { + throw new Error(response?.error || `${label} failed`); + } + + const total = response.total || 0; + const synced = response.synced || 0; + const skipped = response.skipped || 0; + const errors = response.errors || 0; + + const summary = `${label}: ${synced} captured, ${skipped} skipped, ${errors} errors (${total} total)`; + syncProgressText.textContent = summary; + showSyncResult(summary, errors > 0 ? 'error' : 'success'); + addSyncLogEntry(summary); + + await loadSyncStates(); + await loadActivityLog(); + } catch (err) { + syncProgressText.textContent = 'Sync failed'; + showSyncResult(err.message, 'error'); + addSyncLogEntry(`Error: ${err.message}`); + } finally { + syncAllBtn.disabled = false; + syncIncrementalBtn.disabled = false; + chatgptSyncAllBtn.disabled = false; + chatgptSyncIncrementalBtn.disabled = false; + } + } + + syncAllBtn.addEventListener('click', () => runSync('all', 'claude')); + syncIncrementalBtn.addEventListener('click', () => runSync('incremental', 'claude')); + + syncAutoToggle.addEventListener('change', async () => { + try { + await chrome.runtime.sendMessage({ + type: 'SET_AUTO_SYNC', + enabled: syncAutoToggle.checked, + intervalMinutes: 15 + }); + showSyncResult( + syncAutoToggle.checked ? 'Claude auto-sync enabled (every 15 min)' : 'Claude auto-sync disabled', + 'success' + ); + } catch (err) { + showSyncResult(err.message, 'error'); + } + }); + + chatgptSyncAllBtn.addEventListener('click', () => runSync('all', 'chatgpt')); + chatgptSyncIncrementalBtn.addEventListener('click', () => runSync('incremental', 'chatgpt')); + + chatgptSyncAutoToggle.addEventListener('change', async () => { + try { + await chrome.runtime.sendMessage({ + type: 'SET_CHATGPT_AUTO_SYNC', + enabled: chatgptSyncAutoToggle.checked, + intervalMinutes: 15 + }); + showSyncResult( + chatgptSyncAutoToggle.checked ? 'ChatGPT auto-sync enabled (every 15 min)' : 'ChatGPT auto-sync disabled', + 'success' + ); + } catch (err) { + showSyncResult(err.message, 'error'); + } + }); + + refresh().catch((error) => { + console.error('[Open Brain Capture] Popup init failed', error); + showResult(error.message, 'error'); + setStatusDot(false, true); + }); +})(); From 400f5c160be944109b900cfa426d026fb0abe723 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:24:08 -0400 Subject: [PATCH 02/25] [integrations] Fix REVIEW-CODEX-P1-1: failed ingests no longer persisted as synced --- .../lib/sync-chatgpt.js | 18 +++++++++++---- .../lib/sync-claude.js | 22 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/integrations/chrome-capture-extension/lib/sync-chatgpt.js b/integrations/chrome-capture-extension/lib/sync-chatgpt.js index 0c854ebf..f32d96e4 100644 --- a/integrations/chrome-capture-extension/lib/sync-chatgpt.js +++ b/integrations/chrome-capture-extension/lib/sync-chatgpt.js @@ -261,13 +261,18 @@ try { const result = await processOneConversation(accessToken, conv, captureHandler); - if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || + // See REVIEW-CODEX P1 #1: failed ingests must NOT persist the + // timestamp cursor, otherwise incremental sync skips them forever. + if (result && result.ok === false) { + errors++; + } else if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { skipped++; + timestamps[conv.id] = String(conv.update_time); } else { synced++; + timestamps[conv.id] = String(conv.update_time); } - timestamps[conv.id] = String(conv.update_time); } catch (err) { console.error(`[Open Brain Capture] Failed to sync ChatGPT conversation "${conv.title}":`, err); errors++; @@ -314,13 +319,18 @@ try { const result = await processOneConversation(accessToken, conv, captureHandler); - if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || + // See REVIEW-CODEX P1 #1: failed ingests must NOT persist the + // timestamp cursor, otherwise incremental sync skips them forever. + if (result && result.ok === false) { + errors++; + } else if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { skipped++; + updatedTimestamps[conv.id] = String(conv.update_time); } else { synced++; + updatedTimestamps[conv.id] = String(conv.update_time); } - updatedTimestamps[conv.id] = String(conv.update_time); } catch (err) { console.error(`[Open Brain Capture] Failed to sync ChatGPT conversation "${conv.title}":`, err); errors++; diff --git a/integrations/chrome-capture-extension/lib/sync-claude.js b/integrations/chrome-capture-extension/lib/sync-claude.js index 52e0d9df..39fb8eb0 100644 --- a/integrations/chrome-capture-extension/lib/sync-claude.js +++ b/integrations/chrome-capture-extension/lib/sync-claude.js @@ -213,12 +213,20 @@ try { const result = await processOneConversation(orgId, conv, captureHandler); - if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + // CRITICAL: only persist the timestamp cursor when the ingest truly + // succeeded. A {ok:false, status:'queued_retry'} means the payload + // went to the retry queue — if we saved the timestamp here, a later + // incremental sync would skip this conversation even if the retry + // eventually dead-lettered. See REVIEW-CODEX P1 #1. + if (result && result.ok === false) { + errors++; + } else if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { skipped++; + timestamps[conv.uuid] = conv.updated_at; } else { synced++; + timestamps[conv.uuid] = conv.updated_at; } - timestamps[conv.uuid] = conv.updated_at; } catch (err) { console.error(`[Open Brain Capture] Failed to sync conversation "${conv.name}":`, err); errors++; @@ -274,12 +282,18 @@ try { const result = await processOneConversation(orgId, conv, captureHandler); - if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { + // See note above in syncAll: do NOT persist updatedTimestamps when + // the ingest failed. Otherwise a subsequent incremental run will + // skip this conversation even though Open Brain never received it. + if (result && result.ok === false) { + errors++; + } else if (result && (result.status === 'skipped' || result.status === 'duplicate_fingerprint' || result.status === 'too_short' || result.status === 'restricted_blocked' || result.status === 'existing')) { skipped++; + updatedTimestamps[conv.uuid] = conv.updated_at; } else { synced++; + updatedTimestamps[conv.uuid] = conv.updated_at; } - updatedTimestamps[conv.uuid] = conv.updated_at; } catch (err) { console.error(`[Open Brain Capture] Failed to sync conversation "${conv.name}":`, err); errors++; From c6e754f4c62a58b826e0095806b1dfbb0af12838 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:25:13 -0400 Subject: [PATCH 03/25] [integrations] Fix REVIEW-CODEX-P1-2: https + loopback-only endpoint policy --- .../chrome-capture-extension/manifest.json | 3 ++- .../chrome-capture-extension/popup/config.js | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/integrations/chrome-capture-extension/manifest.json b/integrations/chrome-capture-extension/manifest.json index 18073a1c..636622a7 100644 --- a/integrations/chrome-capture-extension/manifest.json +++ b/integrations/chrome-capture-extension/manifest.json @@ -12,7 +12,8 @@ ], "optional_host_permissions": [ "https://*/*", - "http://*/*" + "http://localhost/*", + "http://127.0.0.1/*" ], "host_permissions": [ "https://chatgpt.com/*", diff --git a/integrations/chrome-capture-extension/popup/config.js b/integrations/chrome-capture-extension/popup/config.js index b8c6df36..42e39d80 100644 --- a/integrations/chrome-capture-extension/popup/config.js +++ b/integrations/chrome-capture-extension/popup/config.js @@ -12,11 +12,23 @@ result.className = `result ${kind || ''}`.trim(); } + // Transport-security policy: we require HTTPS for any non-loopback origin. + // Plaintext HTTP is only accepted for http://localhost and http://127.0.0.1 + // (with optional port) — the common self-hosted dev pattern. See + // REVIEW-CODEX P1 #2: accepting arbitrary http:// would let the extension + // send the user's x-brain-key and captured chat content in plaintext. + const ENDPOINT_POLICY_RE = + /^(https:\/\/|http:\/\/localhost(:\d+)?\/|http:\/\/127\.0\.0\.1(:\d+)?\/)/i; + function normalizeEndpoint(value) { const trimmed = String(value || '').trim().replace(/\/+$/, ''); if (!trimmed) return ''; - if (!/^https?:\/\//i.test(trimmed)) { - throw new Error('API URL must start with http:// or https://'); + // Append a trailing slash for the policy regex so "http://localhost" + // (no path yet) still matches the "http://localhost/" pattern. + if (!ENDPOINT_POLICY_RE.test(`${trimmed}/`)) { + throw new Error( + 'API URL must be https:// (or http://localhost / http://127.0.0.1 for local dev).' + ); } return trimmed; } From cfd179847fe24a27556254d1ea9c6d7970f75496 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:26:50 -0400 Subject: [PATCH 04/25] [integrations] Fix BLOCKER: API key/endpoint moved to local storage (privacy) --- .../chrome-capture-extension/README.md | 5 +- .../chrome-capture-extension/lib/config.js | 143 +++++++++++++----- 2 files changed, 113 insertions(+), 35 deletions(-) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index 84ed11dd..cb88e225 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -67,7 +67,8 @@ When you click **Save & Grant Permission**, Chrome shows a native permission pro **Storage details:** - API key → `chrome.storage.local` (per-device only, **never** synced across Chrome profiles) -- API URL + platform toggles + thresholds → `chrome.storage.sync` (follows your Google account across devices) +- API URL (`apiEndpoint`) → `chrome.storage.local` (per-device only). Rationale: the URL alone isn't a secret, but combining it with your Google-account-wide synced profiles would let anyone signed into the same Google account on a shared or loaner laptop see a pre-filled target for your Open Brain. Treating the endpoint as per-device avoids that surface, and also sidesteps `chrome.storage.sync`'s 8KB-per-item quota, which could silently reject saves for very long URLs. +- Platform toggles + capture mode + minimum response length → `chrome.storage.sync` (follows your Google account across devices). If `chrome.storage.sync` is unavailable (policy-managed profile, sync disabled, or quota exceeded) the extension transparently falls back to `chrome.storage.local` so saves never silently fail. ## Usage @@ -136,6 +137,8 @@ The `content_scripts` entries for `claude.ai`, `chatgpt.com`, and `gemini.google ## Security - **API key storage.** The `x-brain-key` lives in `chrome.storage.local`. Chrome encrypts local storage on disk with OS-level keys, and the key is **never** written to `chrome.storage.sync` — meaning it does not propagate to your other Chrome profiles on the same Google account. Rotate by reopening the Configure screen and saving a new value. Uninstalling the extension removes the key along with it. +- **API URL storage.** The Open Brain API URL (`apiEndpoint`) also lives in `chrome.storage.local` only, alongside the key. The URL itself isn't a secret, but sync-replicating it would leak your brain's location to any Chrome profile signed into the same Google account (shared laptops, family devices, loaner Chromebooks). Keeping the endpoint per-device avoids that pre-fill attack surface. +- **Transport security.** The Configure screen rejects any API URL that isn't `https://…` or `http://localhost` / `http://127.0.0.1` (with optional port). The manifest's `optional_host_permissions` reflects the same policy: `https://*/*` plus narrow loopback exceptions only. Plaintext `http://` endpoints over the public internet are not accepted — the `x-brain-key` header and captured conversation text would travel in the clear. - **Client-side sensitivity filtering.** `data/sensitivity-patterns.json` holds regex patterns for SSNs, passports, bank accounts, API keys, credit cards, passwords-in-URLs, and medical/financial markers. Anything matching a `restricted` pattern is blocked locally before the request is even built — the text never leaves the browser. `personal` matches are logged but allowed through. Patterns compile once per session and are tested with `String.prototype.match` regex semantics. - **Outbound requests.** Only the service worker calls `fetch()`, and only to the user-configured origin. No telemetry, no analytics, no third-party hosts. - **Retry queue integrity.** Failed captures live in `chrome.storage.local` with the full payload and a `nextRetryAt` timestamp. Retries honour exponential backoff (1, 2, 4, 8, 16 minutes, capped at 60), max 5 attempts, then a dead-letter entry in the activity log. Fingerprints live across retries so a retry-then-manual-retry doesn't produce duplicates in Open Brain. diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js index 2045de5e..9c724301 100644 --- a/integrations/chrome-capture-extension/lib/config.js +++ b/integrations/chrome-capture-extension/lib/config.js @@ -13,6 +13,10 @@ const STORAGE_KEYS = { settings: 'ob_capture_settings', apiKey: 'ob_capture_api_key', + // apiEndpoint moved to chrome.storage.local alongside apiKey — both are + // per-device and must not follow the user's Google account across + // profiles. See README Security section for rationale. + apiEndpoint: 'ob_capture_api_endpoint', captureLog: 'ob_capture_log', retryQueue: 'ob_capture_retry_queue', seenFingerprints: 'ob_capture_seen_fingerprints', @@ -149,65 +153,136 @@ /** * Read the full merged configuration from chrome.storage. * - * The API key lives in chrome.storage.local (NOT chrome.storage.sync — sync - * would replicate the key across every Chrome profile on the user's Google - * account, which is a footgun). All non-secret settings live in - * chrome.storage.sync so platform toggles, endpoint, and capture-mode - * choices follow the user between devices. + * Privacy posture (post REVIEW BLOCKER fix): + * - apiKey AND apiEndpoint now both live in chrome.storage.local only. + * Neither follows the user's Google account across devices. This + * avoids leaking the endpoint to loaner Chromebooks / shared profiles + * and sidesteps chrome.storage.sync's 8KB-per-item / 100KB-total + * quota, which can reject silently for long URLs + settings. + * - Non-secret preferences (platform toggles, captureMode, + * minResponseLength) still live in chrome.storage.sync so they + * follow the user. If sync is disabled or over quota we fall back + * to local-only transparently. */ async function getConfig() { - const [syncStored, localStored] = await Promise.all([ + const [syncStored, localStored, localSettings] = await Promise.all([ chrome.storage.sync.get({ [STORAGE_KEYS.settings]: DEFAULT_SETTINGS + }).catch(() => ({ [STORAGE_KEYS.settings]: DEFAULT_SETTINGS })), + chrome.storage.local.get({ + [STORAGE_KEYS.apiKey]: '', + [STORAGE_KEYS.apiEndpoint]: '' }), + // Fallback local-only settings blob (used when sync is unavailable). chrome.storage.local.get({ - [STORAGE_KEYS.apiKey]: '' + [STORAGE_KEYS.settings]: null }) ]); const syncSettings = mergeSettings(syncStored[STORAGE_KEYS.settings]); const localApiKey = String(localStored[STORAGE_KEYS.apiKey] || '').trim(); + const localApiEndpoint = String(localStored[STORAGE_KEYS.apiEndpoint] || '').trim(); + const localFallbackSettings = localSettings[STORAGE_KEYS.settings]; - // Migrate legacy installs that may have left the API key in sync storage. + // Migrate legacy installs where the API key lived in sync storage. if (!localApiKey && syncSettings.apiKey) { - await Promise.all([ - chrome.storage.local.set({ - [STORAGE_KEYS.apiKey]: syncSettings.apiKey - }), - chrome.storage.sync.set({ - [STORAGE_KEYS.settings]: { - ...syncSettings, - apiKey: '' - } - }) - ]); + try { + await Promise.all([ + chrome.storage.local.set({ + [STORAGE_KEYS.apiKey]: syncSettings.apiKey + }), + chrome.storage.sync.set({ + [STORAGE_KEYS.settings]: { + ...syncSettings, + apiKey: '', + apiEndpoint: '' + } + }) + ]); + } catch (err) { + console.warn('[Open Brain Capture] Legacy key migration hit storage error', err); + } + } + + // Migrate legacy installs where the API endpoint lived in sync storage. + // After this migration, sync keeps a blank apiEndpoint and the real + // value lives in chrome.storage.local only. + if (!localApiEndpoint && syncSettings.apiEndpoint) { + try { + await Promise.all([ + chrome.storage.local.set({ + [STORAGE_KEYS.apiEndpoint]: syncSettings.apiEndpoint + }), + chrome.storage.sync.set({ + [STORAGE_KEYS.settings]: { + ...syncSettings, + apiEndpoint: '', + apiKey: '' + } + }) + ]); + } catch (err) { + console.warn('[Open Brain Capture] Legacy endpoint migration hit storage error', err); + } } + // If sync storage was empty or unavailable and we have a local + // fallback settings blob, prefer the local one (covers policy-managed + // profiles and QUOTA_BYTES failures that forced a fallback at setConfig time). + const baseSettings = (!syncStored[STORAGE_KEYS.settings] || + syncStored[STORAGE_KEYS.settings] === DEFAULT_SETTINGS) && localFallbackSettings + ? mergeSettings(localFallbackSettings) + : syncSettings; + return mergeSettings({ - ...syncSettings, - apiKey: localApiKey || syncSettings.apiKey || '' + ...baseSettings, + apiEndpoint: localApiEndpoint || baseSettings.apiEndpoint || '', + apiKey: localApiKey || baseSettings.apiKey || '' }); } /** - * Persist a configuration update. Splits the secret apiKey into - * chrome.storage.local and everything else into chrome.storage.sync. + * Persist a configuration update. Writes: + * - apiKey + apiEndpoint to chrome.storage.local (private, per-device) + * - everything else to chrome.storage.sync (so toggles follow the user) + * If sync writes fail (QUOTA_BYTES, managed policy, disabled sync) we + * fall back to writing the non-secret settings blob to chrome.storage.local + * so the extension keeps working instead of silently dropping saves. */ async function setConfig(partial) { const current = await getConfig(); const merged = mergeSettings({ ...current, ...(partial || {}) }); - await Promise.all([ - chrome.storage.sync.set({ - [STORAGE_KEYS.settings]: { - ...merged, - apiKey: '' - } - }), - chrome.storage.local.set({ - [STORAGE_KEYS.apiKey]: merged.apiKey - }) - ]); + // Always write secrets to local first — this must not fail silently. + await chrome.storage.local.set({ + [STORAGE_KEYS.apiKey]: merged.apiKey, + [STORAGE_KEYS.apiEndpoint]: merged.apiEndpoint + }); + + const nonSecretSettings = { + ...merged, + apiKey: '', + apiEndpoint: '' + }; + + try { + await chrome.storage.sync.set({ + [STORAGE_KEYS.settings]: nonSecretSettings + }); + // If we previously wrote a local fallback copy, it's fine to leave it — + // getConfig() prefers sync when present. Overwriting the fallback on + // success would just be tidy-up and risks an extra failure vector. + } catch (err) { + // Typical causes: QUOTA_BYTES_PER_ITEM, enterprise policy disables + // sync, or the user signed out of Chrome sync. Fall back to local. + console.warn( + '[Open Brain Capture] chrome.storage.sync.set failed, falling back to local-only', + err + ); + await chrome.storage.local.set({ + [STORAGE_KEYS.settings]: nonSecretSettings + }); + } return merged; } From 4bcf3ab964eebf524153509db9bf517ee4678fe2 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:29:08 -0400 Subject: [PATCH 05/25] [integrations] Fix REVIEW-CODEX-P2: remove dead Capture Mode toggle --- .../chrome-capture-extension/README.md | 2 +- .../background/service-worker.js | 18 +++++++----------- .../chrome-capture-extension/lib/config.js | 16 ++++++++++------ .../chrome-capture-extension/popup/popup.html | 12 ------------ .../chrome-capture-extension/popup/popup.js | 7 +------ 5 files changed, 19 insertions(+), 36 deletions(-) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index cb88e225..fc377545 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -165,7 +165,7 @@ Alternatively, host the packed `.crx` on a maintainer-owned update URL and let u ## Known Limitations - **DOM extraction is fragile.** Claude, ChatGPT, and Gemini all ship UI rewrites without notice. When a platform shuffles its selectors, manual capture returns "No conversation turns found" until the extractor is updated. The Gemini extractor is especially exposed — Google ships new Gemini UIs every few months. Expect occasional maintenance PRs. Bulk sync (Claude + ChatGPT) uses stable internal JSON APIs and is far less fragile than DOM extraction. -- **No passive/ambient capture yet.** The extension only captures when the user explicitly clicks Capture or runs Sync. A previous "observe every turn" design was retired because keeping up with selector churn on every render was not sustainable. Re-introducing ambient capture is tracked as future work. +- **No passive/ambient capture.** The extension only captures when the user explicitly clicks Capture or runs Sync. A previous "observe every turn" design was retired because keeping up with selector churn on every render was not sustainable. The Settings panel has no Auto/Manual capture-mode toggle — that UI was dropped in the initial public release because it controlled only the ambient path. If ambient capture ever ships, the toggle comes back with it. - **Gemini has no bulk sync.** Google does not expose a conversation history API outside the Gemini UI. Manual capture is the only option. - **Large conversations.** The REST API `/ingest` endpoint accepts a single payload per request. A 400-turn Claude thread becomes one very large POST. If your gateway has a request size cap (Supabase default is 10MB), Sync All may dead-letter the longest conversations. Check the activity log and trim in your dashboard if that happens. - **Sensitivity filter is regex-only.** It's deliberately conservative — false negatives are possible. Treat it as a guardrail, not a vault. For truly sensitive content, don't paste it into an AI chat in the first place. diff --git a/integrations/chrome-capture-extension/background/service-worker.js b/integrations/chrome-capture-extension/background/service-worker.js index a018dd61..8126046e 100644 --- a/integrations/chrome-capture-extension/background/service-worker.js +++ b/integrations/chrome-capture-extension/background/service-worker.js @@ -188,7 +188,10 @@ async function queueRetry(item, errorMessage) { function normalizeCaptureRequest(message) { const platform = String(message.platform || '').trim().toLowerCase(); const text = String(message.text || message.content || '').trim(); - const captureMode = String(message.captureMode || 'ambient').trim().toLowerCase(); + // Capture mode is now either 'manual' (user click) or 'sync' (bulk import). + // Ambient capture was removed in the initial public release because it was + // never wired up; no producer in this extension emits 'ambient'. + const captureMode = String(message.captureMode || 'manual').trim().toLowerCase(); const sourceType = String(message.sourceType || '').trim() || OBConfig.getSourceType(platform, captureMode); const sourceLabel = String(message.sourceLabel || `${platform || 'unknown'}:${captureMode}`); const sourceMetadata = message.sourceMetadata && typeof message.sourceMetadata === 'object' @@ -225,15 +228,9 @@ async function processCaptureRequest(message) { return { ok: true, status: 'disabled_platform' }; } - if (config.captureMode === 'manual' && capture.captureMode === 'ambient') { - sessionMetrics.skipped += 1; - return { ok: true, status: 'manual_mode' }; - } - - if (capture.assistantLength < config.minResponseLength && capture.captureMode === 'ambient') { - sessionMetrics.skipped += 1; - return { ok: true, status: 'too_short' }; - } + // Ambient capture was removed — no passive observer ships yet. Manual + // clicks and bulk sync both bypass the minResponseLength gate on purpose: + // the user has explicitly asked for this turn to be captured. const sensitivity = await OBSensitivity.detectSensitivity(capture.text); if (sensitivity.tier === 'restricted') { @@ -430,7 +427,6 @@ async function getStatus() { apiEndpoint: config.apiEndpoint, apiKeyConfigured: Boolean(config.apiKey), enabledPlatforms: config.enabledPlatforms, - captureMode: config.captureMode, minResponseLength: config.minResponseLength }, sessionMetrics: { diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js index 9c724301..fdbb943f 100644 --- a/integrations/chrome-capture-extension/lib/config.js +++ b/integrations/chrome-capture-extension/lib/config.js @@ -39,7 +39,11 @@ claude: true, gemini: true }, - captureMode: 'auto', + // NOTE: the user-level "capture mode" setting (Auto/Manual toggle) was + // removed in the initial public release because ambient capture was + // never wired up. Per-message captureMode on ingest payloads remains + // ('manual' for user clicks, 'sync' for bulk import) — that's a + // source-provenance hint, not a user preference. minResponseLength: 100, autoSyncEnabled: false, autoSyncIntervalMinutes: 15 @@ -98,9 +102,9 @@ ...incoming.enabledPlatforms }; } - if (incoming.captureMode === 'manual' || incoming.captureMode === 'auto') { - merged.captureMode = incoming.captureMode; - } + // incoming.captureMode (auto/manual) is intentionally ignored — that + // user-preference toggle was removed. Legacy saved settings that still + // carry the field are harmless: they're simply dropped during merge. if (Number.isFinite(Number(incoming.minResponseLength))) { merged.minResponseLength = Math.max(0, Number(incoming.minResponseLength)); } @@ -159,8 +163,8 @@ * avoids leaking the endpoint to loaner Chromebooks / shared profiles * and sidesteps chrome.storage.sync's 8KB-per-item / 100KB-total * quota, which can reject silently for long URLs + settings. - * - Non-secret preferences (platform toggles, captureMode, - * minResponseLength) still live in chrome.storage.sync so they + * - Non-secret preferences (platform toggles, minResponseLength) still + * live in chrome.storage.sync so they * follow the user. If sync is disabled or over quota we fall back * to local-only transparently. */ diff --git a/integrations/chrome-capture-extension/popup/popup.html b/integrations/chrome-capture-extension/popup/popup.html index 096963c9..aa9eb298 100644 --- a/integrations/chrome-capture-extension/popup/popup.html +++ b/integrations/chrome-capture-extension/popup/popup.html @@ -55,10 +55,6 @@

Configure Open Brain

-
- Capture mode - auto -
Platforms ChatGPT, Claude, Gemini @@ -143,14 +139,6 @@

Gemini

Opens the first-run Configure screen where you can change the Open Brain REST API URL and API key. Values are stored in chrome.storage.local on this device.

-
- - -
-
diff --git a/integrations/chrome-capture-extension/popup/popup.js b/integrations/chrome-capture-extension/popup/popup.js index 8fcebbea..b3b0b290 100644 --- a/integrations/chrome-capture-extension/popup/popup.js +++ b/integrations/chrome-capture-extension/popup/popup.js @@ -14,12 +14,10 @@ const queuedCount = document.getElementById('queued-count'); const skippedCount = document.getElementById('skipped-count'); const failedCount = document.getElementById('failed-count'); - const captureModeSummary = document.getElementById('capture-mode-summary'); const platformSummary = document.getElementById('platform-summary'); const minLengthSummary = document.getElementById('min-length-summary'); const endpointSummary = document.getElementById('endpoint-summary'); const captureLog = document.getElementById('capture-log'); - const captureModeSelect = document.getElementById('capture-mode'); const enabledChatgpt = document.getElementById('enabled-chatgpt'); const enabledClaude = document.getElementById('enabled-claude'); const enabledGemini = document.getElementById('enabled-gemini'); @@ -101,7 +99,6 @@ claude: enabledClaude.checked, gemini: enabledGemini.checked }, - captureMode: captureModeSelect.value, minResponseLength: Number(minLengthInput.value) }); @@ -110,14 +107,12 @@ } function renderSettings(config) { - captureModeSelect.value = config.captureMode; enabledChatgpt.checked = Boolean(config.enabledPlatforms.chatgpt); enabledClaude.checked = Boolean(config.enabledPlatforms.claude); enabledGemini.checked = Boolean(config.enabledPlatforms.gemini); minLengthInput.value = config.minResponseLength; minLengthValue.textContent = String(config.minResponseLength); - captureModeSummary.textContent = config.captureMode; platformSummary.textContent = formatPlatformSummary(config.enabledPlatforms); minLengthSummary.textContent = `${config.minResponseLength} chars`; endpointSummary.textContent = config.apiEndpoint || '(not configured)'; @@ -215,7 +210,7 @@ }); }); - [captureModeSelect, enabledChatgpt, enabledClaude, enabledGemini, minLengthInput].forEach((element) => { + [enabledChatgpt, enabledClaude, enabledGemini, minLengthInput].forEach((element) => { element.addEventListener('input', saveMutableSettings); element.addEventListener('change', saveMutableSettings); }); From 9de1b964d62f878af10b256426f46c3dd6246e8f Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:30:13 -0400 Subject: [PATCH 06/25] [integrations] Fix REVIEW-CODEX-P3: sensitivity log consistency, prune unused exports --- .../chrome-capture-extension/README.md | 2 +- .../lib/api-client.js | 25 ++++--------------- .../chrome-capture-extension/lib/config.js | 10 -------- .../chrome-capture-extension/popup/popup.js | 3 --- 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index fc377545..cd0ca922 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -139,7 +139,7 @@ The `content_scripts` entries for `claude.ai`, `chatgpt.com`, and `gemini.google - **API key storage.** The `x-brain-key` lives in `chrome.storage.local`. Chrome encrypts local storage on disk with OS-level keys, and the key is **never** written to `chrome.storage.sync` — meaning it does not propagate to your other Chrome profiles on the same Google account. Rotate by reopening the Configure screen and saving a new value. Uninstalling the extension removes the key along with it. - **API URL storage.** The Open Brain API URL (`apiEndpoint`) also lives in `chrome.storage.local` only, alongside the key. The URL itself isn't a secret, but sync-replicating it would leak your brain's location to any Chrome profile signed into the same Google account (shared laptops, family devices, loaner Chromebooks). Keeping the endpoint per-device avoids that pre-fill attack surface. - **Transport security.** The Configure screen rejects any API URL that isn't `https://…` or `http://localhost` / `http://127.0.0.1` (with optional port). The manifest's `optional_host_permissions` reflects the same policy: `https://*/*` plus narrow loopback exceptions only. Plaintext `http://` endpoints over the public internet are not accepted — the `x-brain-key` header and captured conversation text would travel in the clear. -- **Client-side sensitivity filtering.** `data/sensitivity-patterns.json` holds regex patterns for SSNs, passports, bank accounts, API keys, credit cards, passwords-in-URLs, and medical/financial markers. Anything matching a `restricted` pattern is blocked locally before the request is even built — the text never leaves the browser. `personal` matches are logged but allowed through. Patterns compile once per session and are tested with `String.prototype.match` regex semantics. +- **Client-side sensitivity filtering.** `data/sensitivity-patterns.json` holds regex patterns for SSNs, passports, bank accounts, API keys, credit cards, passwords-in-URLs, and medical/financial markers. Anything matching a `restricted` pattern is blocked locally before the request is even built — the text never leaves the browser, and the activity log shows a `restricted_blocked` entry. `personal` matches pass through silently and are NOT logged — the intent is to capture them alongside the rest of the conversation, not to separately surface them. Patterns compile once per session and are tested with `String.prototype.match` regex semantics. - **Outbound requests.** Only the service worker calls `fetch()`, and only to the user-configured origin. No telemetry, no analytics, no third-party hosts. - **Retry queue integrity.** Failed captures live in `chrome.storage.local` with the full payload and a `nextRetryAt` timestamp. Retries honour exponential backoff (1, 2, 4, 8, 16 minutes, capped at 60), max 5 attempts, then a dead-letter entry in the activity log. Fingerprints live across retries so a retry-then-manual-retry doesn't produce duplicates in Open Brain. - **CSP.** Manifest V3 service workers run under a strict CSP that forbids `eval` and remote script loading. The lib scripts are all local. diff --git a/integrations/chrome-capture-extension/lib/api-client.js b/integrations/chrome-capture-extension/lib/api-client.js index 8a97e17f..c7efc1ee 100644 --- a/integrations/chrome-capture-extension/lib/api-client.js +++ b/integrations/chrome-capture-extension/lib/api-client.js @@ -73,30 +73,15 @@ }); } - async function captureThought(payload, options) { - return apiFetch('/capture', { - apiKey: options.apiKey, - endpoint: options.endpoint, - method: 'POST', - body: payload - }); - } - - async function searchThoughts(payload, options) { - return apiFetch('/search', { - apiKey: options.apiKey, - endpoint: options.endpoint, - method: 'POST', - body: payload - }); - } + // NOTE: /capture and /search helpers were dropped from the initial release + // — the extension is a one-way capture source. If a future revision needs + // to query Open Brain from the popup, reintroduce them here and wire + // through apiFetch with the same auth pattern. global.OBApiClient = { REQUEST_TIMEOUT_MS, apiFetch, healthCheck, - ingestDocument, - captureThought, - searchThoughts + ingestDocument }; })(typeof globalThis !== 'undefined' ? globalThis : self); diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js index fdbb943f..f9104143 100644 --- a/integrations/chrome-capture-extension/lib/config.js +++ b/integrations/chrome-capture-extension/lib/config.js @@ -145,15 +145,6 @@ return null; } - async function safe(label, fn, fallbackValue) { - try { - return await fn(); - } catch (error) { - console.error(`[Open Brain Capture] ${label}`, error); - return fallbackValue; - } - } - /** * Read the full merged configuration from chrome.storage. * @@ -309,7 +300,6 @@ getPlatformDefinition, getSourceType, resolvePlatformFromUrl, - safe, getConfig, setConfig, isConfigured diff --git a/integrations/chrome-capture-extension/popup/popup.js b/integrations/chrome-capture-extension/popup/popup.js index b3b0b290..2f85d779 100644 --- a/integrations/chrome-capture-extension/popup/popup.js +++ b/integrations/chrome-capture-extension/popup/popup.js @@ -1,9 +1,6 @@ (function () { 'use strict'; - const settingsKey = OBConfig.STORAGE_KEYS.settings; - const apiKeyStorageKey = OBConfig.STORAGE_KEYS.apiKey; - const statusDot = document.getElementById('status-dot'); const configMissing = document.getElementById('config-missing'); const openConfigBtn = document.getElementById('open-config-btn'); From 5d4f2f9de8738f20ccca94797fbbf30d387e0edc Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:30:43 -0400 Subject: [PATCH 07/25] [integrations] Fix WARNING: onInstalled auto-open on install only --- .../background/service-worker.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/integrations/chrome-capture-extension/background/service-worker.js b/integrations/chrome-capture-extension/background/service-worker.js index 8126046e..1f40f163 100644 --- a/integrations/chrome-capture-extension/background/service-worker.js +++ b/integrations/chrome-capture-extension/background/service-worker.js @@ -634,14 +634,18 @@ chrome.alarms.onAlarm.addListener((alarm) => { } }); -chrome.runtime.onInstalled.addListener(() => { +chrome.runtime.onInstalled.addListener((details) => { chrome.alarms.create(RETRY_ALARM_NAME, { periodInMinutes: 5 }); ensureSyncAlarm(); ensureChatGPTSyncAlarm(); refreshBadge(); - // On first install, open the config page so the user is immediately - // prompted to supply their Open Brain API URL and key. + // Only auto-open the Configure tab on a fresh install. onInstalled also + // fires for every update (including silent self-updates from the Chrome + // Web Store), and we don't want to fling the config page at users every + // time they get a patch release. The yellow "!" badge and the popup's + // config-missing banner are enough of a surface when setup is needed. + if (details.reason !== 'install') return; OBConfig.getConfig().then((config) => { if (!OBConfig.isConfigured(config)) { chrome.tabs.create({ url: chrome.runtime.getURL('popup/config.html') }); From 841e28dbeaf91bead2e1bad81716c7e5313bc127 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:31:21 -0400 Subject: [PATCH 08/25] [integrations] Fix WARNING: processingFingerprints Set leak --- .../background/service-worker.js | 137 ++++++++++-------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/integrations/chrome-capture-extension/background/service-worker.js b/integrations/chrome-capture-extension/background/service-worker.js index 1f40f163..4ebca719 100644 --- a/integrations/chrome-capture-extension/background/service-worker.js +++ b/integrations/chrome-capture-extension/background/service-worker.js @@ -250,77 +250,86 @@ async function processCaptureRequest(message) { sessionMetrics.skipped += 1; return { ok: true, status: 'duplicate_fingerprint', fingerprint }; } - processingFingerprints.add(fingerprint); - - const payload = { - text: capture.text, - source_label: capture.sourceLabel, - source_type: capture.sourceType, - auto_execute: capture.autoExecute, - source_metadata: { - ...capture.sourceMetadata, - extension_capture_mode: capture.captureMode, - extension_platform: capture.platform, - content_fingerprint: fingerprint - } - }; + // Important: the add() and all mutation that follows lives inside the + // try block so the finally guarantees cleanup. If an exception were to + // fire between add() and the ingest call, the old code would leak the + // fingerprint into processingFingerprints forever and hasKnownFingerprint + // would silently suppress any future capture of the same content. + let payload; try { - const result = await OBApiClient.ingestDocument(payload, { - apiKey: config.apiKey, - endpoint: config.apiEndpoint - }); + processingFingerprints.add(fingerprint); + + payload = { + text: capture.text, + source_label: capture.sourceLabel, + source_type: capture.sourceType, + auto_execute: capture.autoExecute, + source_metadata: { + ...capture.sourceMetadata, + extension_capture_mode: capture.captureMode, + extension_platform: capture.platform, + content_fingerprint: fingerprint + } + }; - await rememberFingerprint(fingerprint); - await appendCaptureLog({ - timestamp: new Date().toISOString(), - platform: capture.platform || 'unknown', - status: result && result.status ? result.status : 'captured', - preview: capture.preview, - detail: result && result.message ? result.message : '', - fingerprint: fingerprint.slice(0, 16) - }); + try { + const result = await OBApiClient.ingestDocument(payload, { + apiKey: config.apiKey, + endpoint: config.apiEndpoint + }); - if (result && result.status === 'existing') { - sessionMetrics.skipped += 1; - } else { - sessionMetrics.sent += 1; - } - sessionMetrics.lastError = ''; - await refreshBadge(); + await rememberFingerprint(fingerprint); + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: capture.platform || 'unknown', + status: result && result.status ? result.status : 'captured', + preview: capture.preview, + detail: result && result.message ? result.message : '', + fingerprint: fingerprint.slice(0, 16) + }); - return { - ok: true, - status: result && result.status ? result.status : 'captured', - result, - fingerprint - }; - } catch (error) { - const retryItem = { - platform: capture.platform || 'unknown', - preview: capture.preview, - payload, - fingerprint, - attempts: 0, - queuedAt: new Date().toISOString() - }; + if (result && result.status === 'existing') { + sessionMetrics.skipped += 1; + } else { + sessionMetrics.sent += 1; + } + sessionMetrics.lastError = ''; + await refreshBadge(); - await queueRetry(retryItem, error.message); - await appendCaptureLog({ - timestamp: new Date().toISOString(), - platform: capture.platform || 'unknown', - status: 'queued_retry', - preview: capture.preview, - detail: error.message, - fingerprint: fingerprint.slice(0, 16) - }); + return { + ok: true, + status: result && result.status ? result.status : 'captured', + result, + fingerprint + }; + } catch (error) { + const retryItem = { + platform: capture.platform || 'unknown', + preview: capture.preview, + payload, + fingerprint, + attempts: 0, + queuedAt: new Date().toISOString() + }; + + await queueRetry(retryItem, error.message); + await appendCaptureLog({ + timestamp: new Date().toISOString(), + platform: capture.platform || 'unknown', + status: 'queued_retry', + preview: capture.preview, + detail: error.message, + fingerprint: fingerprint.slice(0, 16) + }); - return { - ok: false, - status: 'queued_retry', - error: error.message, - fingerprint - }; + return { + ok: false, + status: 'queued_retry', + error: error.message, + fingerprint + }; + } } finally { processingFingerprints.delete(fingerprint); } From 8fe78fe6726ed2decf11793a0db0edf65fd1b450 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 09:32:08 -0400 Subject: [PATCH 09/25] [integrations] Document extractor fragility and known limitations --- integrations/chrome-capture-extension/README.md | 2 ++ integrations/chrome-capture-extension/metadata.json | 1 + 2 files changed, 3 insertions(+) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index cd0ca922..31aeebe9 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -164,6 +164,8 @@ Alternatively, host the packed `.crx` on a maintainer-owned update URL and let u ## Known Limitations +- **ChatGPT and Gemini extractors are best-effort and unverified against live pages.** The ChatGPT and Gemini DOM extractors were written from public selector knowledge (`[data-message-author-role]`, `` / `` Web Components, aria-label fallbacks) and have not been exhaustively verified on a live logged-in session at merge time. They may break with any vendor UI refresh — OpenAI and Google both ship Gemini/ChatGPT UI changes on short cadence. When they break, manual capture on those platforms will return "No conversation turns found" until a maintainer updates the selectors. The Claude manual-capture extractor walks open shadow roots and has been exercised against live claude.ai; it is more resilient. Bulk sync (Claude + ChatGPT) uses internal JSON APIs and is far less fragile than any DOM path. +- **Bulk sync depends on vendor-internal APIs that are not publicly supported.** Anthropic's `/api/organizations/.../chat_conversations` and OpenAI's `/backend-api/conversations` endpoints are undocumented and subject to change without notice. Expect periodic maintenance PRs. If you rely on auto-sync, monitor the Sync Log tab for sustained errors. - **DOM extraction is fragile.** Claude, ChatGPT, and Gemini all ship UI rewrites without notice. When a platform shuffles its selectors, manual capture returns "No conversation turns found" until the extractor is updated. The Gemini extractor is especially exposed — Google ships new Gemini UIs every few months. Expect occasional maintenance PRs. Bulk sync (Claude + ChatGPT) uses stable internal JSON APIs and is far less fragile than DOM extraction. - **No passive/ambient capture.** The extension only captures when the user explicitly clicks Capture or runs Sync. A previous "observe every turn" design was retired because keeping up with selector churn on every render was not sustainable. The Settings panel has no Auto/Manual capture-mode toggle — that UI was dropped in the initial public release because it controlled only the ambient path. If ambient capture ever ships, the toggle comes back with it. - **Gemini has no bulk sync.** Google does not expose a conversation history API outside the Gemini UI. Manual capture is the only option. diff --git a/integrations/chrome-capture-extension/metadata.json b/integrations/chrome-capture-extension/metadata.json index 04b552ef..7ca91e4f 100644 --- a/integrations/chrome-capture-extension/metadata.json +++ b/integrations/chrome-capture-extension/metadata.json @@ -12,6 +12,7 @@ "services": ["REST API gateway (PR #201)"], "tools": ["Chrome 120+ or Chromium-based browser"] }, + "_todo": "TODO(#201): replace 'REST API gateway (PR #201)' with the rest-api slug once that PR merges. Until then this dependency string is informational only — the README links out to ../rest-api/ which will resolve once the sibling contribution lands.", "tags": ["chrome-extension", "capture", "claude", "chatgpt", "gemini", "client-side"], "difficulty": "intermediate", "estimated_time": "20 minutes", From 5ae992556a62007db9eb44e52759b1d68679eb7e Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 14:11:16 -0400 Subject: [PATCH 10/25] [integrations] Fix REVIEW-CODEX-2-P2: local-storage fallback uses explicit flag not identity check --- .../chrome-capture-extension/lib/config.js | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js index f9104143..4edd84f4 100644 --- a/integrations/chrome-capture-extension/lib/config.js +++ b/integrations/chrome-capture-extension/lib/config.js @@ -17,6 +17,15 @@ // per-device and must not follow the user's Google account across // profiles. See README Security section for rationale. apiEndpoint: 'ob_capture_api_endpoint', + // Explicit boolean flag (chrome.storage.local) that signals the last + // setConfig() write had to fall back to local because chrome.storage.sync + // rejected the write (QUOTA_BYTES, managed policy, sync disabled). + // While this flag is true, getConfig() MUST read the non-secret settings + // blob from chrome.storage.local, not from sync — otherwise a subsequent + // sync read would return whatever stale/empty value sync holds and + // silently snap toggles back to defaults. The flag is cleared on the + // next successful sync write. + localFallbackActive: 'ob_capture_local_fallback_active', captureLog: 'ob_capture_log', retryQueue: 'ob_capture_retry_queue', seenFingerprints: 'ob_capture_seen_fingerprints', @@ -166,7 +175,8 @@ }).catch(() => ({ [STORAGE_KEYS.settings]: DEFAULT_SETTINGS })), chrome.storage.local.get({ [STORAGE_KEYS.apiKey]: '', - [STORAGE_KEYS.apiEndpoint]: '' + [STORAGE_KEYS.apiEndpoint]: '', + [STORAGE_KEYS.localFallbackActive]: false }), // Fallback local-only settings blob (used when sync is unavailable). chrome.storage.local.get({ @@ -177,6 +187,7 @@ const syncSettings = mergeSettings(syncStored[STORAGE_KEYS.settings]); const localApiKey = String(localStored[STORAGE_KEYS.apiKey] || '').trim(); const localApiEndpoint = String(localStored[STORAGE_KEYS.apiEndpoint] || '').trim(); + const localFallbackActive = Boolean(localStored[STORAGE_KEYS.localFallbackActive]); const localFallbackSettings = localSettings[STORAGE_KEYS.settings]; // Migrate legacy installs where the API key lived in sync storage. @@ -221,13 +232,27 @@ } } - // If sync storage was empty or unavailable and we have a local - // fallback settings blob, prefer the local one (covers policy-managed - // profiles and QUOTA_BYTES failures that forced a fallback at setConfig time). - const baseSettings = (!syncStored[STORAGE_KEYS.settings] || - syncStored[STORAGE_KEYS.settings] === DEFAULT_SETTINGS) && localFallbackSettings - ? mergeSettings(localFallbackSettings) - : syncSettings; + // Fallback selection: + // * If the explicit `localFallbackActive` flag is true, trust the + // local-stored settings — the last setConfig() write couldn't reach + // sync, so sync is known to be stale/empty/rejected. + // * Otherwise fall through to syncSettings. We intentionally do NOT + // use reference-identity against DEFAULT_SETTINGS here: deserialized + // chrome.storage.sync.get() results are always fresh objects and + // would never match the module-scope DEFAULT_SETTINGS instance, so + // the old `=== DEFAULT_SETTINGS` check was effectively dead and let + // non-secret settings silently snap back to defaults after a sync + // failure. Console-log while the fallback is active so the user can + // diagnose persistence issues. + let baseSettings; + if (localFallbackActive && localFallbackSettings) { + console.warn( + '[Open Brain Capture] Local fallback active — reading settings from chrome.storage.local (last sync write failed).' + ); + baseSettings = mergeSettings(localFallbackSettings); + } else { + baseSettings = syncSettings; + } return mergeSettings({ ...baseSettings, @@ -264,18 +289,26 @@ await chrome.storage.sync.set({ [STORAGE_KEYS.settings]: nonSecretSettings }); - // If we previously wrote a local fallback copy, it's fine to leave it — - // getConfig() prefers sync when present. Overwriting the fallback on - // success would just be tidy-up and risks an extra failure vector. + // Sync write succeeded — clear the fallback flag so getConfig() resumes + // reading from sync. A stale local-fallback blob left behind is + // harmless; the flag is what controls the read path. + await chrome.storage.local.set({ + [STORAGE_KEYS.localFallbackActive]: false + }); } catch (err) { // Typical causes: QUOTA_BYTES_PER_ITEM, enterprise policy disables - // sync, or the user signed out of Chrome sync. Fall back to local. + // sync, or the user signed out of Chrome sync. Fall back to local and + // flip the explicit flag so getConfig() reads from local on the next + // pass. Without the flag the fallback blob would be written but never + // read back (sync reads return an empty/stale value, so settings + // silently snap to defaults). console.warn( '[Open Brain Capture] chrome.storage.sync.set failed, falling back to local-only', err ); await chrome.storage.local.set({ - [STORAGE_KEYS.settings]: nonSecretSettings + [STORAGE_KEYS.settings]: nonSecretSettings, + [STORAGE_KEYS.localFallbackActive]: true }); } From 125b846fbef1c8d39d02df189d8d7760c6709cd8 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 14:13:15 -0400 Subject: [PATCH 11/25] [integrations] Fix REVIEW-CODEX-2-P2: remove dead minResponseLength slider --- .../background/service-worker.js | 8 +++--- .../chrome-capture-extension/lib/config.js | 25 +++++++++++-------- .../chrome-capture-extension/popup/popup.html | 9 ------- .../chrome-capture-extension/popup/popup.js | 17 +++---------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/integrations/chrome-capture-extension/background/service-worker.js b/integrations/chrome-capture-extension/background/service-worker.js index 4ebca719..b45d36b0 100644 --- a/integrations/chrome-capture-extension/background/service-worker.js +++ b/integrations/chrome-capture-extension/background/service-worker.js @@ -229,8 +229,9 @@ async function processCaptureRequest(message) { } // Ambient capture was removed — no passive observer ships yet. Manual - // clicks and bulk sync both bypass the minResponseLength gate on purpose: - // the user has explicitly asked for this turn to be captured. + // clicks and bulk sync are the only remaining paths and both capture + // unconditionally: the user (or a user-triggered sync) explicitly + // asked for this turn to be captured, so there is no length gate here. const sensitivity = await OBSensitivity.detectSensitivity(capture.text); if (sensitivity.tier === 'restricted') { @@ -435,8 +436,7 @@ async function getStatus() { settings: { apiEndpoint: config.apiEndpoint, apiKeyConfigured: Boolean(config.apiKey), - enabledPlatforms: config.enabledPlatforms, - minResponseLength: config.minResponseLength + enabledPlatforms: config.enabledPlatforms }, sessionMetrics: { ...sessionMetrics, diff --git a/integrations/chrome-capture-extension/lib/config.js b/integrations/chrome-capture-extension/lib/config.js index 4edd84f4..0553ca24 100644 --- a/integrations/chrome-capture-extension/lib/config.js +++ b/integrations/chrome-capture-extension/lib/config.js @@ -53,7 +53,13 @@ // never wired up. Per-message captureMode on ingest payloads remains // ('manual' for user clicks, 'sync' for bulk import) — that's a // source-provenance hint, not a user preference. - minResponseLength: 100, + // + // NOTE: the former `minResponseLength` slider was also removed: it + // only gated ambient capture, which does not exist. Manual capture and + // bulk sync deliberately bypass any such gate (the user explicitly + // asked to capture the turn), so the control was dead UI. Legacy saved + // settings that still carry `minResponseLength` are harmless — they + // are dropped during mergeSettings(). autoSyncEnabled: false, autoSyncIntervalMinutes: 15 }; @@ -111,12 +117,10 @@ ...incoming.enabledPlatforms }; } - // incoming.captureMode (auto/manual) is intentionally ignored — that - // user-preference toggle was removed. Legacy saved settings that still - // carry the field are harmless: they're simply dropped during merge. - if (Number.isFinite(Number(incoming.minResponseLength))) { - merged.minResponseLength = Math.max(0, Number(incoming.minResponseLength)); - } + // incoming.captureMode (auto/manual) and incoming.minResponseLength + // are intentionally ignored — those user-preference controls were + // removed. Legacy saved settings that still carry the fields are + // harmless: they're simply dropped during merge. return merged; } @@ -163,10 +167,9 @@ * avoids leaking the endpoint to loaner Chromebooks / shared profiles * and sidesteps chrome.storage.sync's 8KB-per-item / 100KB-total * quota, which can reject silently for long URLs + settings. - * - Non-secret preferences (platform toggles, minResponseLength) still - * live in chrome.storage.sync so they - * follow the user. If sync is disabled or over quota we fall back - * to local-only transparently. + * - Non-secret preferences (platform toggles) still live in + * chrome.storage.sync so they follow the user. If sync is disabled + * or over quota we fall back to local-only transparently. */ async function getConfig() { const [syncStored, localStored, localSettings] = await Promise.all([ diff --git a/integrations/chrome-capture-extension/popup/popup.html b/integrations/chrome-capture-extension/popup/popup.html index aa9eb298..bd29dd7a 100644 --- a/integrations/chrome-capture-extension/popup/popup.html +++ b/integrations/chrome-capture-extension/popup/popup.html @@ -59,10 +59,6 @@

Configure Open Brain

Platforms ChatGPT, Claude, Gemini
-
- Minimum response - 100 chars -
API endpoint (not configured) @@ -148,11 +144,6 @@

Gemini

-
- - -
-
Client-side sensitivity filtering runs before any payload leaves the browser. The API key stays in device-local extension storage (chrome.storage.local) and is never synced across Chrome profiles.
diff --git a/integrations/chrome-capture-extension/popup/popup.js b/integrations/chrome-capture-extension/popup/popup.js index 2f85d779..835f9149 100644 --- a/integrations/chrome-capture-extension/popup/popup.js +++ b/integrations/chrome-capture-extension/popup/popup.js @@ -12,14 +12,11 @@ const skippedCount = document.getElementById('skipped-count'); const failedCount = document.getElementById('failed-count'); const platformSummary = document.getElementById('platform-summary'); - const minLengthSummary = document.getElementById('min-length-summary'); const endpointSummary = document.getElementById('endpoint-summary'); const captureLog = document.getElementById('capture-log'); const enabledChatgpt = document.getElementById('enabled-chatgpt'); const enabledClaude = document.getElementById('enabled-claude'); const enabledGemini = document.getElementById('enabled-gemini'); - const minLengthInput = document.getElementById('min-length'); - const minLengthValue = document.getElementById('min-length-value'); const captureCurrentButton = document.getElementById('capture-current'); const captureResult = document.getElementById('capture-result'); const testConnectionButton = document.getElementById('test-connection'); @@ -86,7 +83,7 @@ async function saveMutableSettings() { // NOTE: API URL and key are only editable on the config page. Here we - // only persist toggles and thresholds so accidental popup edits can't + // only persist the platform toggles so accidental popup edits can't // nuke the user's configured credentials. const current = await OBConfig.getConfig(); const merged = OBConfig.mergeSettings({ @@ -95,8 +92,7 @@ chatgpt: enabledChatgpt.checked, claude: enabledClaude.checked, gemini: enabledGemini.checked - }, - minResponseLength: Number(minLengthInput.value) + } }); await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config: merged }); @@ -107,11 +103,8 @@ enabledChatgpt.checked = Boolean(config.enabledPlatforms.chatgpt); enabledClaude.checked = Boolean(config.enabledPlatforms.claude); enabledGemini.checked = Boolean(config.enabledPlatforms.gemini); - minLengthInput.value = config.minResponseLength; - minLengthValue.textContent = String(config.minResponseLength); platformSummary.textContent = formatPlatformSummary(config.enabledPlatforms); - minLengthSummary.textContent = `${config.minResponseLength} chars`; endpointSummary.textContent = config.apiEndpoint || '(not configured)'; const isConfigured = OBConfig.isConfigured(config); @@ -207,15 +200,11 @@ }); }); - [enabledChatgpt, enabledClaude, enabledGemini, minLengthInput].forEach((element) => { + [enabledChatgpt, enabledClaude, enabledGemini].forEach((element) => { element.addEventListener('input', saveMutableSettings); element.addEventListener('change', saveMutableSettings); }); - minLengthInput.addEventListener('input', () => { - minLengthValue.textContent = minLengthInput.value; - }); - openConfigBtn.addEventListener('click', openConfigPage); reconfigureBtn.addEventListener('click', openConfigPage); From 60e0159d35c14a12fe98e159862b97bfa7a58b5b Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 14:13:42 -0400 Subject: [PATCH 12/25] [integrations] Fix REVIEW-CODEX-2-P3: README matches current host permissions and UI --- integrations/chrome-capture-extension/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index 31aeebe9..579b7b19 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -68,7 +68,7 @@ When you click **Save & Grant Permission**, Chrome shows a native permission pro **Storage details:** - API key → `chrome.storage.local` (per-device only, **never** synced across Chrome profiles) - API URL (`apiEndpoint`) → `chrome.storage.local` (per-device only). Rationale: the URL alone isn't a secret, but combining it with your Google-account-wide synced profiles would let anyone signed into the same Google account on a shared or loaner laptop see a pre-filled target for your Open Brain. Treating the endpoint as per-device avoids that surface, and also sidesteps `chrome.storage.sync`'s 8KB-per-item quota, which could silently reject saves for very long URLs. -- Platform toggles + capture mode + minimum response length → `chrome.storage.sync` (follows your Google account across devices). If `chrome.storage.sync` is unavailable (policy-managed profile, sync disabled, or quota exceeded) the extension transparently falls back to `chrome.storage.local` so saves never silently fail. +- Platform toggles (ChatGPT / Claude / Gemini) → `chrome.storage.sync` (follows your Google account across devices). If `chrome.storage.sync` is unavailable (policy-managed profile, sync disabled, or quota exceeded) the extension transparently falls back to `chrome.storage.local` so saves never silently fail. ## Usage @@ -130,7 +130,7 @@ This extension uses **`optional_host_permissions` + runtime `chrome.permissions. | `host_permissions: [""]` | One-line manifest, no prompt flow | Chrome Web Store flags it as a high-risk permission, install-time prompt scares users, extension can hit any site | | `optional_host_permissions` + runtime request (chosen) | Minimum-viable permissions, user sees exactly which origin they're granting, survives Chrome Web Store review | Requires a Configure screen + one extra click during setup | -The extension declares `optional_host_permissions: ["https://*/*", "http://*/*"]` in the manifest. On the Configure screen it parses the user's URL, derives an origin pattern like `https://your-project-ref.supabase.co/*`, and calls `chrome.permissions.request({ origins: [origin] })`. The user approves once; Chrome persists the grant; the service worker can now `fetch()` that origin. Nothing else. +The extension declares `optional_host_permissions: ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]` in the manifest — the HTTPS wildcard covers public deployments, and the two loopback HTTP entries exist so local dev setups (e.g. `http://localhost:54321`) work without dropping TLS requirements for everyone else. On the Configure screen the extension parses the user's URL, derives an origin pattern like `https://your-project-ref.supabase.co/*`, and calls `chrome.permissions.request({ origins: [origin] })`. The user approves once; Chrome persists the grant; the service worker can now `fetch()` that origin. Nothing else. The `content_scripts` entries for `claude.ai`, `chatgpt.com`, and `gemini.google.com` remain as normal `host_permissions` because the content scripts inject at `document_idle` on page load — they can't wait for a runtime prompt. Those three origins are scoped narrowly and visible in the install dialog. From fa80269fab1be7390166f5ec70e600ed1d81d446 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Sat, 18 Apr 2026 18:43:48 -0400 Subject: [PATCH 13/25] [integrations] Fix CI Rule 13: convert broken relative links to external PR URLs --- integrations/chrome-capture-extension/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md index 579b7b19..5ffb2705 100644 --- a/integrations/chrome-capture-extension/README.md +++ b/integrations/chrome-capture-extension/README.md @@ -20,7 +20,7 @@ Placeholder. See [`docs/screenshots/README.md`](docs/screenshots/README.md) for ## Prerequisites - Working Open Brain setup ([guide](../../docs/01-getting-started.md)) -- The [REST API gateway integration](../rest-api/) deployed and reachable — the extension POSTs to `/open-brain-rest/ingest` and pings `/open-brain-rest/health` +- The [REST API gateway integration (PR #201)](https://github.com/NateBJones-Projects/OB1/pull/201) deployed and reachable — the extension POSTs to `/open-brain-rest/ingest` and pings `/open-brain-rest/health` - An `MCP_ACCESS_KEY` (or equivalent `x-brain-key` token) issued by your Open Brain for this device - Chrome 120+, or any Chromium-based browser that supports MV3 (Edge 120+, Brave, Arc, Opera) From addc1cd7b2401b0f5b61868d2fc4c20d3d361527 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Tue, 21 Apr 2026 16:38:02 -0400 Subject: [PATCH 14/25] =?UTF-8?q?[integrations]=20Chrome=20ext=20=E2=80=94?= =?UTF-8?q?=20Phase=20B=20Gemini=20history=20capture=20via=20chrome.debugg?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Phase B foundation for Gemini bulk backfill. Gemini exposes no public conversation API, so the extension attaches chrome.debugger to gemini.google.com tabs and watches for the one internal RPC that Gemini itself uses to load conversation history (batchexecute rpcids=hNvQHb). On loadingFinished the service worker reads the response body via the debugger protocol, parses Gemini's framed positional-array envelope, and yields one normalized turn per user/assistant exchange. All turns are funneled through the existing processCaptureRequest pipeline so they inherit retry queue, sensitivity filter, fingerprint dedup, and session metrics — no parallel /ingest path. This commit is debugger infrastructure only. Phase C (the Sync All orchestrator that drives per-conversation navigation) lands in a follow-up commit. Live StreamGenerate / ambient capture is NOT ported — the extension's public release deliberately dropped ambient capture, and this port preserves that policy. Manifest adds the minimum permissions needed: `debugger` (attach only to gemini.google.com, observe one RPC pattern) and `scripting` (for the Phase C sidebar enumerator). Version bumps 0.4.0 → 0.5.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../background/gemini-debugger.js | 531 ++++++++++++++++++ .../lib/extractor-gemini-history.js | 246 ++++++++ .../chrome-capture-extension/manifest.json | 6 +- 3 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 integrations/chrome-capture-extension/background/gemini-debugger.js create mode 100644 integrations/chrome-capture-extension/lib/extractor-gemini-history.js diff --git a/integrations/chrome-capture-extension/background/gemini-debugger.js b/integrations/chrome-capture-extension/background/gemini-debugger.js new file mode 100644 index 00000000..615629ba --- /dev/null +++ b/integrations/chrome-capture-extension/background/gemini-debugger.js @@ -0,0 +1,531 @@ +/** + * Open Brain Capture — Gemini durable history capture via chrome.debugger + * + * Phase B: attach chrome.debugger to https://gemini.google.com/* tabs, watch + * for the batchexecute `rpcids=hNvQHb` request/response (Gemini's internal + * conversation-history loader), pair the request+response so MV3 service- + * worker suspensions don't lose state mid-response, fetch the response body + * on loadingFinished, and funnel extracted turns through + * `processCaptureRequest`. + * + * What this file does NOT do: + * - It does NOT observe StreamGenerate (the live per-turn stream). That + * path would be ambient capture, which the extension deliberately + * dropped in the initial public release (see service-worker.js notes). + * Only Phase B's history-load path ships here, and it only fires when + * the user (or the Sync All orchestrator on their behalf) opens a + * conversation. + * + * Coordination with the sync orchestrator: + * - When Phase C's gemini-sync.js drives a bulk backfill it navigates a + * hidden tab to `/app/` and waits on a per-conversation + * waiter. The page loads the conversation by firing the hNvQHb RPC; we + * observe the response here, funnel every turn through the capture + * pipeline (retry queue, sensitivity filter, fingerprint dedup), and + * then ping `OBGeminiSync.notifyHistoryCaptured(conversationId, totals)` + * so the orchestrator's waiter resolves and it can drive the next + * conversation. + * + * Respects the user's Gemini toggle: if the user disables Gemini capture in + * the popup settings, this module detaches from all tabs and stops listening + * until re-enabled. No probes, no telemetry, no third-party hosts. + */ + +/* global chrome, OBConfig */ + +(function () { + 'use strict'; + + // Phase B: conversation history is loaded via a batchexecute RPC. + // `rpcids=hNvQHb` is the history-load variant, confirmed via the Gemini + // network research referenced in the README. Other batchexecute rpcids + // (MaZiqc, ESY5D, L5adhe, etc.) handle sidebar/settings/status and are + // ignored by the URL guard below. + const BATCHEXECUTE_PATH = 'batchexecute'; + const HISTORY_RPCID = 'hNvQHb'; + const DEBUGGER_PROTOCOL_VERSION = '1.3'; + const REQUEST_STASH_TTL_MS = 120 * 1000; + const GEMINI_URL_PATTERN = 'https://gemini.google.com/'; + + // chrome.storage.session key prefix for the pending-request stash. + // Full key: `${STASH_KEY_PREFIX}${tabId}:${requestId}`. + const STASH_KEY_PREFIX = 'ob_gemini_stash_'; + + // chrome.storage.local key the popup reads to show the paused indicator. + const PAUSED_STATE_KEY = 'ob_gemini_paused'; + + // In-memory mirror of the persisted stash for speed. Canonical copy lives + // in chrome.storage.session; this map is always re-derivable from there. + const pendingRequests = new Map(); + + const attachedTabs = new Set(); + let capturePausedByUser = false; + let geminiEnabled = true; + let initialized = false; + + const LOG = (msg, ...rest) => console.log(`[OB Gemini] ${msg}`, ...rest); + const ERR = (msg, ...rest) => console.error(`[OB Gemini] ${msg}`, ...rest); + + function isHistoryUrl(url) { + return typeof url === 'string' + && url.includes(BATCHEXECUTE_PATH) + && url.includes(`rpcids=${HISTORY_RPCID}`); + } + + // --------------------------------------------------------------------------- + // Stash — chrome.storage.session-backed, in-memory mirrored + // --------------------------------------------------------------------------- + + function stashKey(tabId, requestId) { + return `${STASH_KEY_PREFIX}${tabId}:${requestId}`; + } + + async function stashSet(tabId, requestId, entry) { + const key = stashKey(tabId, requestId); + pendingRequests.set(key, entry); + try { + await chrome.storage.session.set({ [key]: entry }); + } catch (err) { + ERR(`stashSet failed key=${key}:`, err?.message || err); + } + } + + async function stashDelete(tabId, requestId) { + const key = stashKey(tabId, requestId); + pendingRequests.delete(key); + try { + await chrome.storage.session.remove(key); + } catch (err) { + ERR(`stashDelete failed key=${key}:`, err?.message || err); + } + } + + function stashGet(tabId, requestId) { + const entry = pendingRequests.get(stashKey(tabId, requestId)); + if (!entry) return null; + if (Date.now() - entry.startedAt > REQUEST_STASH_TTL_MS) return null; + return entry; + } + + async function stashRehydrate() { + try { + const all = await chrome.storage.session.get(null); + const now = Date.now(); + const expired = []; + let live = 0; + for (const [key, value] of Object.entries(all)) { + if (!key.startsWith(STASH_KEY_PREFIX)) continue; + if (!value || typeof value !== 'object' || typeof value.startedAt !== 'number') { + expired.push(key); + continue; + } + if (now - value.startedAt > REQUEST_STASH_TTL_MS) { + expired.push(key); + continue; + } + pendingRequests.set(key, value); + live += 1; + } + if (expired.length) { + await chrome.storage.session.remove(expired); + } + LOG(`stash rehydrate live=${live} expired=${expired.length}`); + } catch (err) { + ERR('stashRehydrate failed:', err?.message || err); + } + } + + async function stashDropForTab(tabId) { + const prefix = `${STASH_KEY_PREFIX}${tabId}:`; + const keys = []; + for (const key of pendingRequests.keys()) { + if (key.startsWith(prefix)) keys.push(key); + } + if (!keys.length) return; + for (const key of keys) pendingRequests.delete(key); + try { + await chrome.storage.session.remove(keys); + } catch (err) { + ERR(`stashDropForTab failed tab=${tabId}:`, err?.message || err); + } + } + + // --------------------------------------------------------------------------- + // Paused-state flag — persisted for the popup + // --------------------------------------------------------------------------- + + async function setPausedByUser(paused) { + capturePausedByUser = Boolean(paused); + try { + await chrome.storage.local.set({ [PAUSED_STATE_KEY]: capturePausedByUser }); + } catch (err) { + ERR('setPausedByUser failed:', err?.message || err); + } + } + + function isCapturePausedByUser() { + return capturePausedByUser; + } + + // --------------------------------------------------------------------------- + // Settings — read Gemini toggle from user config + // --------------------------------------------------------------------------- + + async function readGeminiEnabled() { + try { + const config = await OBConfig.getConfig(); + return config?.enabledPlatforms?.gemini !== false; + } catch (err) { + ERR('readGeminiEnabled failed — defaulting to enabled:', err?.message || err); + return true; + } + } + + async function applyEnabledState(nextEnabled) { + const prevEnabled = geminiEnabled; + geminiEnabled = Boolean(nextEnabled); + + if (geminiEnabled && !prevEnabled) { + LOG('gemini capture enabled — attaching to open tabs'); + await attachToOpenGeminiTabs(); + } else if (!geminiEnabled && prevEnabled) { + LOG('gemini capture disabled — detaching all tabs'); + await detachFromAllTabs(); + } + } + + // --------------------------------------------------------------------------- + // Attach lifecycle + // --------------------------------------------------------------------------- + + async function attachToGeminiTab(tabId) { + if (!geminiEnabled) return; + if (attachedTabs.has(tabId)) return; + try { + await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); + await chrome.debugger.sendCommand({ tabId }, 'Network.enable', {}); + attachedTabs.add(tabId); + LOG(`attached tab=${tabId}`); + // A successful attach clears any prior "user canceled" paused state. + if (capturePausedByUser) await setPausedByUser(false); + } catch (err) { + ERR(`attach failed tab=${tabId}:`, err?.message || String(err)); + } + } + + async function detachFromTab(tabId) { + if (!attachedTabs.has(tabId)) { + await stashDropForTab(tabId); + return; + } + try { + await chrome.debugger.detach({ tabId }); + LOG(`detached tab=${tabId}`); + } catch (err) { + // detach often fails if the tab is already closed; not fatal + ERR(`detach failed tab=${tabId}:`, err?.message || String(err)); + } + attachedTabs.delete(tabId); + await stashDropForTab(tabId); + } + + async function attachToOpenGeminiTabs() { + try { + const tabs = await chrome.tabs.query({ url: 'https://gemini.google.com/*' }); + LOG(`startup scan: ${tabs.length} Gemini tab(s) open`); + for (const tab of tabs) { + if (typeof tab.id === 'number') await attachToGeminiTab(tab.id); + } + } catch (err) { + ERR('attachToOpenGeminiTabs failed:', err?.message || err); + } + } + + async function detachFromAllTabs() { + const snapshot = Array.from(attachedTabs); + for (const tabId of snapshot) await detachFromTab(tabId); + } + + // --------------------------------------------------------------------------- + // Event wiring + // --------------------------------------------------------------------------- + + function wireTabListeners() { + chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { + if (typeof changeInfo.url !== 'string') return; + if (changeInfo.url.startsWith(GEMINI_URL_PATTERN)) { + await attachToGeminiTab(tabId); + } else if (attachedTabs.has(tabId)) { + await detachFromTab(tabId); + } + }); + + chrome.tabs.onRemoved.addListener(async (tabId) => { + if (attachedTabs.has(tabId)) { + await detachFromTab(tabId); + } else { + await stashDropForTab(tabId); + } + }); + } + + function wireDebuggerListeners() { + chrome.debugger.onDetach.addListener(async (source, reason) => { + const tabId = source.tabId; + if (typeof tabId !== 'number') return; + LOG(`onDetach tab=${tabId} reason=${reason}`); + attachedTabs.delete(tabId); + await stashDropForTab(tabId); + if (reason === 'canceled_by_user') { + await setPausedByUser(true); + } + }); + + chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (typeof tabId !== 'number' || !attachedTabs.has(tabId)) return; + + if (method === 'Network.requestWillBeSent') { + handleRequestWillBeSent(tabId, params).catch((err) => + ERR(`requestWillBeSent handler failed tab=${tabId}:`, err?.message || err) + ); + } else if (method === 'Network.loadingFinished') { + handleLoadingFinished(tabId, params).catch((err) => + ERR(`loadingFinished handler failed tab=${tabId}:`, err?.message || err) + ); + } + }); + } + + function wireSettingsListener() { + // OBConfig stores non-secret platform toggles in chrome.storage.sync under + // STORAGE_KEYS.settings (and falls back to chrome.storage.local if sync + // is unavailable). Watch both so enabling/disabling Gemini capture takes + // effect regardless of which area currently holds the settings blob. + const settingsKey = OBConfig.STORAGE_KEYS.settings; + chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName !== 'sync' && areaName !== 'local') return; + if (!(settingsKey in changes)) return; + const next = await readGeminiEnabled(); + await applyEnabledState(next); + }); + } + + // --------------------------------------------------------------------------- + // Request/response handlers + // --------------------------------------------------------------------------- + + async function handleRequestWillBeSent(tabId, params) { + const url = params?.request?.url ?? ''; + const requestId = params.requestId; + + // Phase B: history load for a conversation the user (or the sync + // orchestrator) opened. The request body isn't needed — the user prompts + // and assistant turns are all embedded in the response body. + if (isHistoryUrl(url)) { + const entry = { + tabId, + requestId, + url, + kind: 'history', + startedAt: Date.now() + }; + await stashSet(tabId, requestId, entry); + LOG(`requestWillBeSent tab=${tabId} requestId=${requestId} kind=history`); + return; + } + + // Not a URL we care about. + } + + async function handleLoadingFinished(tabId, params) { + const requestId = params.requestId; + const entry = stashGet(tabId, requestId); + if (!entry) return; + + const elapsed = Date.now() - entry.startedAt; + LOG(`loadingFinished tab=${tabId} requestId=${requestId} kind=${entry.kind || 'unknown'} elapsed=${elapsed}ms`); + + let body = null; + try { + const result = await chrome.debugger.sendCommand( + { tabId }, + 'Network.getResponseBody', + { requestId } + ); + body = typeof result?.body === 'string' ? result.body : null; + const bodyLen = body ? body.length : 0; + LOG(`body received tab=${tabId} length=${bodyLen} base64=${Boolean(result?.base64Encoded)}`); + } catch (err) { + ERR(`getResponseBody failed tab=${tabId} requestId=${requestId}:`, err?.message || err); + await stashDelete(tabId, requestId); + return; + } + + // Phase B is the only request kind we handle here. + if (entry.kind === 'history') { + try { + await routeHistoryThroughCapturePipeline({ tabId, requestId, responseBody: body }); + } finally { + await stashDelete(tabId, requestId); + } + return; + } + + // Unknown kind — drop defensively. + await stashDelete(tabId, requestId); + } + + async function routeHistoryThroughCapturePipeline({ tabId, requestId, responseBody }) { + const extractor = self.OBGeminiHistoryExtractor; + if (!extractor || typeof extractor.extractGeminiHistory !== 'function') { + ERR(`OBGeminiHistoryExtractor unavailable — dropping tab=${tabId} requestId=${requestId}`); + return; + } + + const turns = extractor.extractGeminiHistory({ responseBody }); + if (!Array.isArray(turns) || turns.length === 0) { + LOG(`history extractor returned empty tab=${tabId} requestId=${requestId} — dropping`); + return; + } + + const captureHandler = self.processCaptureRequest; + if (typeof captureHandler !== 'function') { + ERR(`processCaptureRequest unavailable in SW scope — dropping tab=${tabId} requestId=${requestId}`); + return; + } + + // Loop the turns serially to keep the ingest pipeline's retry queue, + // sensitivity filter, and fingerprint dedup operating predictably per + // turn. Fingerprint dedup guarantees that re-opening the same + // conversation does NOT produce duplicate thoughts; each turn either + // ingests new or returns 'duplicate_fingerprint' / 'existing'. + LOG(`history load tab=${tabId} requestId=${requestId} turns=${turns.length}`); + + let captured = 0; + let skippedDup = 0; + let other = 0; + + for (const turn of turns) { + const combinedText = `User: ${turn.userPrompt}\n\nAssistant: ${turn.assistantText}`; + try { + const result = await captureHandler({ + platform: 'gemini', + captureMode: 'sync', + text: combinedText, + sourceMetadata: { + gemini_conversation_id: turn.conversationId, + gemini_response_id: turn.responseId, + gemini_candidate_id: turn.candidateId, + gemini_language: turn.language, + gemini_model: turn.model, + gemini_user_prompt: turn.userPrompt, + gemini_assistant_text: turn.assistantText, + gemini_captured_at: turn.capturedAt, + gemini_history_order: turn.historyOrder, + gemini_capture_kind: 'history' + }, + assistantLength: turn.assistantText.length, + preview: turn.assistantText + }); + + const status = result?.status || 'unknown'; + if (status === 'duplicate_fingerprint' || status === 'existing') { + skippedDup += 1; + } else if (status === 'complete' || status === 'captured' || status === 'inserted') { + captured += 1; + } else { + other += 1; + LOG(`history turn[${turn.historyOrder}] tab=${tabId} status=${status}`); + } + } catch (err) { + other += 1; + ERR(`history turn[${turn.historyOrder}] threw tab=${tabId}:`, err?.message || err); + } + } + + LOG(`history captured tab=${tabId} requestId=${requestId} captured=${captured} dedup=${skippedDup} other=${other} total=${turns.length}`); + + // Phase C hook: notify the sync orchestrator (if present) so it can + // un-block its per-conversation waiter. Use the first turn's + // conversation ID — all turns in a single hNvQHb response share it. + // + // The sync orchestrator keys its waiters by the BARE conversation hash + // (derived from the /app/ URL it navigates to). Our extractor + // returns the PREFIXED form (c_) straight from Gemini's JSON. + // Strip the prefix at the notify boundary so sync's Map lookup hits. + // The stored metadata on the thought keeps the prefixed form — that's + // canonical for retrieval. This normalization is sync-waiter-only. + // + // Silently no-ops when Phase C isn't loaded or no sync is in flight. + const rawConversationId = turns[0]?.conversationId; + const firstConversationId = + typeof rawConversationId === 'string' && rawConversationId.startsWith('c_') + ? rawConversationId.slice(2) + : rawConversationId; + if ( + typeof firstConversationId === 'string' && + firstConversationId && + self.OBGeminiSync && + typeof self.OBGeminiSync.notifyHistoryCaptured === 'function' + ) { + try { + self.OBGeminiSync.notifyHistoryCaptured(firstConversationId, { + captured, + skippedDup, + other, + total: turns.length + }); + } catch (err) { + ERR(`notifyHistoryCaptured threw tab=${tabId}:`, err?.message || err); + } + } + } + + // --------------------------------------------------------------------------- + // Init + // --------------------------------------------------------------------------- + + async function initGeminiDebugger() { + if (initialized) return; + initialized = true; + + LOG('init'); + + geminiEnabled = await readGeminiEnabled(); + LOG(`gemini capture enabled=${geminiEnabled}`); + + await stashRehydrate(); + + wireDebuggerListeners(); + wireTabListeners(); + wireSettingsListener(); + + if (geminiEnabled) { + await attachToOpenGeminiTabs(); + } + + LOG('event listeners wired'); + } + + // Auto-initialize on SW wake. Idempotent. + initGeminiDebugger().catch((err) => ERR('init failed:', err?.message || err)); + + // Expose to the classic importScripts service-worker global scope. + self.OBGeminiDebugger = { + initGeminiDebugger, + attachToGeminiTab, + detachFromTab, + detachFromAllTabs, + isCapturePausedByUser, + // Constants for tests and later wiring. + DEBUGGER_PROTOCOL_VERSION, + REQUEST_STASH_TTL_MS, + GEMINI_URL_PATTERN, + STASH_KEY_PREFIX, + PAUSED_STATE_KEY, + // Read-only views of internal state. + _attachedTabs: attachedTabs, + _pendingRequests: pendingRequests + }; +})(); diff --git a/integrations/chrome-capture-extension/lib/extractor-gemini-history.js b/integrations/chrome-capture-extension/lib/extractor-gemini-history.js new file mode 100644 index 00000000..3777f0b8 --- /dev/null +++ b/integrations/chrome-capture-extension/lib/extractor-gemini-history.js @@ -0,0 +1,246 @@ +/** + * Open Brain Capture — Gemini batchexecute history extractor (Phase B). + * + * Pure function. No chrome.* calls, no fetches. Takes the response body of a + * Gemini batchexecute `rpcids=hNvQHb` call and returns an array of normalized + * conversation turns (one per user/assistant exchange in the history). + * + * This is the history-load path — distinct from StreamGenerate (the live-turn + * path). StreamGenerate pairs one request with one response; history load + * returns every turn of an opened conversation in a single response frame. + * + * Durability: every access to Gemini's positional response shape is + * type-guarded. Any unexpected input returns null/empty-array. We never throw + * and never produce partial/garbage output. + * + * Important: this file lives in `lib/` and runs in the service-worker scope + * (classic importScripts), NOT as a content script. It does not touch the + * DOM. The DOM-based manual-capture extractor lives at + * `content-scripts/extractor-gemini.js` and is unchanged by the Phase B/C + * port. + */ + +/* global self, TextEncoder, TextDecoder */ + +(function () { + 'use strict'; + + const ANTI_XSSI_PREFIX = ')]}\''; + const WHITESPACE_BYTES = new Set([0x0a, 0x0d, 0x20, 0x09]); + const DIGIT_MIN = 0x30; + const DIGIT_MAX = 0x39; + // Length prefix is sometimes off by 1-2 bytes because HAR / some network + // paths normalize \r\n -> \n. Try a small delta window around the hint + // until JSON.parse succeeds. +/-5 proven sufficient across 44 frames in + // the original research HAR fixtures. + const FRAME_DELTA_WINDOW = [0, -1, -2, -3, 1, 2, 3, -4, -5, 4, 5]; + + // ─── Response parsing helpers ────────────────────────────────────────── + + function stripLeadingPrefix(body) { + const s = typeof body === 'string' ? body : ''; + const trimmed = s.replace(/^\s+/, ''); + if (trimmed.startsWith(ANTI_XSSI_PREFIX)) { + return trimmed.slice(ANTI_XSSI_PREFIX.length).replace(/^\s+/, ''); + } + return trimmed; + } + + function decodeBytes(bytes, start, end) { + const slice = bytes.slice(start, end); + return new TextDecoder('utf-8', { fatal: false }).decode(slice); + } + + function parseAdaptive(s, hintLen) { + for (const delta of FRAME_DELTA_WINDOW) { + const len = hintLen + delta; + if (len < 1 || len > s.length) continue; + try { + const value = JSON.parse(s.slice(0, len).replace(/\s+$/, '')); + return { value, consumed: len }; + } catch (_err) { + // Try next delta + } + } + return null; + } + + function parseFramedResponse(body) { + const bytes = new TextEncoder().encode(stripLeadingPrefix(body)); + const frames = []; + let off = 0; + + while (off < bytes.length) { + // Skip whitespace between frames + while (off < bytes.length && WHITESPACE_BYTES.has(bytes[off])) off += 1; + if (off >= bytes.length) break; + + // Read digit-length prefix + let digitsEnd = off; + while (digitsEnd < bytes.length && bytes[digitsEnd] >= DIGIT_MIN && bytes[digitsEnd] <= DIGIT_MAX) { + digitsEnd += 1; + } + if (digitsEnd === off) break; + + const hintLen = Number(decodeBytes(bytes, off, digitsEnd)); + if (!Number.isFinite(hintLen) || hintLen <= 0) break; + off = digitsEnd; + if (bytes[off] === 0x0a) off += 1; + + const remaining = decodeBytes(bytes, off, bytes.length); + const parsed = parseAdaptive(remaining, hintLen); + if (!parsed) break; + + frames.push(parsed.value); + // Advance by the byte length of the consumed string (encoder/decoder + // roundtrip is stable for valid UTF-8). + off += new TextEncoder().encode(remaining.slice(0, parsed.consumed)).length; + } + + return frames; + } + + // ─── History payload extraction ──────────────────────────────────────── + // + // Envelope shape: + // Frame = ["wrb.fr", "hNvQHb", "", null,null,null, "generic"] + // Decoded JSON = [ [turn, turn, ...], null, null, [] ] + // where each turn is: + // [0] = [conversationId, responseId] + // [2] = [[userPrompt], ...] -- user prompt lives in response + // [3] = [[[candidate, ...], [timeSec, timeNanos]]] + // candidate[0] = candidateId + // candidate[1][0] = assistantText + // candidate[9] = language + // candidate[near-end] = model (e.g. "3 Pro") -- heuristic scan + + /** + * Extract one-or-more turns from a Gemini history-load response. + * + * @param {{ responseBody: string | null | undefined }} args + * @returns {Array|null} array of normalized turns, or null if the + * response wasn't a valid hNvQHb payload. Never throws. + */ + function extractGeminiHistory(args) { + try { + if (!args || typeof args !== 'object') return null; + const responseBody = typeof args.responseBody === 'string' ? args.responseBody : ''; + if (!responseBody) return null; + + const frames = parseFramedResponse(responseBody); + if (!frames || frames.length === 0) return null; + + // Find the wrb.fr hNvQHb frame (there should be exactly one). + for (const frame of frames) { + if (!Array.isArray(frame) || !Array.isArray(frame[0])) continue; + const entry = frame[0]; + if (entry[0] !== 'wrb.fr' || entry[1] !== 'hNvQHb' || typeof entry[2] !== 'string') continue; + + const turns = parseHistoryPayload(entry[2]); + if (turns && turns.length > 0) return turns; + } + return null; + } catch (_err) { + return null; + } + } + + function parseHistoryPayload(payloadStr) { + let nested; + try { + nested = JSON.parse(payloadStr); + } catch (_err) { + return null; + } + if (!Array.isArray(nested) || !Array.isArray(nested[0])) return null; + + const turnsArr = nested[0]; + const results = []; + const capturedAt = new Date().toISOString(); + + for (let i = 0; i < turnsArr.length; i += 1) { + const turn = extractHistoryTurn(turnsArr[i], capturedAt); + if (turn) { + turn.historyOrder = i; + results.push(turn); + } + } + return results.length > 0 ? results : null; + } + + function extractHistoryTurn(turn, fallbackCapturedAt) { + if (!Array.isArray(turn)) return null; + + // Ids: [conversationId, responseId] + const ids = Array.isArray(turn[0]) ? turn[0] : []; + const conversationId = typeof ids[0] === 'string' ? ids[0] : ''; + const responseId = typeof ids[1] === 'string' ? ids[1] : ''; + if (!conversationId || !responseId) return null; + + // User prompt: turn[2][0][0] + const promptSection = Array.isArray(turn[2]) ? turn[2] : null; + const promptArr = promptSection && Array.isArray(promptSection[0]) ? promptSection[0] : null; + const userPrompt = promptArr && typeof promptArr[0] === 'string' ? promptArr[0] : ''; + if (!userPrompt) return null; + + // Candidate: turn[3][0][0] + const candidatesBlock = Array.isArray(turn[3]) ? turn[3] : null; + const candidateSet = candidatesBlock && Array.isArray(candidatesBlock[0]) ? candidatesBlock[0] : null; + const candidate = candidateSet && Array.isArray(candidateSet[0]) ? candidateSet[0] : null; + if (!candidate) return null; + + const candidateId = typeof candidate[0] === 'string' ? candidate[0] : ''; + const textArr = Array.isArray(candidate[1]) ? candidate[1] : null; + const assistantText = textArr && typeof textArr[0] === 'string' ? textArr[0] : ''; + const language = typeof candidate[9] === 'string' ? candidate[9] : ''; + if (!candidateId || !assistantText) return null; + + // Model: heuristic scan near the end of the candidate array for a string + // matching Gemini model naming (e.g. "3 Pro", "2.5 Pro", "Flash", "Nano"). + let model = null; + const start = Math.max(0, candidate.length - 15); + for (let idx = candidate.length - 1; idx >= start; idx -= 1) { + const val = candidate[idx]; + if (typeof val === 'string' && /^\d+(\.\d+)?\s?(Pro|Flash|Ultra|Nano)/i.test(val)) { + model = val; + break; + } + } + + // Original timestamp: candidateSet[1] = [seconds, nanos] + let capturedAt = fallbackCapturedAt; + const tsArr = Array.isArray(candidateSet[1]) ? candidateSet[1] : null; + if (tsArr && typeof tsArr[0] === 'number' && typeof tsArr[1] === 'number') { + const ms = tsArr[0] * 1000 + Math.floor(tsArr[1] / 1e6); + if (Number.isFinite(ms) && ms > 0) { + capturedAt = new Date(ms).toISOString(); + } + } + + return { + userPrompt, + assistantText, + conversationId, + responseId, + candidateId, + language, + model, + capturedAt, + historyOrder: -1 // set by parseHistoryPayload + }; + } + + // ─── Exports ─────────────────────────────────────────────────────────── + + self.OBGeminiHistoryExtractor = { + extractGeminiHistory, + // Exposed for fixture-based tests. + _internal: { + parseFramedResponse, + parseAdaptive, + stripLeadingPrefix, + parseHistoryPayload, + extractHistoryTurn + } + }; +})(); diff --git a/integrations/chrome-capture-extension/manifest.json b/integrations/chrome-capture-extension/manifest.json index 636622a7..aa03a185 100644 --- a/integrations/chrome-capture-extension/manifest.json +++ b/integrations/chrome-capture-extension/manifest.json @@ -1,14 +1,16 @@ { "manifest_version": 3, "name": "Open Brain Capture", - "version": "0.4.0", + "version": "0.5.0", "description": "Capture AI conversations from Claude, ChatGPT, and Gemini into your Open Brain via the REST API gateway.", "permissions": [ "storage", "alarms", "activeTab", "tabs", - "cookies" + "cookies", + "debugger", + "scripting" ], "optional_host_permissions": [ "https://*/*", From 0d51d48596ff1b5805c0db260e69aa9714d53da2 Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Tue, 21 Apr 2026 16:38:26 -0400 Subject: [PATCH 15/25] =?UTF-8?q?[integrations]=20Chrome=20ext=20=E2=80=94?= =?UTF-8?q?=20Phase=20C=20Gemini=20Sync=20All=20orchestrator=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Phase C orchestrator that drives full-history backfill. The state machine lives in a pure helper (lib/gemini-sync-state.js) so it can be unit-tested under node --test without a Chrome stub; the orchestrator (background/gemini-sync.js) owns all chrome.* calls. Flow: 1. Enumerate the Gemini sidebar via chrome.scripting.executeScript (scrolls the list until IDs stop growing). 2. Open one background sync tab, drive it through each conversation. 3. Per conversation: register a waiter keyed by the conversation ID, navigate the tab, wait for Phase B to notify via notifyHistoryCaptured(id, totals). 4. Fingerprint dedup at the ingest layer guarantees re-runs are safe (duplicate turns return duplicate_fingerprint / existing). Resilience: - Resumable across MV3 SW restarts via chrome.storage.local state. - User-cancelable at any time; Sync All button relabels to Resume Sync when a run was paused mid-flight. - Tab-health check before every navigation catches Google bot challenges (CAPTCHA / login prompts that redirect off Gemini) and transitions gracefully to a CANCELED paused state instead of burning through the queue with silent timeouts. Anti-bot throttle: - 4–12 s jittered delay between conversations (sub-millisecond precision so whole-second clusters don't fingerprint as a bot). - 20–35 s "reading pause" every 10 conversations to break cadence. Tuned to stay under Google's challenge threshold (earlier uniform 4 s cadence tripped the challenge around conversation 21). Incremental path: - syncIncremental() filters the sidebar against lifetime everSyncedIds and navigates only the delta. Capped at 20 per run so scheduled use stays quiet. - Auto-sync (4-hour cadence, opt-in) drives incremental sync. Tests (35 cases) cover state transitions, pendingIds deduplication and cap enforcement, completion/failure bookkeeping, progress summary, and the waiter registry's resolve/abort/abortAll semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../background/gemini-sync.js | 1008 +++++++++++++++++ .../lib/__tests__/gemini-sync-state.test.js | 353 ++++++ .../lib/gemini-sync-state.js | 387 +++++++ 3 files changed, 1748 insertions(+) create mode 100644 integrations/chrome-capture-extension/background/gemini-sync.js create mode 100644 integrations/chrome-capture-extension/lib/__tests__/gemini-sync-state.test.js create mode 100644 integrations/chrome-capture-extension/lib/gemini-sync-state.js diff --git a/integrations/chrome-capture-extension/background/gemini-sync.js b/integrations/chrome-capture-extension/background/gemini-sync.js new file mode 100644 index 00000000..87c870c3 --- /dev/null +++ b/integrations/chrome-capture-extension/background/gemini-sync.js @@ -0,0 +1,1008 @@ +/** + * Open Brain Capture — Gemini "Sync All History" orchestrator (Phase C) + * + * Drives a one-shot full-history backfill by walking the Gemini sidebar, + * navigating a dedicated background tab to each conversation, and waiting + * for the Phase B debugger capture (gemini-debugger.js → hNvQHb batchexecute) + * to call back through `notifyHistoryCaptured(id, result)`. + * + * Design principles: + * - No DOM scraping for content — Phase B still owns that via chrome.debugger. + * - Resumable across MV3 service-worker restarts via chrome.storage.local. + * - User-cancelable at any time. + * - No per-conversation API calls from this module; it only coordinates. + * - No telemetry, no third-party hosts. + * + * State transitions and bookkeeping live in the pure helper at + * `lib/gemini-sync-state.js` (`OBGeminiSyncState`). + */ + +/* global chrome, self, OBGeminiSyncState */ + +(function () { + 'use strict'; + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + // Persisted state key. Single object under chrome.storage.local so rehydrate + // on SW wake is a single read. + const STATE_STORAGE_KEY = 'ob_gemini_sync_state'; + + const GEMINI_APP_URL = 'https://gemini.google.com/app'; + + // Hard ceiling to avoid runaway iteration on pathological sidebar DOMs. + const DEFAULT_CAP = 2000; + + // Gentler cap for auto/incremental runs. Keeps total navigations per + // scheduled cycle low so we don't tempt Google's bot detector. If there + // are more than this many new conversations since the last run, the + // remainder waits for the next alarm. + const DEFAULT_AUTO_CAP = 20; + + // Max time to wait between navigating the sync tab and Phase B firing the + // capture callback. A typical hNvQHb round-trip is 0.5s-3s; 15s absorbs + // slow networks without pinning the orchestrator forever. + const CAPTURE_WAIT_TIMEOUT_MS = 15000; + + // Max time to wait for Phase B's debugger to re-attach to the sync tab + // after we navigate. If we don't see OBGeminiDebugger._attachedTabs list + // our tab within this window, we proceed anyway — capture will either + // happen or time out via CAPTURE_WAIT_TIMEOUT_MS. + const ATTACH_WAIT_TIMEOUT_MS = 2000; + const ATTACH_POLL_INTERVAL_MS = 100; + + // Sidebar enumeration: how long to scroll the sidebar for and how many + // scrolls to perform before giving up. + const ENUMERATE_SCROLL_STEPS = 60; + const ENUMERATE_SCROLL_PAUSE_MS = 250; + + // Heartbeat stale threshold — if we see a record in state=syncing whose + // heartbeat is older than this, we assume the previous SW died + // mid-conversation and the user may want to resume manually. + const STALE_HEARTBEAT_MS = 5 * 60 * 1000; + + // Anti-bot throttle. An earlier experiment with a uniform 4s cadence + // triggered Google's bot challenge around conversation 21. Mitigations: + // - Longer base interval (8s average) + // - Randomized jitter (4-12s range) with full-float precision so delays + // never cluster on whole-second ticks (a classic bot signature) + // - Periodic "reading pauses" every N conversations to break cadence + const THROTTLE_MIN_MS = 4000; + const THROTTLE_MAX_MS = 12000; + const READING_PAUSE_EVERY_N = 10; + const READING_PAUSE_MIN_MS = 20000; + const READING_PAUSE_MAX_MS = 35000; + + const LOG = (msg, ...rest) => console.log(`[OB Gemini SYNC] ${msg}`, ...rest); + const ERR = (msg, ...rest) => console.error(`[OB Gemini SYNC] ${msg}`, ...rest); + + // Live waiter registry for notifyHistoryCaptured. Created lazily because + // OBGeminiSyncState may not yet be on the global when this IIFE runs; + // we access it via `getStateModule()` below. + let waiters = null; + + // In-memory flag to short-circuit the main loop when cancel was requested. + // Also mirrored into persisted state for resume-after-wake behavior. + let cancelRequested = false; + + // Guards against concurrent startSync invocations from the popup. + let syncInFlight = false; + + // --------------------------------------------------------------------------- + // Lazy accessor for the state helper module + // --------------------------------------------------------------------------- + + function getStateModule() { + const mod = self.OBGeminiSyncState; + if (!mod) { + throw new Error('OBGeminiSyncState module not loaded'); + } + return mod; + } + + function getWaiters() { + if (!waiters) waiters = getStateModule().createWaiterRegistry(); + return waiters; + } + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + async function loadState() { + try { + const stored = await chrome.storage.local.get({ [STATE_STORAGE_KEY]: null }); + const raw = stored[STATE_STORAGE_KEY]; + if (!raw || typeof raw !== 'object') { + return getStateModule().createInitialState(); + } + // Defensive merge — guarantees shape even if stored record is from + // an older extension version. + const fresh = getStateModule().createInitialState(); + const merged = { + ...fresh, + ...raw, + totals: { ...fresh.totals, ...(raw.totals || {}) }, + pendingIds: Array.isArray(raw.pendingIds) ? raw.pendingIds : [], + completedIds: Array.isArray(raw.completedIds) ? raw.completedIds : [], + failedIds: Array.isArray(raw.failedIds) ? raw.failedIds : [] + }; + return merged; + } catch (err) { + ERR('loadState failed — returning initial:', err?.message || err); + return getStateModule().createInitialState(); + } + } + + async function saveState(record) { + try { + await chrome.storage.local.set({ [STATE_STORAGE_KEY]: record }); + } catch (err) { + ERR('saveState failed:', err?.message || err); + } + } + + async function updateState(mutator) { + const record = await loadState(); + const next = mutator(record) || record; + await saveState(next); + return next; + } + + // --------------------------------------------------------------------------- + // Sidebar enumeration — runs in the page via chrome.scripting.executeScript + // --------------------------------------------------------------------------- + + /** + * Page-context function. Scrolls the sidebar conversation list and returns + * every conversation id it can find as hrefs of the form `/app/`. + * + * Gemini's DOM changes frequently. We cast a wide net: any anchor whose + * href matches /app/[a-z0-9]+ is treated as a conversation link. Duplicates + * are collapsed. + */ + function enumerateSidebar(scrollSteps, scrollPauseMs) { + const isValidId = (id) => typeof id === 'string' && /^[a-z0-9]{8,}$/i.test(id); + + const collect = () => { + const ids = new Set(); + const anchors = document.querySelectorAll('a[href*="/app/"]'); + for (const anchor of anchors) { + const href = anchor.getAttribute('href') || ''; + const m = href.match(/\/app\/([a-z0-9]+)/i); + if (m && isValidId(m[1])) ids.add(m[1]); + } + return ids; + }; + + // Find the most likely scroll container. Gemini's sidebar is typically + // a `