DEV Community

Nguyen Tu
Nguyen Tu

Posted on Originally published at botslave.com

Your Browser History Is a Dataset: Build a Local, Private Bookmark Brain with a Local LLM

A story climbing daily.dev Popular this week — "I gave my local LLM my browser history, and it built a better bookmark system than I ever had" (#6 Popular on 29 October 2026, tags #automation #google-chrome, 18 upvotes, 185.1K impressions) — lands because it names a frustration every heavy browser user knows: bookmarks rot, and browser history is a pile you can only search by remembering the exact words in a title. The interesting part of the story is not the demo. It is the architecture hiding underneath it: your own browsing, indexed locally, retrievable by meaning, with nothing leaving your machine.

We build browser automation for a living, and our rule, stated on our how we work page, is to automate aggressively while keeping the blast radius small. A personal index over your own history is a perfect test of that rule: the data is yours, the value is real, and the failure mode — handing your entire browsing life to a hosted model — is exactly the kind of quiet blast-radius expansion we refuse. This piece is our practitioner design for the system: what browser history actually contains, the pipeline that turns it into a retrievable index, the privacy guardrails most demos skip, and the failure modes that decide whether the thing stays useful past week two.

1. Why bookmarks fail, and why history fails differently

Bookmarks fail by neglect. You save a page with the intention of reading it later, file it in a folder whose name made sense once, and never open the folder again. The collection grows, the retrieval path decays, and after a year the bookmark bar is a museum of intentions. Search inside bookmarks does not save you, because it is substring search over titles you did not write.

History fails in the opposite direction: it is complete but unorganised. It records everything in chronological order, with no notion of importance, topic, or intent. Finding "that article about connection pool sizing I read three weeks ago" means scrolling a timeline or guessing a keyword that appears in the title. Both tools answer the wrong question. Bookmarks answer "what did I decide to keep?" History answers "what did I open, in order?" Neither answers the question you actually ask: "what did I read about X, and where is it?"

A semantic index over history answers that third question. You ask in your own words, it returns the pages whose titles, URLs, and (optionally) content are closest in meaning, with links you can click to verify. The local LLM is not the product here. The index is. The model is just the retrieval layer, and keeping it local is what makes the whole thing safe to build.

2. What browser history actually contains

Before designing anything, look at the raw material. Chromium-based browsers (Chrome, Edge, Brave, and others) store history in a SQLite database file called History. On macOS it lives under ~/Library/Application Support/Google/Chrome/Default/History; on Linux under ~/.config/google-chrome/Default/History. Two tables do most of the work:

  • urls: one row per distinct URL, with the URL itself, the page title, a visit count, and the last visit time.
  • visits: one row per visit, with the visit time, the referring visit, and a transition type (typed, link click, reload, and so on).

Two facts about this schema shape the entire design. First, page content is not stored. History knows you visited a URL and what its title was; it does not know what the page said. Any system that claims to search "what the page was about" from history alone is really searching titles and URLs — which is often enough for "that article about X" retrieval, but must not be oversold. If you want content search, you need a separate enrichment step that fetches the page yourself (section 3). Second, Chrome locks the file while it runs. Never query the live database in place. Copy it first, then query the copy. A read-only copy is also your safety net: the pipeline can never corrupt the browser's own data.

A first look at your own data takes one command:

# Copy first — Chrome locks the live file while it runs.
cp ~/Library/Application\ Support/Google/Chrome/Default/History /tmp/history-copy.db

# Where does your time actually go?
sqlite3 /tmp/history-copy.db \
  "SELECT substr(url, 1, instr(substr(url, 9), '/') + 8) AS domain,
          COUNT(*) AS visits
   FROM urls GROUP BY domain ORDER BY visits DESC LIMIT 15;"
Enter fullscreen mode Exit fullscreen mode

Run that and you will learn something about your own habits in ten seconds — usually that three domains eat half your browsing. That query is also the shape of the whole pipeline: a small script, a local database, no service, no account, no upload.

One more schema detail matters for later: Chrome timestamps are microseconds since 1 January 1601, not Unix epoch. Any ingest script must convert ((visit_time / 1000000) - 11644473600 gives Unix seconds) or every "recent" ranking in the system will be silently wrong. This is the kind of boring conversion bug that ships in demos and then poisons retrieval for months. Test it on one known visit before trusting any output.

3. The pipeline: ingest, clean, enrich, embed, retrieve

The system is five stages, each deliberately boring. Boring is the point: every stage is a script you can read, rerun, and debug, and no stage requires a hosted service.

Ingest (incremental, idempotent). A scheduled job — cron on Linux, launchd on macOS — copies the History file and imports new visits. Keep a watermark: the maximum visit time already imported. Each run imports only visits newer than the watermark, and upserts by URL so reruns are harmless. This is the same discipline we described in our event-driven pipelines piece: retries and replays need idempotency and a dead-letter path, or a crashed run duplicates everything downstream. A visit that fails to import three times goes to a small review table, not into an infinite retry loop.

Clean (aggressive, before anything else). Raw history is not a dataset; it is a junk drawer. Cleaning runs before embedding, never after, and it does three things. It strips tracking parameters (utm_*, gclid, fbclid, and friends) so the same article shared from three sources collapses to one URL. It drops noise: chrome:// pages, localhost development servers, new-tab pages, and redirect hops that add rows without adding information. And it applies a denylist (section 4) that removes sensitive categories entirely — not flagged, removed, before any embedding exists. Cleaning is where most hobby versions of this system fail: they embed everything, then try to filter at query time, which means the sensitive data is already sitting in a vector store waiting for a bad query to surface it.

Enrich (optional, and honestly labelled). Title plus URL is enough for a surprising amount of retrieval, because titles usually carry the topic. If you want more, fetch the page text yourself at index time and store a short extracted summary — never the full page, which bloats the index and reintroduces copyrighted text you do not need. Fetching has honest limits: pages behind logins will fail, pages that changed since you visited will differ from what you read, and some sites will block automated fetches. Record the fetch status per URL. A result labelled "title-only, fetch failed" is more trustworthy than a result that silently pretends otherwise.

Embed (local, always). Run an embedding model on your own machine — a local runtime such as Ollama with an embedding model, or any equivalent local setup — and store one vector per URL. The embedding step is the one stage that must never be outsourced: the moment vectors derived from your history go to a hosted API, the privacy story is over, even if the raw URLs never leave. Vector storage can stay in SQLite with a vector extension, or in any local vector store you already run; the choice matters less than the location, which is your disk. Keep the embedding model version in the database alongside the vectors. If you change models later, re-embed everything in one pass rather than mixing vectors from two models in one index — mixed-model indexes retrieve badly in ways that look like random quality loss.

Retrieve (ranked, with sources). A query embeds with the same model, retrieves the nearest neighbours, then reranks with signals history already gives you for free: recency (a page from last week usually beats one from last year for the same query), visit count (you returned to it for a reason), and typed-versus-clicked transitions (a URL you typed is more likely a destination than one you clicked through). The answer format is fixed: a short list of titles with links, each labelled with its source signal (title-only or enriched, last visited date). The LLM may write a one-line summary per result. It may not answer from memory. Every claim in the output must trace to a link you can click — the same rule we apply to automation generally: a machine's summary is a pointer to evidence, not evidence itself.

Stage Input Output What breaks if you skip it
Ingest History copy New visits, watermarked Full re-imports, duplicates, a runaway job
Clean Raw visits Deduplicated, denylisted URLs Sensitive pages embedded, redirect spam in results
Enrich Clean URLs Short extracted text or title-only flag Title-only retrieval oversold as content search
Embed Clean text Local vectors + model version Hosted API sees your history; mixed models degrade ranking
Retrieve Query Ranked links with source labels Summaries without citations, stale pages on top

4. The privacy guardrails most demos skip

A local system is private only if its defaults are private. Four rules, all enforced in code rather than in good intentions:

Denylist before embedding, not after. Banking and payment domains, health portals, webmail, password-reset and one-time-code pages, and any URL containing tokens (token=, session=, auth=) never reach the embedder. Write the denylist as patterns in a config file you can read, and log how many rows each pattern removed per run. If the count is ever zero, something changed in your browsing or your patterns, and you should notice.

# Denylist: applied at clean time, before any embedding exists.
DENY_PATTERNS = [
    r"bank|payment|checkout",          # financial flows
    r"mail\.|webmail|inbox",           # other people's messages
    r"token=|session=|auth=|otp=",     # credentials in URLs
    r"localhost|127\.0\.0\.1",         # local dev, often sensitive
    r"chrome://|about:",               # browser internals
]

def keep(url: str) -> bool:
    return not any(re.search(p, url) for p in DENY_PATTERNS)
Enter fullscreen mode Exit fullscreen mode

One machine, one index, unless you decide otherwise. History from a work laptop and a personal laptop are different datasets with different sensitivity. Syncing the index across machines through a cloud drive quietly turns a local system into a distributed one. If you want multi-device search, sync the encrypted database file yourself and know that you did — do not let a tool sync it as a side effect.

A delete path that actually deletes. A bookmark brain without deletion becomes a liability the first time someone else uses your machine. Deleting a URL must remove its row, its vector, and its enriched text in one operation, and the operation should be a single command you can run without reading documentation. Test it on one URL and verify the row is gone before trusting it.

No second audience. The index answers to you. Do not point a shared assistant, a team tool, or a "helpful" browser extension at it. The moment a second consumer reads the index, every denylist decision has to be re-audited for that consumer's access, and nobody ever does that audit.

5. Failure modes that decide whether it survives week two

Most versions of this system work on day one and decay by day fourteen. The decay has predictable causes, each with a cheap fix.

Doomscroll pollution. History records attention, not intention. A week of idle scrolling produces hundreds of visits that will outrank the five pages you actually need, because the index cannot tell a rabbit hole from research. The fix is weighting, not filtering: visits with dwell time under a few seconds, or pages never revisited, rank below pages you returned to or typed directly. If your ingest does not capture dwell time, visit count and typed transitions are usable proxies — use both.

Duplicate identities. The same article arrives as a shared link, a search result, and a newsletter click — three URLs, one page, after tracking parameters are stripped. Dedupe on the cleaned URL, and when two cleaned URLs still point at the same title, keep the earliest and merge visit counts. Without this, your top results for any popular topic will be three copies of the same page.

Link rot and drift. Pages move, paywalls rise, and content changes after you read it. A monthly dead-link sweep — re-fetch stored URLs, mark the ones that 404 or redirect to a homepage — keeps the index honest. Mark, do not silently delete: a dead link to a page you read is still a useful memory, but it must be labelled dead so you do not click it expecting the article.

Title ambiguity. "Dashboard", "Home", and "Untitled" are titles of a thousand different pages. Title-only retrieval fails on exactly these. The fix is the enrichment step from section 3, plus a fallback: when a result's title is generic, show its domain and visit date prominently so you can disambiguate by context. A result you cannot identify is worse than no result, because it costs a click to discover it is wrong.

History gaps. Incognito visits are not recorded, other browsers and devices are not in this database, and history auto-expiry deletes old rows. The index is a view of one browser on one machine, not a memory of everything you read. State that limitation in the tool's own help text. Systems that pretend to completeness get trusted for questions they cannot answer; systems that state their coverage get used for the questions they can.

6. The minimal version worth building

Resist the urge to build a product. The version worth building is two scripts and one database: an ingest script that runs on a schedule and a query script that takes a question and prints ranked links. Everything else — a web UI, a browser extension, a chat interface — is decoration that can come later, and each addition reopens the privacy audit from section 4.

Concretely, the first weekend version looks like this: a cron job that copies History, imports new visits past the watermark, cleans and denylists, and embeds new titles with a local model; and a query command that embeds your question, retrieves the top ten, reranks by recency and visit count, and prints titles, URLs, and last-visited dates. If you use the query command daily for two weeks and it keeps finding pages faster than scrolling history, the system has earned a UI. If it does not, no UI will save it — the failure is in the data or the ranking, and a nicer interface will only hide that for longer.

That restraint is also the connection to our earlier writing on browser automation. In our piece on building automation that survives the browser, the lesson was to target the engine and the portable session, not the brand on the window. The same lesson applies here: target the History file format and the local index, not any one browser's sync service or any one model's API. Browsers get shut down, sync services change terms, and hosted models get repriced. A SQLite file on your disk and a script you can read survive all three.

7. What not to do

Three mistakes show up in nearly every public version of this idea. First, do not export raw history to a hosted LLM, even "just to try it". The export is the breach: once the data leaves, deletion requests and privacy policies are your only recourse, and they are not a design. Second, do not auto-share or auto-publish anything the system surfaces — a retrieved link is a memory aid, not a recommendation, and the difference matters the moment a private page appears in a shared context. Third, do not let the model's summary replace the page. If the summary says a page contains something, click through and check. Models paraphrase confidently from titles alone, and a confident paraphrase of a title you misremember is how a wrong citation enters your work.

FAQ

Does this require a powerful machine?
No. Embedding a few thousand titles is a small job for a modern laptop, and retrieval over a personal-scale index is fast on SQLite. The expensive version of this system is the one that fetches and embeds full page text for every visit; the title-plus-URL version runs comfortably on hardware you already own.

Will it work with Firefox or Safari?
The pipeline is the same, but the source file differs: Firefox stores history in places.sqlite with its own schema, and Safari's history storage is less directly accessible. The ingest stage is the only part that changes. If you use two browsers, run one ingest per browser into the same index and tag rows by source — but remember the privacy audit from section 4 applies per source.

Is title-only retrieval actually useful?
For "that article about X" questions, yes — titles usually carry the topic, and recency plus visit-count reranking does the rest. For "what did that page say about X" questions, no, and the system should say so: a title-only result labelled as such is honest, while a summary invented from a title is not.

What happens when I change embedding models?
Re-embed everything in one pass and record the new model version. Mixing vectors from two models in one index degrades retrieval in ways that look like random quality loss, which is the hardest kind of bug to notice.

Should I index a shared or work machine?
Only with the denylist from section 4 extended for that machine's sensitivity, and only if you are the sole audience for the index. On a machine others use, the delete path matters more than the retrieval quality — build that first.

References

Originally published on botslave.com. Botslave is an automation studio — see what we build and how we work.

Top comments (0)