Skip to content

Connecting Chrome history

This connection reads the local History SQLite file that Chrome, Edge, and Brave all use (they share the same schema), clusters the last 30 days of visits into interests, and stores the result as memories. It never leaves the machine except as an LLM prompt containing domain names and page titles — no full URLs are ever sent to a model or stored in a memory.

Finding the file

chromeHistoryCandidates() (apps/api/src/twin/browser.ts) builds a list of the standard profile locations per OS and browser and locateChromeHistory() returns the first one that exists:

OSBase paths checked
Windows%LOCALAPPDATA%\Google\Chrome\User Data, ...\Microsoft\Edge\User Data, ...\BraveSoftware\Brave-Browser\User Data
macOS~/Library/Application Support/Google/Chrome, .../Microsoft Edge, .../BraveSoftware/Brave-Browser
Linux~/.config/google-chrome, ~/.config/chromium, ~/.config/microsoft-edge, ~/.config/BraveSoftware/Brave-Browser

Each base is checked against Default, Profile 1, Profile 2, Profile 3. GET /api/twin/status reports providers[].browser.configured based on whether any candidate resolves, with the hint “Chrome, Edge or Brave history was not found on this machine” when none do.

POST /api/connections { "provider": "browser" }

or with an explicit path: { "provider": "browser", "config": { "path": "C:/.../History" } }.

Reading a locked file

Chrome holds an exclusive lock on History while running, so the connector copies it to a temp file first (fs.copyFileSync) and opens the copy read-only with better-sqlite3, then deletes the copy (and its -journal sidecar) when done. It reads the last 30 days:

SELECT url, title, visit_count, last_visit_time FROM urls
WHERE last_visit_time > ? AND hidden = 0
ORDER BY last_visit_time DESC LIMIT 3000

(Chrome timestamps are microseconds since 1601-01-01; chromeToMs/msToChrome convert both ways.)

From URLs to interests, not a log

The raw visits are aggregated by domain (domainOf, stripping www.), with a small noise list filtered out (localhost, accounts.google.com, etc.) and capped at the top 60 domains by visit count. This domain-level digest — never individual URLs — is what goes to the model:

github.com (142 visits): "vercel/next.js" | "PR #4821" | "..."
news.ycombinator.com (58 visits): "..."

A cheap-tier structured call (clusterInterests()) turns that into 4–10 third-person interest memories, explicitly instructed to never mention specific private URLs:

{
"interests": [
{ "topic": "Rust", "summary": "Owner is actively learning Rust.", "kind": "insight", "importance": 0.6, "tags": ["rust"] },
{ "topic": "coffee gear", "summary": "Owner reads a lot about pour-over coffee equipment.", "kind": "preference", "importance": 0.4, "tags": ["coffee"] }
],
"profile": { "facts": { "code editor": "VS Code" }, "preferences": { "reads": "Hacker News, r/programming" } }
}

Without an OpenRouter key, heuristicInterests() takes over: the top 8 domains each become a plain insight memory — “Owner visits github.com often (142 visits in the last 30 days; e.g. “vercel/next.js”)” — with importance scaled by visit count, capped at 0.7.

What gets saved to the connection

{
status: 'connected',
meta: { path, browser: 'Chrome' | 'Edge' | 'Brave', lastImportAt, urls, topDomains: string[] },
}

The raw History file path is the connection’s encrypted secret (see Environment variables on ENCRYPTION_KEY); meta only ever exposes the top domain names, never full URLs, to the settings UI.

Re-import cadence

The learning-loop scheduler re-imports browser history automatically at most once every 24 hours per being (BROWSER_EVERY_MS), checked against meta.lastImportAt — see The learning loop. You can also trigger it on demand:

POST /api/twin/sync/browser → { started: true, provider: 'browser' }