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. Spool

-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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 DY#TO7 literal 0 HcmV?d00001 diff --git a/packages/app/src/renderer/components/DaemonNoticeModal.tsx b/packages/app/src/renderer/components/DaemonNoticeModal.tsx new file mode 100644 index 0000000..c3a7a84 --- /dev/null +++ b/packages/app/src/renderer/components/DaemonNoticeModal.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import daemonIconUrl from '../assets/daemon-icon.png' + +interface Props { + onClose: () => void +} + +export default function DaemonNoticeModal({ onClose }: Props) { + const [busy, setBusy] = useState<'install' | 'dismiss' | null>(null) + + const handleAction = async (action: 'install' | 'dismiss') => { + if (busy) return + setBusy(action) + try { + await window.spool.daemonNoticeAction(action) + } finally { + onClose() + } + } + + return ( +
+
+
+
+ +
+

+ Connectors moved to Spool Daemon +

+

+ Spool now focuses on AI sessions. Twitter, GitHub, Reddit, Hacker News and other + platform connectors live in{' '} + + Spool Daemon + + , a sibling app. Synced platform data has been removed from Spool — install Daemon + to keep using connectors. +

+
+ +
+ + +
+
+
+ ) +} + diff --git a/packages/core/README.md b/packages/core/README.md index 95bcee0..1f99dff 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,8 +1,8 @@ # @spool-lab/core -The engine behind [Spool](https://spool.pro) — a local search engine for your AI sessions and connected sources. +The engine behind [Spool](https://spool.pro) — a local search engine for your AI sessions. -This package provides the core runtime: session parsing, full-text search, the connector sync engine, and the SQLite database layer. It powers both the Spool desktop app and the `@spool-lab/cli`. +This package provides the core runtime: session parsing, full-text search, and the SQLite database layer. It powers both the Spool desktop app and the `@spool-lab/cli`. ## Usage @@ -26,9 +26,8 @@ syncer.syncAll() - **Session parsers** — reads Claude Code, Codex, and Gemini CLI session files - **Full-text search** — FTS5 with unicode + trigram indexes for CJK support -- **Sync engine** — paginated connector sync with cursor-based state, backfill, and error recovery -- **Connector loader** — discovers and loads connector plugins from `~/.spool/connectors/` -- **Connector registry** — in-memory registry of available connectors +- **Watcher** — incremental indexing as new session files arrive +- **Stars** — pin sessions for quick recall ## Native dependency diff --git a/packages/core/src/db/db.ts b/packages/core/src/db/db.ts index b12ef45..c9dd87d 100644 --- a/packages/core/src/db/db.ts +++ b/packages/core/src/db/db.ts @@ -1,25 +1,39 @@ import Database from 'better-sqlite3' import { homedir } from 'node:os' import { join } from 'node:path' -import { mkdirSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, statSync } from 'node:fs' export const SPOOL_DIR = process.env['SPOOL_DATA_DIR'] ?? join(homedir(), '.spool') export const DB_PATH = join(SPOOL_DIR, 'spool.db') let _db: Database.Database | null = null +let _wasNewDb = false +let _initialUserVersion: number | null = null export function getDB(_readonly = false): Database.Database { if (_db) return _db mkdirSync(SPOOL_DIR, { recursive: true }) + // Capture pre-open state before better-sqlite3 creates the file. These two + // signals together let callers tell apart "fresh install" from "upgrade": + // - wasNewDb=true → DB file did not exist; this is a first-time install + // - wasNewDb=false → upgrade path, and initialUserVersion tells you from where + _wasNewDb = !existsSync(DB_PATH) const db = new Database(DB_PATH) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.pragma('busy_timeout = 5000') + _initialUserVersion = (db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version ?? 0 runMigrations(db) _db = db return db } +/** True if the DB file did not exist before this process opened it. */ +export function wasNewDb(): boolean { return _wasNewDb } + +/** user_version of the DB before any migrations ran this process. Null if getDB() hasn't been called. */ +export function getInitialUserVersion(): number | null { return _initialUserVersion } + export function getDBSize(): number { try { return statSync(DB_PATH).size diff --git a/packages/core/src/db/migration-v5.test.ts b/packages/core/src/db/migration-v5.test.ts index e599a08..ea098e0 100644 --- a/packages/core/src/db/migration-v5.test.ts +++ b/packages/core/src/db/migration-v5.test.ts @@ -144,6 +144,10 @@ describe('migration v5 (connector subsystem removal)', () => { const dbModule = await import('./db.js') const db = dbModule.getDB() + // Upgrade-path detection: DB pre-existed and was on v4 + expect(dbModule.wasNewDb()).toBe(false) + expect(dbModule.getInitialUserVersion()).toBe(4) + // user_version bumped to 5 expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5) @@ -181,6 +185,10 @@ describe('migration v5 (connector subsystem removal)', () => { const dbModule = await import('./db.js') const db = dbModule.getDB() + // Upgrade-path detection: DB file did not exist before this run + expect(dbModule.wasNewDb()).toBe(true) + expect(dbModule.getInitialUserVersion()).toBe(0) + expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5) // Stars exists with narrow CHECK