docs/capture-pipeline.md

Capture Pipeline Technical Documentation

1. Overview

The capture pipeline is the core data ingestion path in Birdbrain. It captures web pages from a Chrome extension, transmits them over HTTP to a local Hono server running inside the Electron main process, stores the content on disk, records metadata in SQLite, runs post-capture analysis, and pushes real-time events to the renderer for observability.

The pipeline handles three capture sources (auto, manual, selector) through a single unified endpoint. All sources share the same storage, hashing, database insertion, and post-capture extraction logic, differing only in their validation rules and how they are triggered.

Key Components

ComponentLocationRole
Chrome extension backgroundextension/src/background.tsCapture orchestration, dedup, connection management
Content scriptextension/src/content.tsPage extraction (freeze-dry), selector matching, highlighting
Extension HTTP clientextension/src/utils/api.tsTyped API client for the Hono server
Toast systemextension/src/toast.tsIn-page user feedback via Shadow DOM toasts
Capture serversrc/main/services/captureServer.tsHono HTTP server with unified capture endpoint
File storagesrc/main/services/storage.tsWrites HTML, PNG, and TXT files to disk
Hash servicesrc/main/services/hash.tsSHA-256 content hashing and verification
IPC channelssrc/shared/ipc.tsTyped channel definitions for Electron IPC
Preload bridgesrc/preload/index.tsExposes IPC to renderer via contextBridge
Zustand storesrc/renderer/stores/appStore.tsCapture events state (ring buffer, stats)
Server status hooksrc/renderer/hooks/useServerStatus.tsSubscribes to IPC events, updates store
Health dashboardsrc/renderer/components/status/CaptureHealth.tsxPipeline observability UI
Shared typessrc/shared/types.tsCaptureEvent, CaptureSource, Capture, etc.

2. Architecture Diagram

Full Capture Flow

1Chrome Extension Electron Main Process Renderer
2================ ===================== ========
3
4 background.ts captureServer.ts (Hono) appStore.ts
5 +-----------+ +------------------+ +-----------+
6 | | POST /api/captures | | IPC: | |
7 | captureTab|--------------------->| Unified Endpoint | NEW_CAPTURE | Zustand |
8 | manual | | |---------------->| Store |
9 | selector | | 1. Validate | | |
10 | | | 2. Blacklist | IPC: | capture |
11 | | | 3. Source rules | CAPTURE_ | Events[] |
12 | | JSON response | 4. Hash | ACTIVITY | capture |
13 | |<---------------------| 5. Save files |---------------->| Stats |
14 | | | 6. DB insert | | |
15 +-----------+ | 7. IPC notify | +-----------+
16 ^ | 8. Post-capture | |
17 | +------------------+ v
18 content.ts | CaptureHealth.tsx
19 +-----------+ | +-----------+
20 | freeze-dry| storage.ts database.ts | Activity |
21 | text | +--------+ +----------+ | list |
22 | selectors | | HTML | | captures | | Stats |
23 +-----------+ | PNG | | entities | | Test btns |
24 | TXT | +----------+ +-----------+
25 +--------+

Event Flow for Observability

1captureServer.ts preload/index.ts appStore.ts CaptureHealth.tsx
2 | | | |
3 | emitCaptureEvent() | | |
4 |---IPC:CAPTURE_ACTIVITY---------->| | |
5 | |--onCaptureActivity()----->| |
6 | | |--addCaptureEvent() |
7 | | | (ring buffer, 50) |
8 | | | |
9 | | |---re-render---------->|
10 | | | |

3. Capture Sources

Auto Capture

Auto capture is session-based. It fires automatically when a page finishes loading while a capture session is active.

Trigger: chrome.tabs.onUpdated listener in extension/src/background.ts (line 363). When changeInfo.status === 'complete', the shouldCapture() function checks:

  1. Session is active and extension is connected
  2. URL is not in the default ignore list (DEFAULT_IGNORE)
  3. URL is not matched by user-configured ignored patterns
  4. URL has not been captured in the last 60 seconds (client-side dedup via dedupeMap)

Behavior:

  • Case ID is inferred from state.activeCaseId on the server -- the extension does not send a caseId
  • The server increments state.captureCount for auto captures only
  • The extension updates its badge count after each successful auto capture
  • No toast feedback is shown for auto captures (they happen silently in the background)

Manual Capture

Manual capture is session-independent. It can be triggered at any time the extension is connected to the server, regardless of whether a session is active.

Triggers:

  • Context menu: "Capture with Birdbrain" right-click menu item (extension/src/background.ts, line 115)
  • Popup: MANUAL_CAPTURE message from the extension popup (extension/src/background.ts, line 421)

Behavior:

  • Requires an explicit caseId in the request body
  • The server validates the case exists and is not archived
  • Shows a toast on the page: "Capturing..." while in progress, then updates to success/degraded/error
  • Extraction (freeze-dry) and screenshot run in parallel via Promise.all (extension/src/background.ts, line 221)
  • Reports the degraded flag to distinguish freeze-dry success from fallback

Selector Capture

Selector capture is triggered when page text matches user-defined patterns (selectors) associated with cases. It operates in two modes controlled by autoCaptureMode:

Trigger: checkSelectorsOnTab() in extension/src/background.ts (line 310), called on page load completion when the session is active and selectors exist.

Modes:

  • auto: Automatically captures the page for each case with matching selectors
  • notify: Shows a Chrome notification listing the matches; the user can then manually capture

Behavior:

  • Requires caseId and matchedSelectors in the request body
  • Has its own dedup map (selectorDedupeMap) keyed by caseId:url
  • Matches are grouped by case, so a single page can trigger captures for multiple cases
  • The content script highlights matching text on the page and shows a sidebar with match details

Source Comparison

AspectAutoManualSelector
Session requiredYesNoSession must be active (for selector fetching)
Case IDInferred from active caseExplicit in requestExplicit in request
Dedup60s client-side per URLNone60s client-side per caseId:URL
Toast feedbackNoneYes (full lifecycle)None
ScreenshotYesYes (parallel)Yes
Archived case checkNo (active case assumed valid)YesYes

4. The Unified Capture Endpoint

Endpoint: POST /api/captures

Defined in src/main/services/captureServer.ts, line 254. All three capture sources hit this single endpoint, discriminated by the source field.

Request Body

typescript
1{
2 source: 'auto' | 'manual' | 'selector'
3 caseId?: string // required for manual/selector
4 url: string // required
5 title: string
6 html: string // required
7 screenshot?: string // base64-encoded PNG
8 timestamp: string
9 textContent?: string
10 headers?: Record<string, string>
11 matchedSelectors?: SelectorMatchInfo[] // required for selector
12}

Validation Pipeline

The endpoint processes requests in a strict order:

Step 1 -- Common validation (line 265):

1if (!url || !html) → 400 "Missing required fields: url, html"

Step 2 -- URL blacklist (line 270): The server checks the URL against settings.ignoredUrlPatterns using isUrlBlacklisted(). This is a defense-in-depth check -- the extension also filters URLs client-side, but the server enforces it independently.

1if blacklisted → 403 "URL blocked by ignored pattern"
2 + emits CaptureEvent { type: 'skipped', skipReason: "Blacklisted: {pattern}" }

Step 3 -- Source-specific validation (line 278):

SourceValidation
autostate.sessionActive must be true (400), state.activeCaseId must exist (400)
manualbody.caseId required (400), case must exist (404), case must not be archived (400)
selectorbody.caseId required (400), matchedSelectors required (400), case must exist (404), case must not be archived (400)
Unknown400 "Invalid source: {source}"

Step 4 -- Shared pipeline (line 319 onward):

  1. Emit received event
  2. Compute SHA-256 hash of the HTML content
  3. Decode base64 screenshot to Buffer (if present)
  4. Generate a UUID for the capture (crypto.randomUUID())
  5. Save files to disk via saveCapture()
  6. Insert capture record into SQLite via db.insertCapture()
  7. Increment state.captureCount (auto source only)
  8. Schedule post-capture work via setImmediate()
  9. Send NEW_CAPTURE IPC event to the renderer
  10. Emit stored event with duration measurement
  11. Return { captureId, hash, status: 'ok', source }

Step 5 -- Error handling (line 355):

On any exception in the shared pipeline:

  • Logs the error to console
  • Emits a failed CaptureEvent
  • Returns 500 "Failed to process capture"

Response

typescript
1{ captureId: string, hash: string, status: 'ok', source: string }

5. Page Extraction

Page extraction runs in the content script (extension/src/content.ts) and is triggered by the EXTRACT_PAGE message from the background script.

freeze-dry

The freeze-dry library serializes the live DOM into a self-contained HTML string. It inlines CSS, images, fonts, and other resources as data URIs, producing a snapshot that can be rendered offline without any external dependencies.

Configuration (line 265):

typescript
1freezeDry(document, {
2 timeout: 10000, // 10-second timeout to prevent hangs on complex pages
3 addMetadata: true // adds freeze-dry metadata to the output
4})

Degraded Fallback

If freeze-dry fails (throws an error), the content script falls back to raw document.documentElement.outerHTML and sets degraded: true in the response (line 272-278). This ensures a capture always succeeds, even if it lacks inlined resources.

The degraded flag propagates to the background script, which uses it to show an amber "Captured (basic snapshot)" toast instead of the green "Page captured" toast for manual captures.

Text Content Extraction

Alongside the HTML, the content script extracts document.body.innerText as plain text (line 263). This text content is:

  • Saved as a .txt file alongside the HTML
  • Used for entity extraction (regex pattern matching)
  • Used for selector matching (pattern detection)

Screenshot Capture

Screenshots are captured via the Chrome extension API in the background script:

typescript
1chrome.tabs.captureVisibleTab({ format: 'png' })

The result is a data URI that gets stripped of its data:image/png;base64, prefix before being sent to the server. Screenshot capture can fail silently (e.g., on restricted pages like chrome:// URLs), in which case the capture proceeds without a screenshot.

Parallel Extraction (Manual Capture)

For manual captures, freeze-dry and screenshot run in parallel via Promise.all (extension/src/background.ts, line 221):

typescript
1const [pageData, rawScreenshot] = await Promise.all([
2 chrome.tabs.sendMessage(tabId, { type: 'EXTRACT_PAGE' }),
3 chrome.tabs.captureVisibleTab({ format: 'png' }).catch(() => undefined)
4])

This minimizes total capture latency. For auto captures, the extraction and screenshot are sequential (lines 178-189).

6. Storage & Integrity

File Organization

All capture files are stored under a configurable storage root directory, organized by case ID:

1{storageRoot}/
2 {caseId}/
3 {captureId}.html -- freeze-dried (or raw fallback) HTML
4 {captureId}.png -- viewport screenshot (if available)
5 {captureId}.txt -- plain text content (if available)

The saveCapture() function in src/main/services/storage.ts (line 26) handles directory creation and file writes:

  • Creates the case directory if it does not exist (mkdirSync with recursive: true)
  • Writes HTML as UTF-8 text
  • Writes screenshot as raw binary (from a Node.js Buffer)
  • Writes text content as UTF-8 text
  • Returns relative paths (e.g., {caseId}/{captureId}.html) for storage in the database

SHA-256 Hash Computation

Every capture's HTML content is hashed using SHA-256 (src/main/services/hash.ts):

typescript
1export function hashContent(content: string): string {
2 return createHash('sha256').update(content, 'utf-8').digest('hex')
3}

The hash is:

  • Computed at capture time from the HTML string
  • Stored in the captures table hash column
  • Returned to the extension in the capture response
  • Used by the test pipeline to verify round-trip integrity

Hash Verification

The verifyHash() function allows checking whether stored content matches its expected hash:

typescript
1export function verifyHash(content: string, expectedHash: string): boolean {
2 return hashContent(content) === expectedHash
3}

This supports the integrity verification feature used in evidence export and the HashVerification type in src/shared/types.ts (line 124), which reports verified, tampered, or missing status per capture.

7. Observability

CaptureEvent Types

Defined in src/shared/types.ts (line 176):

typescript
1export interface CaptureEvent {
2 type: 'received' | 'stored' | 'failed' | 'skipped' | 'extraction_done'
3 captureId?: string
4 source: CaptureSource
5 url: string
6 timestamp: string
7 error?: string // only for 'failed'
8 skipReason?: string // only for 'skipped'
9 durationMs?: number // only for 'stored'
10}
Event TypeMeaningEmitted When
receivedCapture request accepted for processingAfter source-specific validation passes
storedFiles written and DB record createdAfter saveCapture() + insertCapture() succeed
failedCapture processing failedOn any exception in the shared pipeline
skippedCapture rejected before processingURL matches a blacklist pattern
extraction_donePost-capture analysis completeAfter entity extraction and selector matching finish

Event Flow

1captureServer.ts Electron IPC preload/index.ts useServerStatus.ts appStore.ts
2 | | | | |
3 |--emitCaptureEvent()--->| | | |
4 | mainWindow.webContents| | | |
5 | .send(CAPTURE_ACTIVITY| | | |
6 | , event) | | | |
7 | |--ipcRenderer.on()---->| | |
8 | | |--onCaptureActivity-->| |
9 | | | (callback) |--addCaptureEvent-->|
10 | | | | |

The emitCaptureEvent() function (src/main/services/captureServer.ts, line 139) sends events via the CAPTURE_ACTIVITY IPC channel. The preload bridge (src/preload/index.ts, line 132) exposes onCaptureActivity() which the useServerStatus hook subscribes to. The hook calls addCaptureEvent() on the Zustand store.

Health Dashboard

The CaptureHealth component (src/renderer/components/status/CaptureHealth.tsx) is mounted in the TopBar (src/renderer/components/layout/TopBar.tsx) and provides:

Trigger button: A pill-shaped button showing success count and failure count. Color-coded:

  • Emerald: captures succeeding, no failures
  • Red: failures detected
  • Slate: no activity yet

Popover panel (toggled on click):

  1. Status indicators: Extension connection state, session recording state
  2. Aggregate stats: {N}ok / {N}fail / {N}skip
  3. Last error: Most recent failure message and timestamp (if any)
  4. Activity list: Scrollable list of last 50 events, each showing:
    • Status icon (green check, red X, amber warning, blue spinner, indigo zap)
    • Truncated URL (40 chars)
    • Source type, timestamp, duration (for stored events)
    • Error message (for failed events)
    • Skip reason (for skipped events)
  5. Test buttons: "Test Pipeline" and "Test HTTP" (see Section 12)

Extension Toast Feedback

The toast system (extension/src/toast.ts) renders feedback directly on the captured page using Shadow DOM isolation (to avoid style conflicts with page CSS).

Toast lifecycle for manual captures:

  1. Background sends SHOW_CAPTURE_TOAST -- content script shows spinner + "Capturing page..."
  2. After capture completes, background sends UPDATE_CAPTURE_TOAST with the outcome:
StatusIconMessageAuto-dismiss
capturingSpinner"Capturing page..."No (stays until update)
successCheckmark"Page captured"3 seconds
degradedWarning"Captured (basic snapshot)"5 seconds
errorX mark"Capture failed" (or custom message)5 seconds
skippedInfo"Already captured"2 seconds

Error Categorization

When a manual capture fails, the background script categorizes the error by HTTP status (extension/src/background.ts, line 252):

HTTP StatusUser-Facing Message
400"Capture rejected: {server detail}"
403"URL is blacklisted"
404"Case not found"
500"Server error -- check Birdbrain app"
TypeError (network)"Can't reach Birdbrain -- is it running?"

The ApiError class in extension/src/utils/api.ts (line 3) carries the HTTP status and parsed error detail from the server response body.

8. Post-Capture Processing

After a capture is stored, the server schedules asynchronous work via schedulePostCaptureWork() (src/main/services/captureServer.ts, line 115):

typescript
1function schedulePostCaptureWork(
2 captureId: string,
3 caseId: string,
4 source: CaptureSource,
5 url: string,
6 textContent: string | undefined
7): void {
8 setImmediate(() => {
9 // 1. Rule-based entity extraction
10 // 2. Selector matching
11 // 3. Emit extraction_done event
12 })
13}

Rule-Based Entity Extraction

runRuleBasedExtraction() (line 75):

  1. Skips if textContent is empty/whitespace
  2. Loads enabled entity types from settings (cached after first load in cachedEnabledEntityTypes)
  3. Calls extractEntitiesRuleBased(textContent, enabledTypes) -- regex + NLP extraction
  4. Batch-inserts discovered entities into the entities table via db.insertEntitiesBatch()
  5. Sends an EXTRACTION_COMPLETE IPC event to the renderer with the entity count
  6. Errors are caught and logged, never propagated -- extraction failure does not fail the capture

Selector Matching

After entity extraction, the server runs db.matchSelectorsForCapture(captureId, caseId, textContent) (line 124). This checks the capture's text content against all active selectors for the case and records matches in the database.

Scheduling via setImmediate

Post-capture work uses setImmediate() rather than await for two reasons:

  1. The HTTP response returns immediately after storage -- the client does not wait for extraction
  2. Extraction errors cannot cause the capture to appear failed

Each task (extraction, selector matching) is wrapped in its own try/catch so that a failure in one does not prevent the other from running. After both complete, an extraction_done event is emitted.

9. Connection Management

Extension to Server Connection

The Chrome extension maintains a connection to the Hono server through status polling.

Polling interval (extension/src/background.ts, line 97):

typescript
1setInterval(() => { checkStatus() }, connected ? 30_000 : 5_000)
  • When disconnected: polls every 5 seconds (aggressive reconnect)
  • When connected: polls every 30 seconds (keepalive)
  • Initial check runs immediately on extension load

Status endpoint: GET /api/status returns server state including running, activeCase, sessionActive, captureCount, autoCaptureMode, cases, and ignoredUrlPatterns.

Server-Side Connection Tracking

The server tracks when the extension was last seen via state.extensionLastSeen (updated on every /api/status hit). A 5-second interval check (startExtensionConnectionCheck(), line 478) detects disconnection:

1if (Date.now() - extensionLastSeen > 10000) → extension disconnected

When connection state changes, the server sends EXTENSION_CONNECTION IPC events to the renderer.

Badge States

The extension icon and badge reflect connection state (extension/src/background.ts, line 381):

StateIconBadge TextBadge Color
disconnectedDefault!Gray (#6b7280)
connectedDefault(empty)--
active (session recording)Active variantCapture countRed (#ef4444)
Selector match--Match countBlue (#3b82f6)

10. Design Decisions

Why a Unified Endpoint Over Separate Endpoints

The previous design had three nearly-identical endpoints (/api/captures, /api/captures/manual, /api/captures/selector) duplicating ~80% of their logic. The unified endpoint with a source discriminator:

  • Eliminates code duplication for the shared pipeline (hash, storage, DB, events)
  • Makes it impossible for source-specific endpoints to drift out of sync
  • Simplifies the extension API from 4 capture functions to 1
  • Makes the validation rules per source explicit in a single decision tree

Why Single-Phase Capture Over Two-Phase

The previous two-phase manual capture showed "Captured!" immediately (Phase 1: raw HTML), then ran freeze-dry in the background (Phase 2). Phase 2 failures were silent -- the user saw success but got a degraded capture.

The single-phase design:

  • Waits for freeze-dry (with 10s timeout) before responding
  • Reports actual outcome: success, degraded, or failure
  • Eliminates the PATCH /api/captures/:id/html endpoint and EXTRACT_PAGE_FAST content script handler
  • Trade-off: manual capture latency increases from ~100ms to ~500ms-2s, but feedback is honest

Why Client-Side Dedup Instead of Server-Side

Auto-capture deduplication (60-second window per URL) runs in the extension's background.ts via dedupeMap. The server does not perform dedup because:

  • The extension already knows recent URLs it has captured -- no HTTP round-trip needed for dedup checks
  • Server-side dedup would require maintaining URL state or querying the database on every request
  • The skipped event is never emitted from the server for dedup -- the extension simply does not send the request
  • Selector captures have their own dedup keyed by caseId:url (different URLs can match the same selectors for different cases)

Why Fire-and-Forget for Entity Extraction

Post-capture entity extraction runs via setImmediate() and its results are not included in the HTTP response because:

  • Extraction can take variable time depending on text length and enabled entity types
  • A failed extraction should not invalidate a successful capture
  • The renderer is notified asynchronously via EXTRACTION_COMPLETE IPC when extraction finishes
  • The extraction_done CaptureEvent provides observability into extraction completion

Why Ring Buffer (50 Events) for the Activity List

The Zustand store caps capture events at 50 (appStore.ts, line 106):

typescript
1const events = [event, ...s.captureEvents].slice(0, 50)
  • Prevents unbounded memory growth during long recording sessions
  • 50 events provides sufficient recent history for debugging
  • Events are prepended (newest first) for display in the health dashboard
  • Aggregate stats (successCount, failCount, skipCount) accumulate independently and are not lost when events rotate out

11. URL Filtering

Blacklist Pattern Types

The isUrlBlacklisted() function in src/main/services/captureServer.ts (line 56) and the equivalent isIgnoredByUser() in extension/src/background.ts (line 139) support three pattern formats:

  1. Regex: Patterns wrapped in / delimiters (e.g., /^https:\/\/mail\.google\.com/i). Parsed with new RegExp().
  2. Glob: Patterns containing * or ? wildcards (e.g., *.google.com/mail/*). Converted to regex via globToRegex().
  3. Literal substring: Plain strings (e.g., facebook.com). Matched via url.includes(pattern).

Default Ignore Patterns (Extension)

The extension has hardcoded patterns in DEFAULT_IGNORE (extension/src/background.ts, line 9):

typescript
1const DEFAULT_IGNORE = [
2 /^chrome:\/\//,
3 /^chrome-extension:\/\//,
4 /^about:/,
5 /^devtools:/,
6 /^data:/,
7 /^file:/,
8 /localhost:19845/ // the capture server itself
9]

These are applied client-side before any request is sent. They filter out browser internal pages and prevent recursive captures of the Birdbrain server.

User-Configured Patterns

Users can add custom ignored URL patterns via settings (BirdbrainSettings.ignoredUrlPatterns). These are:

  1. Fetched by the extension on every status poll and stored in userIgnoredPatterns
  2. Applied client-side in the extension via isIgnoredByUser() before sending captures
  3. Applied server-side in isUrlBlacklisted() as a defense-in-depth check -- if the extension is out of date or a direct HTTP request bypasses the extension, the server still rejects blacklisted URLs

Dual-Layer Filtering

URL filtering happens at two layers:

1Extension (client-side) Server (defense-in-depth)
2======================== =========================
3DEFAULT_IGNORE (regex) settings.ignoredUrlPatterns
4+ userIgnoredPatterns via isUrlBlacklisted()
5via shouldCapture() / isIgnoredByUser() → 403 with pattern name
6→ request never sent + CaptureEvent { type: 'skipped' }

12. Testing

Test Pipeline Endpoint

GET /api/captures/test (src/main/services/captureServer.ts, line 369)

This endpoint exercises the full capture pipeline without requiring the Chrome extension:

  1. Finds the first available case in the database (fails if no cases exist)
  2. Creates test HTML: <html><body>Birdbrain pipeline test {timestamp}</body></html>
  3. Computes SHA-256 hash of the test HTML
  4. Emits a received CaptureEvent
  5. Saves the test capture to disk via saveCapture()
  6. Inserts it into the database via db.insertCapture()
  7. Reads it back and verifies the hash matches
  8. Emits a stored CaptureEvent
  9. Cleans up: deletes the test capture from the database in a finally block

Returns: { success: boolean, durationMs: number, error?: string }

HTTP Test

The HTTP test (captures:testHttp IPC channel, src/main/ipcHandlers.ts, line 94) verifies the Hono server is reachable by making a fetch() call to GET /api/status from the main process. This tests the HTTP listener independently of the extension.

Health Dashboard Test Buttons

Both tests are accessible from the CaptureHealth popover:

  • "Test Pipeline": Invokes window.birdbrain.testPipeline() -> IPC captures:testPipeline -> GET /api/captures/test on the local server
  • "Test HTTP": Invokes window.birdbrain.testHttp() -> IPC captures:testHttp -> GET /api/status on the local server

Results are displayed inline: "Pipeline OK -- verified in {N}ms" (green) or "Pipeline FAILED: {error}" (red).

Unit Test Coverage

The capture server endpoint is covered by tests in tests/main/services/captureServer.test.ts. Key test scenarios include:

  • Unified endpoint validation per source type
  • URL blacklist rejection
  • Event emission at each pipeline stage
  • Test endpoint round-trip verification