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:
| OS | Base 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 urlsWHERE last_visit_time > ? AND hidden = 0ORDER 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' }