SPECIFICATION

his Spec

Browser history archive & viewer.

Overview

One SQLite archive for all browser history, with WebUI.

Unified archive

One SQLite file (WAL + FTS5), WebExtensions-style model — HistoryItem + VisitItem.

Incremental sync

Snapshot copies + checkpoint cursors.

Three frontends

CLI, WebUI, Desktop shell.

Local only

127.0.0.1 only

Directory structure

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

Architecture

his serve owns the archive, the WebUI, and the RPC socket. Desktop just spawns it.

HIS SERVE PROCESS — his serve GET / · static WS /rpc upsert · dedup snapshot copy spawn · stop his <command> bin.ts · sync · sources · serve · query HTTP server static dist · WebUI assets RPC server JSON-RPC 2.0 · WS /rpc Sync gate → snapshot → parse → upsert history.db WAL · FTS5 WebUI React · @preact/signals Desktop (Electron) spawn · stop · BrowserWindow Browser sources Chromium · Firefox · Safari · Takeout

Desktop app

macOS menu bar client. Spawns his serve.

Process

Menubar app — dock hidden, tray. Spawns his serve at boot, stops on quit.

Window

One BrowserWindow — loads the serve URL. Close hides it.

Serve

Reuses a running instance, else spawns + waits. Tray actions respawn when down.

Tray menu

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

WebUI

React SPA served by his serve.

Dashboard

Totals, by-hour / by-day charts, top domains.

Visits

Date-grouped visits, infinite scroll + search.

URLs

Unique URLs, list / card toggle + search.

Keywords

Search-engine queries, daily chart + recent searches.

GitHub

PR / repo stats + recent visits.

YouTube

Video / shorts stats, daily chart + recent items.

Sources

Discovered profiles, sync targets + resync.

Settings

Privacy — hide URL patterns.

CLI

Syntax: his [command] [options]

CommandBehavior
syncOne 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
serveForeground: HTTP + RPC + 6h sync. --port <n> (default 4477) · --open
query [text]Read-only search → JSON. --url · --from · --to · --limit
statusPrint running instance (exit 1 if none)
--home <dir>Archive home (default ~/.his, env HIS_HOME)
-h / -vHelp / version

RPC

JSON-RPC 2.0 over WS at /rpc.

Auth

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

Data model

URLs and visits in one SQLite file.

Schema

HISTORY.DB — WAL · foreign_keys=ON url_id · N:1 source_profile_id 1:1 external content · triggers urls HistoryItem id · PK url · UNIQUE title first_visit_time · ms last_visit_time · ms visit_count typed_count idx: last_visit_time DESC urls_fts FTS5 · trigram title / url — indexed content='urls' · rowid=id trigger-maintained < 3 chars → LIKE fallback visits VisitItem id · PK url_id · FK visit_time · ms transition · Chromium enum source_profile_id · FK source_visit_id · native UNIQUE(source_profile_id, source_visit_id) idx: visit_time DESC idx: (url_id, visit_time DESC) source_profiles id · PK browser profile kind · chromium, firefox, safari, takeout source_path UNIQUE(browser, profile) sync_checkpoints cursors source_profile_id · PK · FK last_source_visit_id source_id_offset · reset offset last_fingerprint · change gate last_run_at
-- 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;

WebExtensions mapping

Based on the WebExtensions history API.

hisWebExtensions
history.searchbrowser.history.search() — plus cursor / limit
HistoryItemHistoryItem
VisitItemVisitItem
transitionTransitionType

Sync

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.

Gate

Skips targets whose fingerprint (mtime/size) matches the stored sync_checkpoints.last_fingerprint.

Snapshot

The live DB is locked by the browser, so sync reads a stable copy. path: ~/.his/snapshots/<source>/<profile>

  1. Copy: VACUUM INTO from a read-only handle — WAL folded.
  2. Fallback: on hot journal / open failure, copy main + sidecars, fold on open.

Parse

Normalizes each source's rows into the schema.

Upsert

INSERT OR IGNORE keeps re-sync idempotent.

Checkpoint

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.

Trigger

6h periodic + startup catch-up.

Sources

KindSourceTime → msTransition
chromiumHistory DB — Chrome / Chromium / Edge / Brave / Vivaldi / Arc / Opera ...WebKit µs (1601) → msNative enum
firefoxplaces.sqlitePRTime µs ÷ 1000Places visit_type → enum (below)
safariHistory.dbCore Data s (2001) + 978307200Conservative → link, raw kept
takeoutBrowserHistory.json — 3 layoutstime_usec ÷ 1000page_transition → lowercase enum

Transition mapping

Visit kindChromium (stored)Firefox Places
Linklink1
Address bartyped2
Bookmarkauto_bookmark3
Subframeauto_subframe / manual_subframe4 / 8
Form / generatedform_submit / generated—
Reload / start / keywordreload / auto_toplevel / keyword · keyword_generated9 / —
Redirect— (chain)5 / 6 → link
Download—7 → link

Data directory

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

Runtime state

The running instance keeps ~/.his/state.json.

Created / removed

Written once the port is bound, removed on clean shutdown.

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.

State file

{
  "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
}