Browser history archive & viewer.
One SQLite archive for all browser history, with WebUI.
One SQLite file (WAL + FTS5), WebExtensions-style model — HistoryItem + VisitItem.
Snapshot copies + checkpoint cursors.
CLI, WebUI, Desktop shell.
127.0.0.1 only
core · server · three frontends — cli · webui · desktop
his/ 🧠 core/ # types · db · features · sync ├── package.json └── src/ ├── index.ts ├── node.ts # node entry ├── types.ts # HistoryItem · VisitItem · Transition · epoch ms ├── db/ # migrations/ · db.ts · schema.ts · query/ ├── features/ # history/ · keywords/ · github/ · youtube/ · settings/ · sources/ · sync/ ├── util/ # url · top └── testing/ # fixtures 📦 cli/ # npm package: his ├── package.json # "bin": { "his": "./bin.ts" } ├── bin.ts └── src/ ├── context.ts # context for commands (home path) ├── sync.ts # sync command ├── sources.ts # sources command · list / add / remove targets ├── query.ts # query command ├── serve.ts # serve command · delegates to @his/server └── status.ts # status command 🔌 server/ # @his/server — transport over core ├── package.json └── src/ ├── index.ts # serve() · HTTP + RPC · state.json ├── app.ts # static · entry token ├── rpc.ts # WS JSON-RPC · framing · handlers ├── static.ts # dist · MIME · cache └── sync-runner.ts # in-process sync · 6h 🌐 webui/ # React + @preact/signals SPA ├── package.json ├── vite.config.ts # dist/ → served by `his serve` └── src/ ├── main.tsx ├── App.tsx # root · view nav ├── app/ # app model · api ├── components/ # SearchBar · icons · ui ├── lib/ # transport · date · domain · format · signals └── views/ # dashboard · visits · urls · keywords · github · youtube · sources · settings 🖥️ desktop/ # Electron shell ├── package.json ├── vite.config.ts ├── assets/ # tray icon └── src/ ├── main.ts # Electron entry · app events ├── app.ts # HisApp · window · serve lifecycle └── tray.ts # tray menu
his serve owns the archive, the WebUI, and the RPC socket. Desktop just spawns it.
macOS menu bar client. Spawns his serve.
Menubar app — dock hidden, tray. Spawns his serve at boot, stops on quit.
One BrowserWindow — loads the serve URL. Close hides it.
Reuses a running instance, else spawns + waits. Tray actions respawn when down.
Open # BrowserWindow → serve URL Open in Browser # OS default browser Sync Now # sync.run over RPC ───────────────────────────── New version available — v0.3.0 # only when a newer release exists · opens the release page Quit his # stop serve · quit
React SPA served by his serve.
Totals, by-hour / by-day charts, top domains.
Date-grouped visits, infinite scroll + search.
Unique URLs, list / card toggle + search.
Search-engine queries, daily chart + recent searches.
PR / repo stats + recent visits.
Video / shorts stats, daily chart + recent items.
Discovered profiles, sync targets + resync.
Privacy — hide URL patterns.
Syntax: his [command] [options]
| Command | Behavior |
|---|---|
sync | One sync, writes history.db. --target <browser[:profile]> (repeatable; default: configured targets) · --rescan (bypass the change gate; dedup, no deletions) |
sources [action] [target...] | List discovered ∪ registered sources (enabled marked *). add <target...> / remove persist sync_targets; both idempotent. Target is browser or browser:profile |
serve | Foreground: HTTP + RPC + 6h sync. --port <n> (default 4477) · --open |
query [text] | Read-only search → JSON. --url · --from · --to · --limit |
status | Print running instance (exit 1 if none) |
--home <dir> | Archive home (default ~/.his, env HIS_HOME) |
-h / -v | Help / version |
JSON-RPC 2.0 over WS at /rpc.
Allow-listed Origin (127.0.0.1 / localhost) + the state-file token.
# request / response history.search { query?, url?, from?, to?, cursor?, limit? } → { items, nextCursor } history.searchVisits { query?, url?, from?, to?, cursor?, limit? } → { items, nextCursor } history.stats { month? } → Stats keywords.stats { month? } → KeywordStats keywords.search { month?, cursor?, limit? } → { items, nextCursor } github.stats { month? } → GithubStats github.search { month?, owner?, repo?, kind?, cursor?, limit? } → { items, nextCursor } youtube.stats { month? } → YoutubeStats youtube.search { month?, kind?, cursor?, limit? } → { items, nextCursor } sources.list { check? } → SourceEntry[] # discovered ∪ registered · check probes source files sync.run { targets?, rescan? } → SyncSummary # targets omitted → settings sync.status → SyncSummary | null # latest server run; timestamps and errors included settings.get → SettingsInfo # { hidden_patterns, hidden_url_count, sync_targets } settings.set { hidden_patterns?, sync_targets? } → SettingsInfo
URLs and visits in one SQLite file.
-- history.db · WAL · foreign_keys=ON CREATE TABLE source_profiles ( -- browser profiles id INTEGER PRIMARY KEY, browser TEXT NOT NULL, -- chrome, brave, ... profile TEXT NOT NULL, -- profile dir kind TEXT NOT NULL, -- engine source_path TEXT NOT NULL, UNIQUE(browser, profile) ); CREATE TABLE urls ( -- HistoryItem id INTEGER PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT, -- last seen first_visit_time INTEGER NOT NULL, -- epoch ms last_visit_time INTEGER NOT NULL, visit_count INTEGER NOT NULL DEFAULT 0, typed_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE visits ( -- VisitItem id INTEGER PRIMARY KEY, url_id INTEGER NOT NULL REFERENCES urls(id), visit_time INTEGER NOT NULL, -- epoch ms transition TEXT NOT NULL, -- LINK / TYPED / ... source_profile_id INTEGER NOT NULL REFERENCES source_profiles(id), source_visit_id INTEGER NOT NULL, -- native id · takeout: hash UNIQUE(source_profile_id, source_visit_id) ); CREATE INDEX idx_visits_visit_time ON visits(visit_time DESC); CREATE INDEX idx_visits_url_id_visit_time ON visits(url_id, visit_time DESC); CREATE INDEX idx_urls_last_visit_time ON urls(last_visit_time DESC); -- timeline sort CREATE TABLE sync_checkpoints ( -- sync cursors source_profile_id INTEGER PRIMARY KEY REFERENCES source_profiles(id), last_source_visit_id INTEGER NOT NULL DEFAULT 0, source_id_offset INTEGER NOT NULL DEFAULT 0, -- += last_raw_id on clear last_fingerprint TEXT, -- change gate last_run_at INTEGER ); CREATE VIRTUAL TABLE urls_fts USING fts5( title, url, content='urls', content_rowid='id', tokenize='trigram' -- trigger-maintained ); CREATE TRIGGER urls_fts_after_insert AFTER INSERT ON urls BEGIN INSERT INTO urls_fts(rowid, title, url) VALUES (new.id, new.title, new.url); END; CREATE TRIGGER urls_fts_after_delete AFTER DELETE ON urls BEGIN INSERT INTO urls_fts(urls_fts, rowid, title, url) VALUES ('delete', old.id, old.title, old.url); END; CREATE TRIGGER urls_fts_after_update AFTER UPDATE ON urls BEGIN INSERT INTO urls_fts(urls_fts, rowid, title, url) VALUES ('delete', old.id, old.title, old.url); INSERT INTO urls_fts(rowid, title, url) VALUES (new.id, new.title, new.url); END;
Based on the WebExtensions history API.
| his | WebExtensions |
|---|---|
history.search | browser.history.search() — plus cursor / limit |
HistoryItem | HistoryItem |
VisitItem | VisitItem |
transition | TransitionType |
Sync the configured targets.
targets → gate → snapshot → parse → upsert → checkpoint
Targets come from settings.sync_targets; an empty list syncs nothing. his sync --target overrides them once. Edit the set in the WebUI Sources page or with his sources add / remove.
Skips targets whose fingerprint (mtime/size) matches the stored sync_checkpoints.last_fingerprint.
The live DB is locked by the browser, so sync reads a stable copy. path: ~/.his/snapshots/<source>/<profile>
VACUUM INTO from a read-only handle — WAL folded.Normalizes each source's rows into the schema.
INSERT OR IGNORE keeps re-sync idempotent.
Advances the cursor after each committed run. Reset: if the source's max id shrinks (history cleared), bump source_id_offset += last_raw_id so old ids can't collide.
6h periodic + startup catch-up.
| Kind | Source | Time → ms | Transition |
|---|---|---|---|
chromium | History DB — Chrome / Chromium / Edge / Brave / Vivaldi / Arc / Opera ... | WebKit µs (1601) → ms | Native enum |
firefox | places.sqlite | PRTime µs ÷ 1000 | Places visit_type → enum (below) |
safari | History.db | Core Data s (2001) + 978307200 | Conservative → link, raw kept |
takeout | BrowserHistory.json — 3 layouts | time_usec ÷ 1000 | page_transition → lowercase enum |
| Visit kind | Chromium (stored) | Firefox Places |
|---|---|---|
| Link | link | 1 |
| Address bar | typed | 2 |
| Bookmark | auto_bookmark | 3 |
| Subframe | auto_subframe / manual_subframe | 4 / 8 |
| Form / generated | form_submit / generated | — |
| Reload / start / keyword | reload / auto_toplevel / keyword · keyword_generated | 9 / — |
| Redirect | — (chain) | 5 / 6 → link |
| Download | — | 7 → link |
Default ~/.his, override with --home <dir> or HIS_HOME.
~/.his/ ├── state.json # running instance — {pid, port, url, db, token} (0600) ├── history.db # the archive └── snapshots/ # snapshot copies
The running instance keeps ~/.his/state.json.
Written once the port is bound, removed on clean shutdown.
Ctrl+C or SIGTERM stops the server. A Desktop-spawned server also exits when the app goes away: Desktop holds its stdin pipe and sets HIS_EXIT_ON_STDIN_CLOSE=1, so a crash or Force Quit cannot leave an orphan holding the port or the archive.
{
"pid": 12345, // serve process
"port": 4477, // bound port
"url": "http://localhost:4477/",
"db": "/Users/me/.his/history.db", // archive path
"token": "9f2c…" // required by /rpc
}