From 999a9d0827648107ba7ec418a465d5402f135e64 Mon Sep 17 00:00:00 2001
From: Chen <99816898+donteatfriedrice@users.noreply.github.com>
Date: Tue, 28 Apr 2026 18:06:27 +0800
Subject: [PATCH] chore(m5): add Spool Daemon upgrade notice + clean up docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One-time notice for users upgrading from a pre-M5 Spool, plus the
cross-cutting documentation cleanup that PR2-#5 deferred.
## Upgrade notice
Built as a React modal (not a native dialog) so it matches Spool's
design system — warm card, Geist Sans, accent CTA — and shows the
real Spool Daemon app icon (copied from spool-lab/spool-daemon).
Trigger logic (in main, exposed via IPC):
- shouldShow = !shown && !wasNewDb && initialUserVersion < 5
- New users with no prior DB → don't show (no captures to mourn)
- Existing users at v0-v4 → show on first launch after upgrade
- After dismiss/install click → flag persists, never shown again
- User who quits without dismissing → loses the notice (acceptable
for an informational nudge; not critical state)
The "wasNewDb" + "getInitialUserVersion" helpers are added to
packages/core/src/db/db.ts. Both are captured before runMigrations
runs so the post-state can't muddy the upgrade detection.
uiPreferences now respects SPOOL_DATA_DIR (was hardcoded to
~/.spool/ui.json regardless of env override) — this surfaced during
sandbox smoke testing and would have leaked dialog-shown state out
of test sandboxes.
The "Get Spool Daemon" button uses shell.openExternal to
https://spool.pro/daemon (was earlier mistakenly written as
spool.dev/daemon in three places — fixed across the dialog, README,
and positioning doc).
## Doc cleanup (cross-cutting)
- README.md — drop stars/bookmarks/likes from tagline, drop the
Connectors bullet, replace with a forward to Spool Daemon, drop
the fieldtheory-cli acknowledgement (Twitter bookmarks gone)
- DESIGN.md — drop bookmarks/capture from Product Context, rewrite
Source Chips + Sources Panel sections to be session-only, drop
"Capture / add" icon, fix Status Bar dot color guidance
- docs/spool-positioning.md — strip bookmark/star marketing, end
with a forward to Spool Daemon
- packages/core/README.md — drop "connected sources", "connector
sync engine", "connector loader", "connector registry" — replaced
with watcher + stars
- Deleted docs/connector-developer-guide.md (607 lines) and
docs/connector-sync-architecture.md (924 lines) — both
exclusively about the removed subsystem
docs/superpowers/* historical plans + specs left intact (they're
dated git-log-style records, not current docs).
## Tests
- Extended migration-v5.test.ts with assertions for the new
wasNewDb / getInitialUserVersion helpers on both the upgrade
path (wasNewDb=false, initialVersion=4) and the fresh-install
path (wasNewDb=true, initialVersion=0).
- Smoke verified locally: launching dev against a downgraded v4
sandbox shows the modal with the right icon and copy; clicking
either button persists the flag and the modal does not return on
re-launch.
Net -1466 / +103.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
DESIGN.md | 18 +-
README.md | 11 +-
docs/connector-developer-guide.md | 607 ------------
docs/connector-sync-architecture.md | 924 ------------------
docs/spool-positioning.md | 18 +-
packages/app/src/main/index.ts | 27 +-
packages/app/src/main/uiPreferences.ts | 14 +-
packages/app/src/preload/index.ts | 7 +
packages/app/src/renderer/App.tsx | 13 +
.../app/src/renderer/assets/daemon-icon.png | Bin 0 -> 55487 bytes
.../renderer/components/DaemonNoticeModal.tsx | 81 ++
packages/core/README.md | 9 +-
packages/core/src/db/db.ts | 16 +-
packages/core/src/db/migration-v5.test.ts | 8 +
14 files changed, 184 insertions(+), 1569 deletions(-)
delete mode 100644 docs/connector-developer-guide.md
delete mode 100644 docs/connector-sync-architecture.md
create mode 100644 packages/app/src/renderer/assets/daemon-icon.png
create mode 100644 packages/app/src/renderer/components/DaemonNoticeModal.tsx
diff --git a/DESIGN.md b/DESIGN.md
index 3fc50fa..33e77fe 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,11 +1,11 @@
# Design System — Spool
## Product Context
-- **What this is:** A local search engine for your thinking — an Electron macOS app that indexes your AI sessions (Claude Code, Codex, ChatGPT), bookmarks (Twitter, GitHub, YouTube), and any URL you capture, then lets you search it all instantly.
+- **What this is:** A local search engine for your thinking — an Electron macOS app that indexes your AI sessions (Claude Code, Codex, Gemini) and lets you search them instantly.
- **Who it's for:** Developers who think with AI daily and have accumulated hundreds of sessions across multiple tools. The persona has re-explained the same context to AI agents dozens of times.
- **Space/industry:** Developer productivity / local-first tooling. Peers: Raycast, Spotlight, Obsidian, Perplexity — but none of them do this.
- **Project type:** macOS Electron app — compact utility window, not a document editor or dashboard.
-- **Core positioning:** "A local Google for your thinking." Search is the entire product. Everything else (sources, capture, AI mode) is in service of the search box.
+- **Core positioning:** "A local Google for your thinking." Search is the entire product. Everything else (session sources, AI mode) is in service of the search box.
## Aesthetic Direction
- **Direction:** Warm Index — library-warm, not terminal-cold. Function-first but with personality.
@@ -124,13 +124,12 @@ Each data source has a fixed color used consistently across badges, chips, and d
### Source Chips (home screen)
- Pill shape, `--surface` background, source dot + name + count.
-- `+ Connect` uses dashed border.
-- Clicking a chip opens the Sources panel filtered to that source.
+- One chip per agent source (Claude / Codex / Gemini).
+- Clicking a chip opens Settings → Sources tab filtered to that source.
-### Sources Panel (accessible from status bar)
-- Slides up from status bar or opens as a separate window. Two tabs: **Sources** and **Import URL**.
-- Sources tab: list of configured connectors with toggle switch, last-sync time, item count.
-- Toggle switches: on = `--accent` background. Off = `--border2` background.
+### Sources Panel (Settings tab)
+- Lists the three built-in agent sources with their session counts.
+- Status: `auto` label + green dot when watcher is healthy.
### AI Answer Card
- Left border: 3px solid `--accent`. Background: `--accent-bg`.
@@ -142,14 +141,13 @@ Each data source has a fixed color used consistently across badges, chips, and d
- Always visible, 30px height, `--surface` background.
- Left: colored dot (green/yellow/red) + synced item count + last sync time.
- Right: `Sources ⊕` button (replace `⊕` with vector icon).
-- Red dot only when a connector has auth error — not for normal background sync.
+- Dot is green when sync is healthy; yellow during active sync; red only on filesystem watcher errors.
## Icons
- **Library:** Lucide React (`lucide-react`) — consistent stroke weight, MIT licensed.
- **Search:** `Search` icon (Lucide)
- **Source indicators:** Replace all emoji placeholder icons with purpose-drawn SVGs or Lucide equivalents. Emoji are placeholders only in mockups.
- **Mode toggle:** Custom SVG — lightning bolt (⚡ Fast) and a minimal "brain" or sparkle (AI mode).
-- **Capture / add:** `PlusCircle` or `Plus` (Lucide)
- **Settings:** `Settings2` (Lucide)
- **Status dots:** No icon — pure colored circle via CSS.
- **Stroke width:** 1.5px at 16px, 1.5px at 14px. Never bold/filled for UI chrome.
diff --git a/README.md b/README.md
index 227a3f9..68fb007 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ The missing search engine for your own data.
-Search your Claude Code sessions, Codex CLI history, Gemini CLI chats, GitHub stars, Twitter bookmarks, and YouTube likes — locally, instantly.
+Search your Claude Code sessions, Codex CLI history, and Gemini CLI chats — locally, instantly.
> **Early stage.** Spool is under active development — expect rough edges. Feedback, bug reports, and ideas are very welcome via [Issues](https://github.com/spool-lab/spool/issues) or [Discord](https://discord.gg/aqeDxQUs5E).
@@ -26,14 +26,15 @@ pnpm build
## What it does
-Spool indexes your AI conversations and bookmarks into a single local search box.
+Spool indexes your AI conversations into a single local search box.
- **AI sessions** — watches Claude/Codex/Gemini session dirs in real time, including profile-based paths like `~/.claude-profiles/*/projects`, `~/.codex-profiles/*/sessions`, and Gemini’s project temp dirs under `~/.gemini/tmp/*/chats`
-- **Connectors** — sync bookmarks and stars from platforms like Twitter/X, GitHub, and more via installable connector plugins
- **Agent search** — a `/spool` skill inside Claude Code feeds matching fragments back into your conversation
Everything stays on your machine. Nothing leaves.
+> Looking for connectors (Twitter / GitHub / Reddit / etc.)? They now live in **[Spool Daemon](https://spool.pro/daemon)**, a sibling app focused on syncing platform data.
+
## Architecture
```
@@ -74,10 +75,6 @@ until CI finishes; artifacts appear on the release page when it returns.
To test a local build without cutting a release, use `pnpm --filter @spool/app build:mac`.
-## Acknowledgements
-
-- **[fieldtheory-cli](https://github.com/afar1/fieldtheory-cli)** — Twitter/X bookmark sync implementation adapted from this project
-
## License
MIT
diff --git a/docs/connector-developer-guide.md b/docs/connector-developer-guide.md
deleted file mode 100644
index bc5e1fa..0000000
--- a/docs/connector-developer-guide.md
+++ /dev/null
@@ -1,607 +0,0 @@
-# Connector Developer Guide
-
-> Everything you need to build, test, and publish a Spool connector.
-
----
-
-## What is a Connector?
-
-A connector is a small npm package that teaches Spool how to fetch data from one platform source. You implement two methods — `checkAuth()` and `fetchPage()` — and the framework handles everything else: scheduling, state persistence, error retries, progress UI, and search indexing.
-
-A connector does NOT:
-- Know when it will be called (the scheduler decides)
-- Track pagination state (the sync engine manages cursors)
-- Write to the database (the engine handles upserts)
-- Handle retries or backoff (the scheduler handles this)
-
-Your job is simple: **given a `FetchContext`, return one page of items.** Most connectors only need the `cursor` field — `sinceItemId` and `phase` are optional hints.
-
----
-
-## Anatomy of a Connector
-
-### The Interface
-
-```typescript
-interface Connector {
- readonly id: string // Unique ID: 'twitter-bookmarks', 'github-stars'
- readonly platform: string // Platform grouping: 'twitter', 'github'
- readonly label: string // Display name: 'X Bookmarks', 'GitHub Stars'
- readonly description: string // One-liner for picker UI
- readonly color: string // Hex color for badges: '#1DA1F2'
- readonly ephemeral: boolean // true = cache (full-replace), false = user data (incremental)
-
- checkAuth(opts?: Record): Promise
- fetchPage(ctx: FetchContext): Promise
-}
-```
-
-### The Six Properties
-
-| Property | Purpose | Example |
-|----------|---------|---------|
-| `id` | Globally unique across all connectors. Used as DB key, IPC identifier, and npm package suffix. | `'twitter-bookmarks'` |
-| `platform` | Groups connectors from the same service. One platform can have multiple connectors (e.g. `twitter-bookmarks`, `twitter-following`). | `'twitter'` |
-| `label` | Shown in the connector list and settings UI. Keep it short. | `'X Bookmarks'` |
-| `description` | Shown below the label in the connector picker. One sentence. | `'Your saved tweets on X'` |
-| `color` | Hex color for the platform dot/badge in the UI. Use the platform's brand color. | `'#1DA1F2'` |
-| `ephemeral` | **Critical flag.** Determines sync strategy. See [Ephemeral vs. Persistent](#ephemeral-vs-persistent) below. | `false` |
-
-### The Two Methods
-
-#### `checkAuth(): Promise`
-
-Called before each sync cycle and when the user clicks "Connect" in the UI. Returns whether the connector can authenticate with the platform right now.
-
-```typescript
-interface AuthStatus {
- ok: boolean
- error?: SyncErrorCode // Machine-readable error classification
- message?: string // Technical detail (logged, not shown to user)
- hint?: string // User-facing guidance: "Log into X in Chrome, then retry."
-}
-```
-
-**Rules:**
-- Must be fast (< 2 seconds). Don't make network requests here — just check if credentials exist locally.
-- Always provide a `hint` on failure. The hint is shown directly in the UI. Write it as an instruction the user can act on.
-- Never throw. Always return an `AuthStatus` object.
-
-#### `fetchPage(ctx: FetchContext): Promise`
-
-The core data fetching method. Called repeatedly by the sync engine to paginate through the platform's data.
-
-```typescript
-interface FetchContext {
- cursor: string | null // Pagination cursor. null = start from newest.
- sinceItemId: string | null // Platform ID of newest known item. The engine
- // passes this during forward sync so you can
- // optimize if your API supports "since" filtering.
- // null during backfill or first-ever sync.
- phase: 'forward' | 'backfill' // Which sync phase is requesting this page.
-}
-
-interface PageResult {
- items: CapturedItem[] // Items on this page
- nextCursor: string | null // Cursor for next page, null = no more data
-}
-```
-
-**Rules:**
-- When `cursor` is `null`, fetch the **newest** page (most recent items first).
-- Return `nextCursor: null` when there are no more pages.
-- Items should be ordered newest-first within each page (this is how most APIs work naturally).
-- Throw `SyncError` on failures. The engine catches it, updates error state, and the scheduler handles backoff.
-- Keep pages small-ish (10–25 items). The engine adds a delay between pages to avoid rate limiting.
-- You can safely ignore `sinceItemId` and `phase` — just destructure `{ cursor }` and use it. The engine has its own early-exit logic that works regardless.
-
----
-
-## CapturedItem: The Universal Data Unit
-
-Every item from every connector is normalized into this shape before storage:
-
-```typescript
-interface CapturedItem {
- url: string // Original URL on the platform
- title: string // Display title
- contentText: string // Full text content (indexed for search)
- author: string | null // Author handle or name
- platform: string // Must match connector.platform
- platformId: string | null // Platform-unique ID (CRITICAL for dedup)
- contentType: string // 'tweet', 'repo', 'video', 'post', 'page', etc.
- thumbnailUrl: string | null
- metadata: Record // Platform-specific extras
- capturedAt: string // ISO 8601 timestamp from the platform
- rawJson: string | null // Raw API response for future re-parsing
-}
-```
-
-### Key Fields Explained
-
-**`platform` + `platformId`** — The deduplication key. The sync engine upserts items by this pair. If two items share the same `(platform, platformId)`, the newer one updates the older one. **Always set `platformId`** to the platform's native ID for the item (tweet ID, repo ID, video ID, etc.).
-
-**`contentText`** — This is what gets full-text indexed. Put the main textual content here: tweet text, repo description, article body, etc. This powers Spool's search.
-
-**`capturedAt`** — Use the platform's timestamp, not the sync time. For a tweet, this is when the tweet was posted. For a GitHub star, this is when the repo was starred. This determines sort order in search results.
-
-**`metadata`** — Extensible JSON bag for anything not covered by the base fields. Common uses:
-- Engagement counts: `{ likeCount, repostCount, viewCount }`
-- Media attachments: `{ media: [{ type, url, width, height }] }`
-- Author details: `{ authorSnapshot: { handle, name, bio, followers } }`
-- Platform-specific data: `{ language, conversationId, isVerified }`
-
-The framework automatically adds `metadata.connectorId` — you don't need to set this.
-
-**`rawJson`** — Store the raw API response. This allows re-parsing items when the schema changes, without re-fetching from the platform.
-
----
-
-## Ephemeral vs. Persistent
-
-The `ephemeral` flag fundamentally changes how the sync engine treats your connector:
-
-### `ephemeral: false` — User-Owned Data (Default)
-
-For data the user created, saved, or curated: bookmarks, stars, saved posts, watch history.
-
-**Sync strategy: Dual-frontier incremental sync.**
-
-```
-[oldest] ◄── tail (backfill) ──── stored data ──── head (forward) ──► [newest]
-```
-
-- **Forward sync** runs frequently (every 15 min). Fetches from newest, stops when it hits already-known items (3 consecutive pages with 0 new items).
-- **Backfill** runs less often (every 60 min). Fills in historical data from where it last stopped, working backwards through time.
-- Items are upserted (dedup by `platform + platformId`), never deleted.
-- State persists across app restarts: cursors, page counts, error history.
-
-### `ephemeral: true` — Cache Data
-
-For public/trending data not tied to user actions: hot topics, trending repos, rankings.
-
-**Sync strategy: Full-replace.**
-
-- Every sync cycle deletes all existing items for this connector, then fetches fresh.
-- No cursor tracking. Always starts from page 1.
-- Simpler, but items don't persist between syncs.
-
----
-
-## Scheduled Sync: How and When Your Connector Runs
-
-You don't control when your connector runs. The **SyncScheduler** handles this automatically.
-
-### Default Schedule
-
-| Parameter | Default | Meaning |
-|-----------|---------|---------|
-| Forward interval | 15 minutes | How often new items are fetched |
-| Backfill interval | 60 minutes | How often historical backfill runs |
-| Page delay | 1200ms | Sleep between `fetchPage()` calls (rate limiting) |
-| Max minutes per run | 10 minutes | Sync aborts after this (scheduler-initiated only; CLI has no limit) |
-| Concurrency | 1 | Only one connector syncs at a time |
-
-The schedule is **global** — all connectors share the same intervals. Per-connector tuning is not currently exposed (but `configJson` in the DB is reserved for this).
-
-### When Does Sync Happen?
-
-| Event | What Happens | Priority |
-|-------|-------------|----------|
-| App launch | All enabled connectors queue for forward+backfill | 80 |
-| System wake | All enabled connectors queue for forward | 60 |
-| Every 30 seconds | Scheduler checks which connectors are "due" based on interval | 40 (forward) / 20 (backfill) |
-| User clicks "Sync now" | That connector queues immediately | 100 |
-
-Higher priority jobs run first. With concurrency=1, only one connector syncs at a time.
-
-### Error Backoff
-
-When `fetchPage()` throws a `SyncError`, the engine increments `consecutiveErrors` on the connector's state. The scheduler uses this to delay retries:
-
-| Consecutive Errors | Wait Before Retry |
-|-------------------|-------------------|
-| 0 | Normal interval |
-| 1 | 60 seconds |
-| 2 | 5 minutes |
-| 3 | 30 minutes |
-| 4+ | 2 hours (cap) |
-
-On a successful sync, `consecutiveErrors` resets to 0.
-
-**Auth errors are special**: any error code starting with `AUTH_` causes the scheduler to stop retrying entirely. The connector stays disabled until the user manually re-authenticates (clicks "Connect" in the UI, which calls `checkAuth()` again).
-
-### Stop Conditions
-
-The sync engine stops a forward sync when ANY of:
-1. **Reached since-anchor**: A page contains the item matching `sinceItemId` (caught up precisely — most efficient)
-2. **Caught up**: 3 consecutive pages with 0 new items (fallback when no anchor exists)
-3. **End of data**: `nextCursor` is `null`
-4. **Time limit**: Exceeded `maxMinutes` (10 min for scheduler, unlimited for CLI). Forward saves `headCursor` for resume.
-5. **Cancelled**: App is quitting or user aborted. Forward saves `headCursor` for resume.
-6. **Error**: `fetchPage()` threw. Forward saves `headCursor` for resume.
-
-### Progress & Events
-
-The scheduler emits events that flow to the UI in real time:
-
-```typescript
-type SchedulerEvent =
- | { type: 'sync-start'; connectorId: string }
- | { type: 'sync-progress'; progress: SyncProgress }
- | { type: 'sync-complete'; result: ConnectorSyncResult }
- | { type: 'sync-error'; connectorId: string; code: SyncErrorCode; message: string }
-```
-
-The UI shows: which connector is syncing, current page, items found, phase (forward/backfill).
-
----
-
-## Error Handling
-
-Connectors signal errors by throwing `SyncError`:
-
-```typescript
-import { SyncError } from '@spool/core'
-
-throw new SyncError('API_RATE_LIMITED', 'Got 429, retry after 60s')
-throw new SyncError('AUTH_SESSION_EXPIRED', 'Cookie returned 401')
-throw new SyncError('NETWORK_OFFLINE') // message defaults to hint text
-```
-
-### Error Code Reference
-
-| Code | When to Use | Framework Behavior |
-|------|------------|-------------------|
-| `AUTH_CHROME_NOT_FOUND` | Chrome or its cookie DB doesn't exist | Stop scheduling, show "needs setup" |
-| `AUTH_NOT_LOGGED_IN` | Platform cookies missing (user not logged in) | Stop scheduling, show "log in" hint |
-| `AUTH_COOKIE_DECRYPT_FAILED` | OS-level decryption failed | Stop scheduling |
-| `AUTH_KEYCHAIN_DENIED` | macOS Keychain access denied | Stop scheduling |
-| `AUTH_SESSION_EXPIRED` | 401/403 from platform API | Stop scheduling, show "re-authenticate" |
-| `API_RATE_LIMITED` | 429 response | Retry with backoff |
-| `API_SERVER_ERROR` | 5xx response | Retry with backoff |
-| `NETWORK_OFFLINE` | DNS/connection failure | Retry with backoff |
-| `NETWORK_TIMEOUT` | Request timed out | Retry with backoff |
-| `API_PARSE_ERROR` | Response shape doesn't match expected schema | No retry (likely a breaking API change) |
-| `CONNECTOR_ERROR` | Anything else | No retry |
-
-**Rule of thumb**: Use `AUTH_*` codes for anything that requires user action to fix. Use `API_*`/`NETWORK_*` codes for transient issues the framework can retry.
-
----
-
-## Authentication Patterns
-
-The `Connector` interface doesn't prescribe how authentication works — it only requires that `checkAuth()` returns an `AuthStatus`. This gives you flexibility to implement whatever auth pattern your platform needs.
-
-### Pattern 1: Chrome Cookie Extraction (Recommended)
-
-Used by Twitter Bookmarks. Reads encrypted cookies directly from Chrome's SQLite database on macOS. **No user interaction needed** — if the user is logged into the platform in Chrome, it just works.
-
-```typescript
-async checkAuth(): Promise {
- try {
- const cookies = extractChromeCookies('.example.com', ['session_id', 'csrf_token'])
- return { ok: true }
- } catch (e) {
- if (e instanceof SyncError) {
- return { ok: false, error: e.code, message: e.message, hint: e.hint }
- }
- return { ok: false, error: 'AUTH_UNKNOWN', hint: 'Check that Chrome is installed and you are logged in.' }
- }
-}
-
-async fetchPage({ cursor }: FetchContext): Promise {
- const cookies = extractChromeCookies('.example.com', ['session_id', 'csrf_token'])
- const response = await fetch('https://api.example.com/bookmarks', {
- headers: { Cookie: cookies.cookieHeader }
- })
- // ... parse response
-}
-```
-
-**Pros**: Zero friction, no OAuth flow, works with any platform the user is logged into.
-**Cons**: macOS only (for now), requires Chrome, cookies can expire mid-sync.
-
-**Shared utility**: The Twitter Bookmarks connector includes a `chrome-cookies.ts` module with macOS Keychain integration, AES-128-CBC decryption, and Chrome DB version handling. Other cookie-based connectors can reuse or adapt this code.
-
-### Pattern 2: CLI Tool Delegation
-
-Used when a well-maintained CLI tool already exists for the platform (e.g., `gh` for GitHub). The connector shells out to the CLI instead of making direct API calls.
-
-```typescript
-async checkAuth(): Promise {
- try {
- const { stdout } = await execAsync('gh auth status')
- return { ok: true }
- } catch {
- return { ok: false, hint: 'Run `gh auth login` in your terminal.' }
- }
-}
-
-async fetchPage({ cursor }: FetchContext): Promise {
- const page = cursor ? parseInt(cursor) : 1
- const { stdout } = await execAsync(`gh api /user/starred?per_page=30&page=${page}`)
- const repos = JSON.parse(stdout)
- return {
- items: repos.map(repoToCapturedItem),
- nextCursor: repos.length === 30 ? String(page + 1) : null,
- }
-}
-```
-
-**Pros**: Leverages existing auth flows (OAuth tokens managed by the CLI), well-tested API wrappers.
-**Cons**: Requires the CLI to be installed, subprocess overhead, output parsing can be brittle.
-
-### Pattern 3: API Token / Config File
-
-For platforms that use API keys, tokens, or config files. The token is stored in the connector's `configJson` field in the DB, or read from a well-known config file path.
-
-```typescript
-async checkAuth(): Promise {
- const config = this.loadConfig() // from configJson or ~/.config/myplatform/token
- if (!config?.apiToken) {
- return { ok: false, hint: 'Set your API token in Spool connector settings.' }
- }
- return { ok: true }
-}
-```
-
-### Pattern 4: No Auth Required
-
-For public data sources (RSS feeds, public APIs). Just return `{ ok: true }`.
-
-```typescript
-async checkAuth(): Promise {
- return { ok: true }
-}
-```
-
-### Auth Design Guidelines
-
-1. **`checkAuth()` must be fast** — no network calls. Check if credentials exist, not if they're valid.
-2. **Always provide a `hint`** — this is shown to the user in the UI. Make it actionable.
-3. **Never store secrets in code** — use Chrome cookies, CLI auth, or per-connector `configJson`.
-4. **Handle expiration gracefully** — if a 401/403 comes during `fetchPage()`, throw `SyncError('AUTH_SESSION_EXPIRED')`. The framework will stop scheduling and surface it in the UI.
-
----
-
-## Building a Connector: Step by Step
-
-### 1. Create the Package
-
-```bash
-mkdir spool-lab-connector-github-stars
-cd spool-lab-connector-github-stars
-npm init -y
-```
-
-Edit `package.json`:
-
-```json
-{
- "name": "@spool-lab/connector-github-stars",
- "version": "0.1.0",
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "spool": {
- "type": "connector",
- "id": "github-stars",
- "platform": "github",
- "label": "GitHub Stars",
- "description": "Repos you've starred on GitHub",
- "color": "#333333",
- "ephemeral": false
- },
- "peerDependencies": {
- "@spool/core": "^0.x"
- }
-}
-```
-
-The `spool` field is the connector manifest. The app reads this to display connector metadata in the UI and on the spool.pro directory page, without loading the module.
-
-### 2. Implement the Connector
-
-```typescript
-// src/index.ts
-import type { Connector, AuthStatus, PageResult, CapturedItem } from '@spool/core'
-import { SyncError } from '@spool/core'
-import { execSync } from 'node:child_process'
-
-export default class GitHubStarsConnector implements Connector {
- readonly id = 'github-stars'
- readonly platform = 'github'
- readonly label = 'GitHub Stars'
- readonly description = 'Repos you\'ve starred on GitHub'
- readonly color = '#333333'
- readonly ephemeral = false
-
- async checkAuth(): Promise {
- try {
- execSync('gh auth status', { stdio: 'pipe' })
- return { ok: true }
- } catch {
- return {
- ok: false,
- error: 'AUTH_NOT_LOGGED_IN',
- hint: 'Install GitHub CLI and run `gh auth login` in your terminal.',
- }
- }
- }
-
- async fetchPage({ cursor }: FetchContext): Promise {
- const page = cursor ? parseInt(cursor) : 1
- const perPage = 30
-
- let stdout: string
- try {
- const result = execSync(
- `gh api /user/starred?per_page=${perPage}&page=${page} -H "Accept: application/vnd.github.v3.star+json"`,
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
- )
- stdout = result
- } catch (e: any) {
- if (e.status === 401) throw new SyncError('AUTH_SESSION_EXPIRED')
- if (e.status === 429) throw new SyncError('API_RATE_LIMITED')
- throw new SyncError('CONNECTOR_ERROR', e.message)
- }
-
- const starred = JSON.parse(stdout)
- const items: CapturedItem[] = starred.map((entry: any) => ({
- url: entry.repo.html_url,
- title: entry.repo.full_name,
- contentText: entry.repo.description ?? '',
- author: entry.repo.owner.login,
- platform: 'github',
- platformId: String(entry.repo.id),
- contentType: 'repo',
- thumbnailUrl: entry.repo.owner.avatar_url,
- metadata: {
- language: entry.repo.language,
- stars: entry.repo.stargazers_count,
- forks: entry.repo.forks_count,
- topics: entry.repo.topics,
- },
- capturedAt: entry.starred_at, // when YOU starred it, not when repo was created
- rawJson: JSON.stringify(entry),
- }))
-
- return {
- items,
- nextCursor: starred.length === perPage ? String(page + 1) : null,
- }
- }
-}
-```
-
-### 3. Declare the Manifest
-
-The `spool` field in `package.json` (shown above) must include:
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| `type` | Yes | Always `"connector"` |
-| `id` | Yes | Must match `connector.id` in code |
-| `platform` | Yes | Must match `connector.platform` in code |
-| `label` | Yes | Display name |
-| `description` | Yes | One-line description |
-| `color` | Yes | Hex color for UI |
-| `ephemeral` | Yes | Sync strategy flag |
-
-### 4. Test Locally
-
-During development, install your connector locally:
-
-```bash
-cd ~/.spool/connectors
-npm install /path/to/your/spool-lab-connector-github-stars
-```
-
-Restart the Spool app. Your connector should appear in the Sources panel. Or test via CLI:
-
-```bash
-spool connector sync github-stars
-```
-
-### 5. Publish
-
-```bash
-npm publish --access public
-```
-
-Users install via:
-```bash
-# From Spool app UI (future), or manually:
-cd ~/.spool/connectors && npm install @spool-lab/connector-github-stars
-```
-
----
-
-## What Happens at Runtime
-
-Here's the full lifecycle of a connector, from installation to search results:
-
-```
-1. DISCOVERY
- App starts → scans ~/.spool/connectors/node_modules/@spool-lab/connector-*
- → require() each → new ConnectorClass() → registry.register(connector)
-
-2. SCHEDULING
- SyncScheduler.start() → queues all enabled connectors (priority 80)
- → tick() every 30s checks which connectors are "due"
- → dequeues highest priority job → calls SyncEngine.sync()
-
-3. SYNC CYCLE (for persistent connectors)
- SyncEngine.sync(connector)
- → loadState() from connector_sync_state table
- → FORWARD: fetchPage({ cursor: headCursor ?? null, sinceItemId, phase: 'forward' })
- → stop on reached_since / stale / timeout / cancel / error
- → interrupted? headCursor saved for resume next cycle
- → completed but sinceItemId not hit? anchor invalidated, rebuilt next cycle
- → BACKFILL: fetchPage({ cursor: tailCursor, sinceItemId: null, phase: 'backfill' })
- → stop on end-of-history / budget
- → saveState()
-
-4. ITEM PROCESSING (per page)
- For each item in PageResult:
- → tag with metadata.connectorId
- → upsert by (platform, platformId) into captures table
- → FTS trigger auto-indexes title + contentText
-
-5. EVENTS
- sync-start → sync-progress (per page) → sync-complete
- → forwarded via IPC to renderer → UI updates in real time
-
-6. SEARCH
- User searches → FTS5 query on captures_fts → results include connector items
- → shown alongside Claude Code sessions in unified results
-```
-
-### Database Tables Your Data Touches
-
-| Table | What's Stored | Who Writes |
-|-------|--------------|------------|
-| `captures` | Your items (one row per CapturedItem) | SyncEngine |
-| `captures_fts` | Full-text index on title + contentText | SQLite trigger (automatic) |
-| `connector_sync_state` | Cursors, error counts, timestamps, enabled flag | SyncEngine |
-
-You never interact with these tables directly. The framework handles all reads and writes.
-
----
-
-## FAQ
-
-### Can I make network requests in `checkAuth()`?
-
-Avoid it. `checkAuth()` is called from the UI thread and should return in under 2 seconds. Check if credentials exist locally (cookies in Chrome DB, CLI auth status, config file). Don't validate them against the remote API — that's what `fetchPage()` is for.
-
-### What if my platform doesn't use cursor-based pagination?
-
-Use page numbers as cursor strings: return `nextCursor: String(page + 1)` and parse with `parseInt(cursor)`. See the GitHub Stars example above.
-
-### What if my platform returns items oldest-first?
-
-The sync engine expects newest-first for forward sync to work correctly (it stops when it hits known items). If your API returns oldest-first, you may need to reverse the response or use `ephemeral: true`.
-
-### How do I store per-connector settings (e.g., which Chrome profile to use)?
-
-The `configJson` field in `connector_sync_state` is available for this. Access it via the constructor options pattern used by Twitter Bookmarks:
-
-```typescript
-constructor(private opts?: { chromeProfileDirectory?: string }) {}
-```
-
-Settings UI is not yet standardized — for now, pass options at registration time.
-
-### What's the difference between a native connector and wrapping an external CLI?
-
-| | Native (e.g., Twitter Bookmarks) | CLI Wrapper (e.g., GitHub Stars via `gh`) |
-|--|---|---|
-| **Auth** | Reads Chrome cookies directly | Delegates to CLI's auth (`gh auth login`) |
-| **Data fetching** | Direct HTTP/GraphQL calls | Shells out to CLI, parses stdout |
-| **Dependencies** | None (Node.js built-ins only) | Requires external CLI installed |
-| **Performance** | Fast, no subprocess overhead | Subprocess per page |
-| **Pagination control** | Full control over cursors and page size | Limited to CLI's pagination options |
-| **Error handling** | Precise: can distinguish 429/401/5xx | Limited: parse stderr or exit codes |
-
-Both implement the same `Connector` interface. The framework doesn't care how `fetchPage()` gets its data. Choose native for high-volume connectors or platforms where you need fine-grained control. Choose CLI wrappers when a good CLI already exists and volume is low.
diff --git a/docs/connector-sync-architecture.md b/docs/connector-sync-architecture.md
deleted file mode 100644
index dec7bc3..0000000
--- a/docs/connector-sync-architecture.md
+++ /dev/null
@@ -1,924 +0,0 @@
-# Connector Architecture
-
-> Plugin-based data sync framework for Spool. A connector is an installable npm package that knows how to read items from one source — a remote API, a local database, a set of files — and hand them to Spool's sync engine as `CapturedItem`s.
-
----
-
-## Core Concepts
-
-### What is a Connector?
-
-A connector is a self-contained module that knows how to check whether its data source is available and fetch paginated items from it. It does NOT know about scheduling, sync state, or storage — those are handled by the framework.
-
-Examples:
-- Remote APIs: `@spool-lab/connector-twitter-bookmarks`, `@spool-lab/connector-github-stars`
-- Local databases: a connector that reads a macOS app's SQLite store
-- Local files: a connector that indexes a directory of notes
-
-A connector only has to implement two methods (`checkAuth` and `fetchPage`). Whether the data comes from HTTP, SQLite, or the filesystem is entirely the connector's concern — the framework treats them uniformly.
-
-### Data Ownership Model
-
-| Kind | Sync Strategy | Examples |
-|------|---------------|----------|
-| **User-owned** | Persistent dual-frontier sync (`ephemeral: false`) | Bookmarks, stars, saved posts, favorites, watch history |
-| **Ephemeral** | Full-replace cache (`ephemeral: true`) | Hot topics, trending, rankings, explore feeds |
-
-User-owned data uses incremental sync with two cursors (head + tail). Ephemeral data is deleted and re-fetched each cycle.
-
----
-
-## Connector Interface
-
-```typescript
-interface Connector {
- /** Unique identifier, e.g. 'twitter-bookmarks' */
- readonly id: string
-
- /** Platform name for grouping, e.g. 'twitter' */
- readonly platform: string
-
- /** Human-readable label, e.g. 'X Bookmarks' */
- readonly label: string
-
- /** Short description for the connector picker */
- readonly description: string
-
- /** UI color for badges/dots */
- readonly color: string
-
- /** Whether this is ephemeral (cache) or user-owned (persistent) */
- readonly ephemeral: boolean
-
- /** Check if authentication is available */
- checkAuth(opts?: Record): Promise
-
- /**
- * Fetch one page of data.
- * The sync engine calls this repeatedly with FetchContext to paginate.
- * The connector can use sinceItemId and phase to optimize fetching,
- * or ignore them and just use the cursor (cursor-walking).
- */
- fetchPage(ctx: FetchContext): Promise
-}
-
-interface FetchContext {
- cursor: string | null // Pagination cursor. null = start from newest.
- sinceItemId: string | null // Platform ID of newest known item (head anchor).
- // Forward passes this so the connector can
- // optimize (e.g. stop early). null during backfill
- // or when no anchor exists yet.
- phase: 'forward' | 'backfill' // Which sync phase is requesting this page.
- signal: AbortSignal // fires when the sync engine wants to stop
-}
-```
-
-A connector only needs to implement two methods: `checkAuth()` and `fetchPage()`. Everything else — persistence, scheduling, retries, UI — is handled by the framework. The `sinceItemId` and `phase` fields in `FetchContext` are informational — a connector can safely ignore them and just use `cursor`. The engine has its own early-exit logic that works regardless of whether the connector acts on these hints.
-
-Connectors should pass `signal` through to `caps.fetch(url, { signal })` and to `abortableSleep(ms, signal)` in retry backoff loops to ensure cancel propagates promptly.
-
-### Key Supporting Types
-
-```typescript
-interface AuthStatus {
- ok: boolean
- error?: string
- code?: SyncErrorCode // machine-readable error classification
- hint?: string // user-facing guidance, e.g. "Log into X in Chrome"
-}
-
-interface PageResult {
- items: CapturedItem[]
- nextCursor: string | null // null = no more data in this direction
-}
-```
-
-### CapturedItem — the Universal Data Unit
-
-Every piece of data flowing through the connector system is a `CapturedItem`. This is the canonical shape for all platform data stored in Spool.
-
-```typescript
-interface CapturedItem {
- /** Original URL on the platform */
- url: string
-
- /** Display title (truncated for tweets, repo name for GitHub, etc.) */
- title: string
-
- /** Full text content */
- contentText: string
-
- /** Author handle or name */
- author: string | null
-
- /** Platform identifier: 'twitter', 'github', 'reddit', etc. */
- platform: string
-
- /** Platform-specific unique ID for deduplication */
- platformId: string | null
-
- /** Content type: 'tweet', 'repo', 'video', 'post', 'page', etc. */
- contentType: string
-
- /** Preview image URL */
- thumbnailUrl: string | null
-
- /** Platform-specific structured data (JSON blob) */
- metadata: Record
-
- /** When the item was created/saved on the platform (ISO 8601) */
- capturedAt: string
-
- /** Raw API response for future re-parsing */
- rawJson: string | null
-}
-```
-
-**Key fields explained:**
-
-| Field | Purpose | Example |
-|-------|---------|---------|
-| `platform` + `platformId` | **Deduplication key**. The sync engine upserts by this pair. | `twitter` + `1234567890` |
-| `contentType` | Determines rendering in search results. | `tweet`, `repo`, `video` |
-| `metadata` | Extensible bag for platform-specific data not covered by the base schema. Connectors store engagement counts, media objects, author snapshots, etc. | `{ likeCount: 42, media: [...] }` |
-| `metadata.connectorId` | **Framework-set** (not connector-set). The sync engine tags every item with the connector ID that produced it, enabling per-connector filtering and cleanup. | `twitter-bookmarks` |
-| `capturedAt` | Used for timeline ordering. Should be the platform's timestamp (when the tweet was posted, when the repo was starred), not the sync time. | `2025-03-15T10:30:00Z` |
-| `rawJson` | Preserved so that schema changes don't require re-fetching. The parser can be re-run on stored raw data. | Full GraphQL response |
-
-### Error Classification
-
-Connectors throw `SyncError` with a typed `code` for the framework to make retry/backoff decisions:
-
-```typescript
-enum SyncErrorCode {
- // Auth errors — connector should surface these clearly
- AUTH_CHROME_NOT_FOUND // Chrome/cookies DB not found
- AUTH_NOT_LOGGED_IN // Required cookies missing
- AUTH_COOKIE_DECRYPT_FAILED // OS keychain decryption failed
- AUTH_KEYCHAIN_DENIED // User denied keychain access prompt
- AUTH_SESSION_EXPIRED // 401/403 from platform API
-
- // Network errors — framework handles retry
- RATE_LIMITED // 429
- SERVER_ERROR // 5xx
- NETWORK_OFFLINE // fetch failed, no connectivity
- NETWORK_TIMEOUT // request timed out
- PARSE_ERROR // response wasn't valid JSON/expected shape
- UNEXPECTED_STATUS // unexpected HTTP status
-
- // Engine errors — framework internal
- MAX_PAGES_REACHED // hit page budget
- SYNC_TIMEOUT // hit time budget
- SYNC_CANCELLED // AbortSignal fired
-
- // Storage errors
- DB_WRITE_ERROR // SQLite write failed
-
- // Catch-all
- CONNECTOR_ERROR // connector-specific unclassified error
-}
-```
-
-Errors with `needsReauth: true` (all `AUTH_*` codes) cause the scheduler to stop retrying until the user re-authenticates. Errors with `retryable: true` (network/server errors) trigger exponential backoff.
-
----
-
-## Sync Engine: Dual-Frontier Model
-
-The sync engine is platform-agnostic. It takes any `Connector` and manages the full sync lifecycle.
-
-### Concept
-
-```
-[history end] ◄── tail frontier ──── stored data ──── head frontier ──► [newest]
- (backfill ←) (→ forward)
-```
-
-Two independent frontiers:
-
-- **Head (forward):** Fetches new items added since last sync. Runs frequently (every 15 min). Stops when it encounters already-known items or runs out of pages.
-- **Tail (backfill):** Fills in historical data. Runs less frequently (every 60 min). Stops when it reaches the end of available history or exhausts its page budget.
-
-### Sync State (per connector, stored in DB)
-
-```typescript
-interface SyncState {
- connectorId: string
-
- // Head frontier
- headCursor: string | null // Forward resume cursor. Non-null only when
- // forward was interrupted (timeout/cancel/error).
- // Cleared on normal completion.
- headItemId: string | null // Platform ID of newest known item (since anchor).
- // Set from page 0 of a fresh forward (not a resumed one).
- // Used as FetchContext.sinceItemId and as the engine's
- // early-exit target. Cleared automatically if forward
- // completes without hitting it (anchor invalidation).
-
- // Tail frontier
- tailCursor: string | null // cursor to resume backfill
- tailComplete: boolean // true = reached end of history
-
- // Metadata
- lastForwardSyncAt: string | null
- lastBackfillSyncAt: string | null
- totalSynced: number
- consecutiveErrors: number
- enabled: boolean
- configJson: string // per-connector config (e.g. chrome profile)
- lastErrorCode: string | null
- lastErrorMessage: string | null
-}
-```
-
-### Stop Conditions
-
-Forward sync stops when ANY of:
-1. **Reached since-anchor**: A page contains the item matching `sinceItemId` (caught up precisely)
-2. **Stale pages**: 3 consecutive pages with 0 new items (fallback when no anchor exists)
-3. **No cursor**: API returned `nextCursor: null` (end of data)
-4. **Timeout**: Exceeded `maxMinutes` (forward interrupted, `headCursor` preserved for resume)
-5. **Cancelled**: `AbortSignal` fired (`headCursor` preserved)
-
-Conditions 1–3 are "normal completion" — `headCursor` is cleared. Conditions 4–5 are "interruption" — `headCursor` retains the current position so the next forward resumes where it stopped instead of re-fetching from the newest end.
-
-### Ephemeral vs. Persistent
-
-```typescript
-class SyncEngine {
- async sync(connector: Connector, opts?: SyncOptions): Promise {
- if (connector.ephemeral) {
- // Delete all existing items for this connector, fetch fresh
- return this.syncEphemeral(connector, opts)
- }
- // Dual-frontier: forward then backfill
- return this.syncPersistent(connector, opts)
- }
-}
-```
-
-### Checkpoint & Crash Safety
-
-The engine checkpoints state to DB every 25 pages. If the app crashes mid-sync:
-- Forward sync: resumes from last saved `headCursor`. Pages between the crash and the last checkpoint may be re-fetched, but dedup by `(platform, platformId)` prevents duplicates.
-- Backfill: resumes from last saved `tailCursor`.
-- No data loss, at most some redundant API calls.
-
----
-
-## Sync Scheduler
-
-The scheduler is the orchestration layer that decides WHEN to run syncs. It runs in the Electron main process.
-
-### Design Principles
-
-1. **Connectors don't know about scheduling.** A connector is a pure data fetcher.
-2. **Sync engine doesn't know about timing.** It runs one sync cycle when asked.
-3. **Scheduler owns the clock.** It decides what to sync, when, and in what order.
-
-### Schedule Configuration
-
-```typescript
-interface ScheduleConfig {
- forwardIntervalMs: number // Default: 15 min
- backfillIntervalMs: number // Default: 60 min
- concurrency: number // Default: 1
- pageDelayMs: number // Default: 1200ms
- retryBackoffMs: number[] // Default: [60s, 300s, 1800s, 7200s]
- maxMinutesPerRun: number // Default: 10 (scheduler); 0 = unlimited (CLI)
-}
-```
-
-### Priority Queue
-
-| Priority | Trigger | Description |
-|----------|---------|-------------|
-| 100 | Manual | User clicked "Sync now" |
-| 80 | Launch | First sync after app launch |
-| 60 | Wake | Sync after system wake from sleep |
-| 40 | Interval | Scheduled forward sync |
-| 20 | Backfill | Background history backfill |
-
-### Error Handling & Backoff
-
-```
-consecutiveErrors = 0 → next sync at normal interval
-consecutiveErrors = 1 → wait 60s
-consecutiveErrors = 2 → wait 5 min
-consecutiveErrors = 3 → wait 30 min
-consecutiveErrors ≥ 4 → wait 2 hr (cap)
-```
-
-Auth errors (`needsReauth`) stop scheduling entirely until the user re-authenticates.
-
-### Lifecycle Events
-
-| Event | Action |
-|-------|--------|
-| App launch | Queue forward sync for all enabled connectors (priority 80) |
-| System wake | Queue forward sync for all enabled connectors (priority 60) |
-| Interval tick | Check which connectors are due, queue at priority 40/20 |
-| Manual trigger | Queue specific connector at priority 100 |
-| Auth error | Mark `needsReauth`, stop scheduling this connector |
-| App quit | Abort running syncs, save state |
-
-### Event System
-
-The scheduler emits events for UI updates:
-
-```typescript
-type SchedulerEvent =
- | { type: 'sync-start'; connectorId: string }
- | { type: 'sync-progress'; progress: SyncProgress }
- | { type: 'sync-complete'; result: ConnectorSyncResult }
- | { type: 'sync-error'; connectorId: string; code: SyncErrorCode; message: string }
-```
-
----
-
-## Connector Plugin System
-
-Connectors are distributed as npm packages and installed to a local directory. The app discovers and loads them at startup. Packages can be authored by anyone — the Spool team ships first-party connectors under the `@spool-lab/*` npm scope, and community authors can publish under any name they choose.
-
-### Package Convention
-
-A connector package is identified by a `spool` manifest field in its `package.json`, **not** by its npm name. Any npm package can declare itself a connector by adding this field:
-
-```json
-{
- "name": "@spool-lab/connector-twitter-bookmarks",
- "version": "1.0.0",
- "main": "dist/index.js",
- "keywords": ["spool-connector"],
- "spool": {
- "type": "connector",
- "id": "twitter-bookmarks",
- "platform": "twitter",
- "label": "X Bookmarks",
- "description": "Your saved tweets on X",
- "color": "#1DA1F2",
- "ephemeral": false
- }
-}
-```
-
-- `spool.type` must be `"connector"` (reserved for future non-connector Spool plugin types)
-- `id` / `platform` / `label` / `description` / `color` / `ephemeral` must match the corresponding fields on the `Connector` interface implementation exported from the package
-- `@spool-lab/` is the scope reserved for first-party packages; any other scope (or unscoped name) is a community package
-- `keywords` must include `"spool-connector"` — this is how spool.pro discovers the package on npm (see "Discovery on spool.pro" below). The keyword is discovery metadata only; the authoritative identification at runtime is still the `spool.type` manifest field
-
-The manifest lets the app read connector metadata (for the directory page, install UI, etc.) without loading the module — and lets the app decide whether to trust the package before running any of its code.
-
-### Trust model
-
-Because connector code runs with file-system and network access, the app distinguishes two trust tiers:
-
-| Tier | Rule | Default behavior |
-|---|---|---|
-| **First-party** | npm scope is `@spool-lab/*` and the package is also listed in Spool's bundled official-connector allow-list | Loaded automatically on startup |
-| **Community** | Any other package that has `spool.type === 'connector'` | Requires explicit user approval at first load, then cached in `~/.spool/config.json` |
-
-On first load of a community connector, Spool shows a consent dialog listing the capabilities the package has declared (see "Capability model" below) and the npm name + version. The user's answer is persisted — subsequent launches load it without re-prompting. The user can revoke trust at any time from Settings, which removes the consent record and disables the connector.
-
-This model keeps `@spool-lab/*` fast-path while still allowing a real community ecosystem. It is **not** a sandbox — a connector the user has trusted can still read files and make network requests. The consent gate is a warning, not a prison.
-
-> **Spec status:** the trust model is specified at the level of the consent flow and allow-list. Capability enforcement is specified below but not yet implemented. Worker-thread isolation is an optional hardening step reserved for a later phase.
-
-### Capability model
-
-> **Spec status: placeholder.** The detailed capability API is under design and will ship with the plugin loader. This section describes the intended shape so third-party authors can plan accordingly.
-
-A connector does not `import 'node:fs'` or `import 'node:http'` directly. Instead, the SDK exposes a constrained set of capabilities that the framework injects into the connector at construction time:
-
-- `fetch(url, init)` — HTTP fetch routed through Spool's network layer (proxy-aware, respects offline/online state). Equivalent to `globalThis.fetch` in shape
-- `cookies` — scoped Chrome/browser cookie reader for connectors that need cookie-based auth (subject to user consent for the specific browser profile)
-- `log` — structured logger that attributes log lines to the connector
-
-> `storage` is reserved for a future SDK v1.1 extension; v1 connectors manage their own state via the engine's `SyncState`.
-
-Any capability a connector uses must be declared in the `spool.capabilities` array in `package.json`:
-
-```json
-{
- "spool": {
- "type": "connector",
- "capabilities": ["fetch", "cookies:chrome"]
- }
-}
-```
-
-The consent dialog shown to users on first load lists these capabilities in plain language ("This connector will make network requests and read your Chrome cookies"). A connector that tries to use an undeclared capability at runtime is terminated with a `CONNECTOR_ERROR` and surfaced to the user.
-
-The v1 capability set is `"fetch" | "cookies:chrome" | "log"`. These names, signatures, and consent strings are frozen as part of the SDK v1 release. Until then this section is a design target, not a contract.
-
-### Discovery on spool.pro
-
-spool.pro is **not an independent registry**. It is a discovery and curation front-end over npm — similar in shape to how the VS Code Marketplace front-ends npm packages, or how Homebrew Cask front-ends upstream releases. Every package shown on spool.pro must exist on the public npm registry; every install button ultimately runs `npm install ` locally in the user's app.
-
-**Discovery mechanism: npm `spool-connector` keyword.** Connector authors add `"spool-connector"` to the `keywords` array in their `package.json`. spool.pro's backend periodically queries the npm registry:
-
-```
-GET https://registry.npmjs.org/-/v1/search?text=keywords:spool-connector&size=250
-```
-
-For each candidate returned, spool.pro fetches the package's `package.json`, cross-validates that `spool.type === 'connector'`, and indexes the `spool.*` manifest fields (label, description, color, capabilities) plus npm metadata (version, author, download count, last-published date).
-
-The keyword is **discovery metadata only**. It is not load-bearing for runtime identification — the Spool app's loader identifies connectors by the `spool.type` manifest field, not by keyword. A package with the keyword but without `spool.type === 'connector'` is rejected by the loader even if it made it into spool.pro's index. A package with `spool.type === 'connector'` but without the keyword will work fine at runtime if a user manually installs it; it just won't be discoverable through spool.pro.
-
-**Trust tiers on spool.pro cards:**
-
-| Badge | Criteria | Install UX |
-|---|---|---|
-| **Official** | Package name is under `@spool-lab/*` scope | Auto-loaded by the Spool app without consent |
-| **Community** | Any other package with the `spool-connector` keyword and valid `spool.type` manifest | Requires user consent on first load |
-
-Every card shows:
-- Package name, version, author (from npm metadata)
-- Label, description, color (from `spool.*` manifest)
-- Declared capabilities, translated to plain language ("Makes network requests · Reads Chrome cookies")
-- Download count, last-published date
-- Trust badge
-- **"Install in Spool" button** → generates a `spool://connector/install/` deep link (see "Deep-link install flow" below)
-
-**What spool.pro's MVP does NOT do:**
-
-- No editorial curation beyond the `@spool-lab/*` scope auto-tag. Any community package with the keyword shows up immediately in the directory, ranked by download count.
-- No submission form. Authors publish to npm the normal way.
-- No automated testing or sandboxing of candidate packages.
-- No takedown mechanism beyond npm's own registry moderation. If a malicious package slips through, spool.pro relies on npm removing it (unpublishing is still a global action on the registry).
-
-**Future curation layer (not Stage E MVP, explicitly deferred):**
-
-- `featured.json` maintained in a public GitHub repo (e.g. `spool-lab/connector-directory`), listing hand-picked community connectors
-- "Featured" badge for packages in that file
-- "Verified" badge for packages that have passed some stability threshold (download count, months since first publish, no unresolved issues) — criteria to be defined
-- Re-ranking logic that pushes Featured and Verified above raw community packages
-
-The MVP ships with only Official vs Community. The curation layer is a second iteration once there are enough community packages to make curation worthwhile.
-
-### Installation & Discovery
-
-**Install location:** `~/.spool/connectors/`
-
-```
-~/.spool/connectors/
-├── node_modules/
-│ ├── @spool-lab/
-│ │ └── connector-twitter-bookmarks/ # shipped with the app, first-run extracted
-│ ├── some-community-scope/
-│ │ └── my-custom-connector/
-│ └── unscoped-connector-package/
-└── package.json # auto-managed, tracks installed connectors
-```
-
-**Install sources — all paths go through the same dynamic loader:**
-
-| Source | How | Backend |
-|---|---|---|
-| **First-run bundle** | The app ships `@spool-lab/connector-*` npm tarballs inside its resource directory. On first launch, if `~/.spool/connectors/` is empty, the app extracts them into place. | File copy |
-| **Deep link from spool.pro** | spool.pro's connector directory buttons open `spool://connector/install/`, which the app handles by running the install flow for that package | `npm install` |
-| **Manual paste** | Settings → Install Connector → user pastes an npm package name | `npm install` |
-| **Local development** | `spool connector install --from ./path/to/local/package` CLI flag for connector authors developing a new plugin | `npm install ` |
-
-There is **no separate "built-in" code path**. Every connector the app loads — including first-party ones the Spool team maintains — goes through the same `~/.spool/connectors/` directory and the same dynamic loader. This is a deliberate choice: it means the first-party code is the first and most-tested consumer of the SDK, any capability a first-party connector needs is also available to community authors, and the plugin loading path is exercised from every launch of the app (not just once after the first community install).
-
-**Install flow for user-initiated installs:**
-1. User clicks "Install" on spool.pro directory, or pastes an npm package name into the app's Settings → Install Connector field
-2. App resolves the source (deep link or direct input) and runs `npm install ` in `~/.spool/connectors/`
-3. App scans every installed package for a `spool` manifest field
-4. For community packages not yet trusted, the app prompts for consent (see "Trust model" above)
-5. Each trusted package's default export is instantiated and registered with `ConnectorRegistry`
-
-**Discovery at startup:**
-```typescript
-// Pseudocode — real loader lives in packages/core/src/connectors/loader.ts
-async function loadConnectors(registry: ConnectorRegistry, trust: TrustStore) {
- const connectorsDir = path.join(homedir(), '.spool', 'connectors')
-
- // First-run bootstrap: extract bundled first-party connectors if the
- // user's connectors directory is empty.
- await extractBundledConnectorsIfNeeded(connectorsDir)
-
- if (!existsSync(path.join(connectorsDir, 'package.json'))) return
-
- // Walk every installed package — not just those with a known name prefix.
- for (const pkgDir of walkNodeModules(path.join(connectorsDir, 'node_modules'))) {
- const pkgJson = readPackageJson(pkgDir)
- if (pkgJson?.spool?.type !== 'connector') continue
-
- if (!trust.isAllowed(pkgJson.name)) {
- // Community package not yet approved — surface in UI, skip loading.
- trust.recordPending(pkgJson)
- continue
- }
-
- try {
- const mod = await import(pkgDir)
- const ConnectorClass = mod.default ?? mod
- const connector = new ConnectorClass(/* capabilities injected here */)
- registry.register(connector)
- } catch (err) {
- // Crash isolation: a broken connector must not take down the loader.
- log.error(`failed to load ${pkgJson.name}: ${err}`)
- }
- }
-}
-```
-
-The loader treats every package as untrusted by default and only loads those in the trust store. First-party packages shipped with the app are added to the trust store automatically as part of the bundle-extraction step. Load failures are isolated so one bad connector cannot prevent the others from registering.
-
-**Uninstall:** `npm uninstall ` in `~/.spool/connectors/`, then remove connector's sync state and captures from DB. The next launch will re-extract first-party bundles if the user has removed them, unless they explicitly set a "do not restore" flag.
-
-### Deep-link install flow
-
-spool.pro's connector directory page has an "Install in Spool" button next to each listed package. Clicking it opens a `spool://connector/install/` URL. The Spool app registers itself as the handler for the `spool://` protocol on install.
-
-```
-https://spool.pro/connectors
- │
- │ user clicks "Install" on @spool-lab/connector-github-stars
- ▼
-spool://connector/install/@spool-lab/connector-github-stars
- │
- │ OS hands off to Spool (custom protocol handler)
- ▼
-App receives the deep link, shows a confirmation dialog:
- "Install @spool-lab/connector-github-stars from npm?"
- │
- │ user confirms
- ▼
-App runs `npm install @spool-lab/connector-github-stars` in ~/.spool/connectors/
- │
- ▼
-Loader picks it up, consent prompt if community, registers with ConnectorRegistry
-```
-
-Deep-link handling uses Electron's `app.setAsDefaultProtocolClient('spool')` in main, the `open-url` event on macOS, and command-line argument parsing on Windows/Linux. The `spool://` scheme is reserved for Spool's own use — any query parameters or additional paths are treated as opaque and validated server-side against the expected shape (`install/`, `open/`, etc.).
-
-**Security note:** deep-link triggers do **not** auto-install. Every install, regardless of source, shows the user a confirmation dialog with the package name and (for community packages) the declared capabilities. A malicious link cannot silently push code onto a user's machine.
-
----
-
-## DB Schema
-
-### `connector_sync_state` — per-connector sync progress
-
-```sql
-CREATE TABLE IF NOT EXISTS connector_sync_state (
- connector_id TEXT PRIMARY KEY,
- head_cursor TEXT,
- head_item_id TEXT,
- tail_cursor TEXT,
- tail_complete INTEGER NOT NULL DEFAULT 0,
- last_forward_sync_at TEXT,
- last_backfill_sync_at TEXT,
- total_synced INTEGER NOT NULL DEFAULT 0,
- consecutive_errors INTEGER NOT NULL DEFAULT 0,
- enabled INTEGER NOT NULL DEFAULT 1,
- config_json TEXT NOT NULL DEFAULT '{}',
- last_error_code TEXT,
- last_error_message TEXT
-);
-```
-
-### `captures` — all connector items
-
-```sql
-CREATE TABLE IF NOT EXISTS captures (
- id INTEGER PRIMARY KEY,
- source_id INTEGER NOT NULL REFERENCES sources(id),
- capture_uuid TEXT NOT NULL UNIQUE,
- url TEXT NOT NULL,
- title TEXT NOT NULL DEFAULT '',
- content_text TEXT NOT NULL DEFAULT '',
- author TEXT,
- platform TEXT NOT NULL,
- platform_id TEXT,
- content_type TEXT NOT NULL DEFAULT 'page',
- thumbnail_url TEXT,
- metadata TEXT NOT NULL DEFAULT '{}',
- captured_at TEXT NOT NULL,
- indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
- raw_json TEXT
-);
-
--- Deduplication: platform + platform_id
--- FTS: captures_fts virtual table on (title, content_text)
-```
-
-Note: The legacy `opencli_sources` and `opencli_setup` tables are removed. All connector state lives in `connector_sync_state`. The `captures.opencli_src_id` column is dropped — connector association is via `json_extract(metadata, '$.connectorId')`.
-
----
-
-## Integration Points
-
-### How a Connector Fits into the Framework
-
-```
-┌──────────────────────────────────────────────────────────┐
-│ spool.pro │
-│ Connector Directory Page │
-│ (curated listing of first-party + community) │
-└───────────────────────┬──────────────────────────────────┘
- │ npm install
- ▼
-┌──────────────────────────────────────────────────────────┐
-│ ~/.spool/connectors/ │
-│ node_modules/**/package.json with `spool.type` │
-└───────────────────────┬──────────────────────────────────┘
- │ trust check → dynamic import()
- ▼
-┌──────────────────────────────────────────────────────────┐
-│ ConnectorRegistry │
-│ register() / list() / get() / has() │
-└───────────────────────┬──────────────────────────────────┘
- │
- ┌─────────┴─────────┐
- ▼ ▼
-┌─────────────────────┐ ┌──────────────────────┐
-│ SyncEngine │ │ SyncScheduler │
-│ (runs sync cycles) │ │ (decides WHEN) │
-│ dual-frontier │ │ priority queue │
-│ upsert to DB │ │ error backoff │
-└─────────┬───────────┘ └──────────┬───────────┘
- │ │
- ▼ ▼
-┌──────────────────────────────────────────────────────────┐
-│ SQLite Database │
-│ captures + captures_fts + connector_sync_state │
-└──────────────────────────────────────────────────────────┘
- │
- ▼
-┌──────────────────────────────────────────────────────────┐
-│ Electron IPC / CLI │
-│ connector:list / connector:sync-now / etc. │
-└───────────────────────┬──────────────────────────────────┘
- │
- ▼
-┌──────────────────────────────────────────────────────────┐
-│ Renderer UI │
-│ SourcesPanel / SettingsPanel / StatusBar │
-└──────────────────────────────────────────────────────────┘
-```
-
-### Electron IPC
-
-| Channel | Input | Output |
-|---------|-------|--------|
-| `connector:list` | — | `ConnectorStatus[]` |
-| `connector:check-auth` | `{ id }` | `AuthStatus` |
-| `connector:sync-now` | `{ id }` | `{ ok: boolean }` |
-| `connector:get-status` | — | `SchedulerStatus` |
-| `connector:set-enabled` | `{ id, enabled }` | `{ ok: boolean }` |
-| `connector:get-capture-count` | `{ connectorId }` | `number` |
-| `connector:install` | `{ packageName }` | `{ ok: boolean }` |
-| `connector:uninstall` | `{ packageName }` | `{ ok: boolean }` |
-
-Event channel: `connector:event` broadcasts `SchedulerEvent` to renderer.
-
-### Preload API
-
-```typescript
-window.spool.connectors = {
- list(): Promise
- checkAuth(id: string): Promise
- syncNow(id: string): Promise<{ ok: boolean }>
- setEnabled(id: string, enabled: boolean): Promise<{ ok: boolean }>
- getStatus(): Promise
- getCaptureCount(connectorId: string): Promise
- install(packageName: string): Promise<{ ok: boolean }>
- uninstall(packageName: string): Promise<{ ok: boolean }>
- onEvent(callback: (event: SchedulerEvent) => void): () => void
-}
-```
-
-### CLI
-
-```bash
-spool connector list # list installed connectors + status
-spool connector sync [connector-id] # sync one or all connectors
-spool connector sync --reset [connector-id] # wipe state and re-sync from scratch
-spool connector install # install a connector from npm
-spool connector uninstall # remove a connector
-```
-
----
-
-## File Structure
-
-```
-packages/core/src/connectors/ # Framework — NOT any individual connector
-├── types.ts # Connector, AuthStatus, PageResult, SyncState, errors
-├── registry.ts # ConnectorRegistry
-├── sync-engine.ts # SyncEngine (dual-frontier logic)
-├── sync-scheduler.ts # SyncScheduler (timing, orchestration)
-└── loader.ts # Plugin discovery & dynamic loading
-
-packages/connectors/ # First-party plugin workspace container
-├── twitter-bookmarks/ # → @spool-lab/connector-twitter-bookmarks on npm
-│ ├── package.json # with `spool.type: 'connector'` + keywords
-│ ├── src/
-│ │ ├── index.ts # TwitterBookmarksConnector (default export)
-│ │ ├── chrome-cookies.ts # uses injected cookies:chrome capability
-│ │ └── graphql-fetch.ts # uses injected fetch capability
-│ └── dist/ # built output, packed as tarball for first-run
-├── typeless/ # → @spool-lab/connector-typeless on npm
-│ ├── package.json
-│ └── src/
-│ ├── index.ts # TypelessConnector (default export)
-│ └── db-reader.ts # reads ~/Library/.../typeless.db
-└── hackernews/ # → @spool-lab/connector-hackernews on npm
- └── ... # future; follows the same shape
-
-~/.spool/connectors/ # User-visible connector install directory
-├── package.json
-└── node_modules/
- ├── @spool-lab/connector-*/ # First-party (bundled with app, auto-trusted)
- └── / # Community (trusted after user consent)
-```
-
-The framework code lives in `packages/core/src/connectors/`. **No connector implementation lives there** — every first-party connector (Twitter Bookmarks, Typeless, Hacker News, …) has its own workspace package under `packages/connectors/`, is built into an npm tarball, and is loaded through the same dynamic-import path as community connectors. This keeps the SDK honest: if the framework ever needs a feature to support one of these, that feature has to be exposed on the SDK surface, not hidden in the core package.
-
-**Workspace layout.** `pnpm-workspace.yaml` declares both levels so pnpm treats every directory under `packages/connectors/` as an independent workspace package:
-
-```yaml
-packages:
- - 'packages/*'
- - 'packages/connectors/*'
-```
-
-**Naming convention for first-party plugins:**
-- Directory: `packages/connectors//`
-- npm package: `@spool-lab/connector-`
-- `spool.id`: `` (may include a sub-scope like `twitter-bookmarks`)
-- `spool.platform`: the underlying platform, not the connector (e.g. `twitter` for `twitter-bookmarks`)
-
-Community plugins do **not** live in this monorepo. They live in their own repositories and publish to npm independently using any package name their authors choose. The `packages/connectors/` directory is reserved for first-party plugins that the Spool team maintains and ships with the app as first-run bundles.
-
----
-
-## Writing a Connector
-
-A minimal connector implementation:
-
-```typescript
-import type { Connector, AuthStatus, PageResult, FetchContext } from '@spool/core'
-
-export default class MyConnector implements Connector {
- readonly id = 'my-platform-bookmarks'
- readonly platform = 'my-platform'
- readonly label = 'My Platform Bookmarks'
- readonly description = 'Your saved items on My Platform'
- readonly color = '#FF6600'
- readonly ephemeral = false
-
- async checkAuth(): Promise {
- // Check if credentials/cookies are available
- return { ok: true }
- }
-
- async fetchPage({ cursor }: FetchContext): Promise {
- // Fetch one page of data from the platform API.
- // sinceItemId and phase are available in FetchContext if your platform
- // supports server-side "since" filtering — most connectors can ignore them
- // and just use cursor. The engine handles early-exit on its own.
- const response = await fetchFromAPI(cursor)
- return {
- items: response.items.map(item => ({
- url: item.url,
- title: item.title,
- contentText: item.body,
- author: item.author,
- platform: this.platform,
- platformId: item.id,
- contentType: 'post',
- thumbnailUrl: null,
- metadata: { /* platform-specific data */ },
- capturedAt: item.createdAt,
- rawJson: JSON.stringify(item),
- })),
- nextCursor: response.nextPage ?? null,
- }
- }
-}
-```
-
-Package it as `@your-scope/connector-my-platform-bookmarks` (or any npm name) with the `spool` manifest in `package.json`, publish to npm, and users can install it from the app's Settings → Install Connector field or from the spool.pro directory. The minimum `package.json` shape is:
-
-```json
-{
- "name": "@your-scope/connector-my-platform-bookmarks",
- "version": "0.1.0",
- "main": "dist/index.js",
- "keywords": ["spool-connector"],
- "peerDependencies": {
- "@spool-lab/connector-sdk": "^1.0.0"
- },
- "spool": {
- "type": "connector",
- "id": "my-platform-bookmarks",
- "platform": "my-platform",
- "label": "My Platform Bookmarks",
- "description": "Your saved items on My Platform",
- "color": "#FF6600",
- "ephemeral": false,
- "capabilities": ["fetch", "log"]
- }
-}
-```
-
-- `keywords: ["spool-connector"]` is how spool.pro's backend discovers your package on npm
-- `spool.type: "connector"` is how the Spool app's loader identifies your package at runtime
-- `spool.capabilities` declares which SDK-injected capabilities your connector needs — this list is shown to users in the first-load consent dialog
-
-### Useful SDK exports
-
-**`abortableSleep(ms, signal)`** — use this inside any retry/backoff loop in `fetchPage`. Unlike plain `setTimeout`, it rejects with the signal's reason when the engine cancels, so `scheduler.stop()` takes effect within one event-loop tick.
-
-### Local source connectors
-
-Not every connector fetches data over the network. A connector that reads a local SQLite database, a directory of markdown files, or another app's export file implements exactly the same `Connector` interface — the framework does not distinguish "remote" from "local" sources.
-
-The technique for making a local source look like a paginated stream is to **synthesize a cursor from a natural ordering** in the data. For a table with a `created_at` column:
-
-```typescript
-async fetchPage({ cursor }: FetchContext): Promise {
- // cursor is the created_at of the last row on the previous page, or null
- // for the first page. Query for 25 rows strictly older than it.
- const db = openMyLocalDb()
- try {
- const rows = queryRows(db, { before: cursor, limit: 25 })
- const items = rows.map(rowToCapturedItem)
- const nextCursor = rows.length === 25
- ? rows[rows.length - 1].created_at
- : null
- return { items, nextCursor }
- } finally {
- db.close()
- }
-}
-```
-
-`checkAuth()` for a local source is typically "is the file readable?":
-
-```typescript
-async checkAuth(): Promise {
- try {
- const db = openMyLocalDb()
- db.close()
- return { ok: true }
- } catch (err) {
- return {
- ok: false,
- error: SyncErrorCode.CONNECTOR_ERROR,
- message: err instanceof Error ? err.message : String(err),
- hint: 'MyApp not found. Install MyApp, create at least one entry, then retry.',
- }
- }
-}
-```
-
-Notes for local connectors:
-
-- The dual-frontier model (forward + backfill) still applies: forward finds items added since the last sync, backfill walks history. With a stable local ordering, "forward" converges after the first cycle and subsequent syncs just pick up deltas.
-- Page delay (`pageDelayMs`) defaults are tuned for remote API rate limits. A local connector can pass `pageDelayMs: 0` via its constructor config if the default 1200ms is wasteful.
-- Error codes like `API_RATE_LIMITED` or `NETWORK_OFFLINE` don't apply. Use `CONNECTOR_ERROR` with a descriptive `hint` for local-specific failures (file missing, database locked, parse failure).
-- `checkAuth()`'s name is legacy — semantically it means "is the source usable right now?" The framework treats any non-`ok` answer the same way.
-
-### Future consideration: source-type taxonomy
-
-The current `Connector` interface is shaped around "paginated pull-based reads from a temporally-ordered source." That model covers:
-
-- Remote cursor-walking APIs (Twitter, GraphQL)
-- Remote `since`-parameterized APIs (GitHub, REST)
-- Local databases with a natural `ORDER BY created_at DESC` ordering
-- Local file directories where mtime serves as the ordering
-
-It does **not** naturally fit:
-
-- Push-based ingestion (filesystem watchers, IPC events from another process)
-- Non-temporal data (configs, static reference material)
-- Sources where the entire state must be re-read each time because no cursor exists (small local files, key-value stores)
-
-Spool currently handles push-based local-file ingestion (Claude Code sessions, Codex history) in a separate subsystem (`packages/core/src/sync/` — the `SpoolWatcher` + `Syncer`), not through the `Connector` framework. This split is intentional: forcing every integration into the paginated model would have produced awkward adapters for sources that don't have a natural pagination story.
-
-If in the future enough local or push-based connectors exist to warrant a unified abstraction, the framework may introduce a **source-type taxonomy** — something like `connector.kind: 'paginated' | 'snapshot' | 'watcher'` — with distinct interface shapes for each kind. This is deliberately **not** done yet because:
-
-1. The current interface has only two local samples (Typeless is a candidate community connector; Claude Code / Codex live outside the framework in `sync/`). Two samples are not enough to generalize a taxonomy correctly.
-2. A premature kind-based split would likely need to be revised once more local samples exist, which would be a breaking public-API change at exactly the wrong time (after community authors have started shipping against v1).
-3. The current interface **already works** for local sources via cursor synthesis — the awkwardness is in naming (`checkAuth` for a file-existence check) and default values (`pageDelayMs` for zero-latency reads), neither of which is a blocker.
-
-The shape of the eventual taxonomy will be decided when there is enough evidence to design it, not before. Until then, local-source authors should use the patterns shown above and accept the HTTP-shaped vocabulary of the current interface.
-
----
-
-## Removed Systems
-
-The following legacy systems have been fully removed in favor of the connector framework:
-
-- **OpenCLI integration** (`packages/core/src/opencli/`): Manager, parser, strategies, onboarding flow. OpenCLI was an external CLI tool that wrapped browser automation for 50+ platforms. Each platform that needs support is now implemented as a standalone connector.
-- **Capture URL** (`CaptureUrlModal.tsx`, Cmd+K): One-off URL fetching via `opencli web read`. Not part of the connector model.
-- **`opencli_sources` table**: Replaced by `connector_sync_state`.
-- **`opencli_setup` table**: No longer needed (no global CLI installation step).
-- **`opencli:*` IPC channels**: Replaced by `connector:*`.
-- **OnboardingFlow**: Each connector handles its own auth; no shared setup wizard.
diff --git a/docs/spool-positioning.md b/docs/spool-positioning.md
index e068ebb..151eab0 100644
--- a/docs/spool-positioning.md
+++ b/docs/spool-positioning.md
@@ -1,8 +1,8 @@
# Spool
-> **The missing search engine for your own data.**
+> **The missing search engine for your own AI sessions.**
-Search your `[Claude Code sessions · Codex history · Gemini chats · ChatGPT history · GitHub stars · Twitter bookmarks · YouTube likes]` — locally.
+Search your `[Claude Code sessions · Codex history · Gemini chats]` — locally.
---
@@ -10,16 +10,16 @@ Search your `[Claude Code sessions · Codex history · Gemini chats · ChatGPT h
### Your coding agent is already the best search engine you have.
-Spool lets Claude Code, Codex, Gemini CLI, and any coding agent search your personal data — past sessions, bookmarks, stars, saves — from a single search box.
-
-### Your bookmarks and stars, synced locally.
-
-Installable connector plugins sync your bookmarks, stars, and saves from Twitter/X, GitHub, and more — no API keys, no tokens. Spool indexes them all locally.
+Spool lets Claude Code, Codex, Gemini CLI, and any coding agent search your past sessions from a single search box.
### Every agent session, indexed automatically.
-Spool watches `~/.claude/`, `~/.codex/`, and Gemini CLI’s `~/.gemini/tmp/*/chats` in real time. Every conversation you have with Claude Code, Codex, or Gemini CLI — searchable the moment it's written.
+Spool watches `~/.claude/`, `~/.codex/`, and Gemini CLI's `~/.gemini/tmp/*/chats` in real time. Every conversation you have with Claude Code, Codex, or Gemini CLI — searchable the moment it's written.
### Context that flows back in.
-A `/spool` skill inside Claude Code. A `spool` CLI in your terminal. Ask your agent to "build on last month's auth discussion" and it actually can — Spool feeds matching fragments from your past sessions and personal data directly into the conversation.
+A `/spool` skill inside Claude Code. A `spool` CLI in your terminal. Ask your agent to "build on last month's auth discussion" and it actually can — Spool feeds matching fragments from your past sessions directly into the conversation.
+
+---
+
+Looking for connectors that sync platform data (Twitter, GitHub, Reddit, etc.)? Those have moved to **[Spool Daemon](https://spool.pro/daemon)**, a sibling app focused on capture sync.
diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts
index 45c68f7..088f6e9 100644
--- a/packages/app/src/main/index.ts
+++ b/packages/app/src/main/index.ts
@@ -1,8 +1,8 @@
-import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, nativeImage } from 'electron'
+import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, nativeImage, shell } from 'electron'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import {
- getDB, Syncer, SpoolWatcher,
+ getDB, wasNewDb, getInitialUserVersion, Syncer, SpoolWatcher,
searchFragments, searchSessionPreview, listRecentSessions, getSessionWithMessages, getStatus,
starItem, unstarItem, listStarredItems, getStarredUuidsByType,
} from '@spool-lab/core'
@@ -13,7 +13,7 @@ import { setupAutoUpdater, downloadUpdate, quitAndInstall } from './updater.js'
import { openTerminal } from './terminal.js'
import { getSessionResumeCommand } from '../shared/resumeCommand.js'
import { resolveResumeWorkingDirectory } from './sessionResume.js'
-import { loadUIPreferences, saveThemeEditor, saveThemeSource } from './uiPreferences.js'
+import { loadUIPreferences, saveThemeEditor, saveThemeSource, saveSpoolDaemonNoticeShown } from './uiPreferences.js'
import type Database from 'better-sqlite3'
import type { SyncWorkerMessage } from './sync-worker.js'
@@ -417,6 +417,27 @@ ipcMain.handle('spool:ai-cancel', () => {
return { ok: true }
})
+// ── Spool Daemon notice ──────────────────────────────────────────────────
+
+ipcMain.handle('spool:get-daemon-notice-pending', (): boolean => {
+ // Only nudge users who actually upgraded from a pre-M5 schema. Fresh
+ // installs land directly at user_version=5 with no DB beforehand —
+ // nothing to apologize for, no notice needed.
+ if (uiPreferences.spoolDaemonNoticeShown) return false
+ if (wasNewDb()) return false
+ const initialVersion = getInitialUserVersion()
+ return initialVersion !== null && initialVersion < 5
+})
+
+ipcMain.handle('spool:daemon-notice-action', (_e, { action }: { action: 'install' | 'dismiss' }) => {
+ uiPreferences.spoolDaemonNoticeShown = true
+ saveSpoolDaemonNoticeShown()
+ if (action === 'install') {
+ void shell.openExternal('https://spool.pro/daemon')
+ }
+ return { ok: true }
+})
+
// ── Auto-update ──────────────────────────────────────────────────────────
ipcMain.handle('spool:download-update', () => {
diff --git a/packages/app/src/main/uiPreferences.ts b/packages/app/src/main/uiPreferences.ts
index b34bb55..8f91aa7 100644
--- a/packages/app/src/main/uiPreferences.ts
+++ b/packages/app/src/main/uiPreferences.ts
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
-import { homedir } from 'node:os'
+import { SPOOL_DIR } from '@spool-lab/core'
import {
normalizeThemeEditorState,
type ThemeEditorStateV1,
@@ -10,13 +10,15 @@ import {
interface UIConfigFile {
themeSource?: unknown
themeEditor?: unknown
+ spoolDaemonNoticeShown?: unknown
}
-const UI_CONFIG_PATH = join(homedir(), '.spool', 'ui.json')
+const UI_CONFIG_PATH = join(SPOOL_DIR, 'ui.json')
export interface UIPreferences {
themeSource: ThemeSource
themeEditor: ThemeEditorStateV1 | null
+ spoolDaemonNoticeShown: boolean
}
function normalizeThemeSource(raw: unknown): ThemeSource {
@@ -33,7 +35,7 @@ function readUIConfig(): UIConfigFile {
}
function writeUIConfig(config: UIConfigFile): void {
- mkdirSync(join(homedir(), '.spool'), { recursive: true })
+ mkdirSync(SPOOL_DIR, { recursive: true })
writeFileSync(UI_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8')
}
@@ -42,9 +44,15 @@ export function loadUIPreferences(): UIPreferences {
return {
themeSource: normalizeThemeSource(config.themeSource),
themeEditor: normalizeThemeEditorState(config.themeEditor),
+ spoolDaemonNoticeShown: config.spoolDaemonNoticeShown === true,
}
}
+export function saveSpoolDaemonNoticeShown(): void {
+ const config = readUIConfig()
+ writeUIConfig({ ...config, spoolDaemonNoticeShown: true })
+}
+
export function saveThemeSource(themeSource: ThemeSource): void {
const config = readUIConfig()
writeUIConfig({ ...config, themeSource })
diff --git a/packages/app/src/preload/index.ts b/packages/app/src/preload/index.ts
index d141620..e42e08c 100644
--- a/packages/app/src/preload/index.ts
+++ b/packages/app/src/preload/index.ts
@@ -141,6 +141,13 @@ const api = {
setThemeEditorState: (state: ThemeEditorStateV1): Promise<{ ok: boolean }> =>
ipcRenderer.invoke('spool:set-theme-editor-state', { state }),
+ // Spool Daemon notice
+ getDaemonNoticePending: (): Promise =>
+ ipcRenderer.invoke('spool:get-daemon-notice-pending'),
+
+ daemonNoticeAction: (action: 'install' | 'dismiss'): Promise<{ ok: boolean }> =>
+ ipcRenderer.invoke('spool:daemon-notice-action', { action }),
+
// Auto-update
onUpdateStatus: (cb: (data: { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number }) => void) => {
const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number })
diff --git a/packages/app/src/renderer/App.tsx b/packages/app/src/renderer/App.tsx
index 10cde7f..e763865 100644
--- a/packages/app/src/renderer/App.tsx
+++ b/packages/app/src/renderer/App.tsx
@@ -9,6 +9,7 @@ import StarredEntryButton from './components/StarredEntryButton.js'
import StatusBar from './components/StatusBar.js'
import AiAnswerCard from './components/AiAnswerCard.js'
import SettingsPanel from './components/SettingsPanel.js'
+import DaemonNoticeModal from './components/DaemonNoticeModal.js'
import { getSessionResumeCommandPrefix } from '../shared/resumeCommand.js'
import { DEFAULT_SEARCH_SORT_ORDER, type SearchSortOrder } from '../shared/searchSort.js'
import { defaultThemeEditorState, type ThemeEditorStateV1 } from './theme/editorTypes.js'
@@ -65,6 +66,7 @@ export default function App() {
// Settings & modals
const [showSettings, setShowSettings] = useState(false)
+ const [showDaemonNotice, setShowDaemonNotice] = useState(false)
const [settingsTab, setSettingsTab] = useState('general')
const [defaultSearchSort, setDefaultSearchSort] = useState(DEFAULT_SEARCH_SORT_ORDER)
const [resumeToastCommand, setResumeToastCommand] = useState(null)
@@ -178,6 +180,13 @@ export default function App() {
})
}, [])
+ useEffect(() => {
+ if (!window.spool?.getDaemonNoticePending) return
+ window.spool.getDaemonNoticePending()
+ .then(pending => { if (pending) setShowDaemonNotice(true) })
+ .catch(console.error)
+ }, [])
+
useEffect(() => {
applyEditorTheme(themeEditor)
}, [themeEditor])
@@ -587,6 +596,10 @@ export default function App() {
onThemeEditorChange={setThemeEditor}
/>
)}
+
+ {showDaemonNotice && (
+ setShowDaemonNotice(false)} />
+ )}
)
}
diff --git a/packages/app/src/renderer/assets/daemon-icon.png b/packages/app/src/renderer/assets/daemon-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..a87f33f235de5f7014836a16a7c6ad5b92175c7e
GIT binary patch
literal 55487
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