feat(web): Insights quadrant + read-heat docs (phase 2)

Admin/org-owner Insights view (⋯ menu): dependency-free SVG scatter of
every file by 30-day reads × days since last change, log scales, with the
hot-but-stale danger quadrant shaded and a ranked fix-these-first list;
lens toggle for all/human/agent reads. The Claude Code plugin gains a
PostToolUse(Read) hook so plugin users feed agent-read telemetry without
project-level hook registration. Docs synced: README, SKILL.md,
plugin install/init commands, CLAUDE.md, design doc marked implemented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-11 14:54:32 -07:00
co-authored by Claude Fable 5
parent 2ce714ac4d
commit e3ca821dc4
10 changed files with 229 additions and 17 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ Package roles (`internal/`):
- **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely.
- **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch).
- **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes/<mount-id>/`. `.bdrive/` is never synced and holds no credentials.
- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `<root>/<project-id>/` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/<token>`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p/<id>/store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`/<project-id>/<path>` in hub mode, `/<path>` in volume mode, `/join/<token>` for invites — no `#`, slashes stay literal); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve, and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`) keep their in-memory maps + logic and persist each change as one record through a repo. Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend.
- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `<root>/<project-id>/` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/<token>`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p/<id>/store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`/<project-id>/<path>` in hub mode, `/<path>` in volume mode, `/join/<token>` for invites — no `#`, slashes stay literal); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve, and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p/<id>/reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p/<id>/heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend.
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `web`, `whoami`, `daemon`, `version``mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login <new-url>` then re-`init``init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared <dir>`, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning.
+16 -1
View File
@@ -122,7 +122,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping; idempotent (`--agent` overrides detection) |
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking; idempotent (`--agent` overrides detection) |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap; drained on the next sync. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
@@ -200,6 +201,10 @@ the positional argument), `--upload` (allow client writes, off by default),
"admins": ["admin@example.com"],
"smtp": { "host": "smtp.example.com", "port": 587,
"user": "drive@example.com", "pass": "…", "from": "drive@example.com" }
},
"reads": { // read heatmap telemetry (hub mode)
"enabled": true, // default true; aggregate counts only
"retention_days": 400 // daily buckets older than this fold into all-time totals
}
}
```
@@ -310,6 +315,16 @@ phase and the API is already shaped for it). Folder rows have a history
shortcut for a subtree feed; the topbar button shows the current file's
versions or the whole project feed.
Hubs also track **read heat**: viewer opens and downloads count as human
reads, share-link hits as share reads, and agent tool reads (reported by
the sync hooks via `bdrive read-log`) as agent reads — sync replication
never counts. Folder listings show heat dots and 30-day read counts to
every member, and admins / org owners get an **Insights** view (⋯ menu)
plotting each file by reads × days since last change: the hot-but-stale
quadrant is the knowledge people rely on that nobody maintains. The API
(`GET /api/p/<id>/heat?prefix=&days=`) exposes only aggregate counts,
distinct-reader counts, and last-read times — never who read what.
### Authentication
Hubs always require sign-in — every change is attributed to a real account.
+1 -1
View File
@@ -1,6 +1,6 @@
# Read heatmap — design
Status: proposed (2026-07-11) · Owner: snow · Prior art: session-linked notes (shipped), history API
Status: implemented (2026-07-11, all three phases) · Owner: snow · Prior art: session-linked notes (shipped), history API
## Problem
+135 -1
View File
@@ -812,7 +812,7 @@ function renderNode(n) {
openFolder(n.path);
};
} else {
flatFiles.push({ path: n.path, name: n.name });
flatFiles.push({ path: n.path, name: n.name, time: n.time });
row.onclick = () => openFile(n.path);
}
return li;
@@ -1135,6 +1135,132 @@ function updateShareButton() {
};
}
/* ---- insights: the read×write matrix ----
Every file plotted by how much it is read (30 days, from the heat API)
against how long since it last changed (from the tree). The hot-but-stale
quadrant is the danger zone: knowledge people still rely on that nobody
maintains. Admin/org-owner only members get the ambient heat dots. */
const HOT_READS = 3; // ≥ this many reads/30d = hot
const STALE_DAYS = 30; // ≥ this many days since last write = stale
function canSeeInsights() {
if (!(serverConfig.mode === "hub" && currentProject)) return false;
if (!(serverConfig.reads && serverConfig.reads.enabled)) return false;
if (serverConfig.auth && serverConfig.auth.admin) return true;
const org = currentOrg();
return !!(org && org.role === "owner");
}
async function showInsights() {
if (!canSeeInsights()) return;
await refreshHeat(true);
currentPath = null;
markActive();
$("crumb").textContent = "Insights — " + currentProject.name;
$("meta").textContent = "";
$("download").hidden = true;
$("more-btn").hidden = true;
const content = $("content");
content.className = "view";
renderInsights(content, "all");
}
function renderInsights(content, lens) {
content.innerHTML = "";
const wrap = el(content, "div", "insights");
el(wrap, "h1", "in-title", "Reads × freshness");
el(wrap, "p", "dl-sub",
"Every file by 30-day reads and days since its last change. " +
"Hot but stale (top right) is the danger zone — read a lot, maintained by nobody.");
const bar = el(wrap, "div", "in-lens");
for (const l of ["all", "human", "agent"]) {
const label = l === "all" ? "All reads" : l === "human" ? "Human reads" : "Agent reads";
const b = el(bar, "button", "in-lens-btn" + (l === lens ? " active" : ""), label);
b.onclick = () => renderInsights(content, lens = l);
}
const readsOf = (e) => (lens === "all" ? heatTotal(e) : e[lens] || 0);
const now = Date.now();
const pts = flatFiles.map((f) => {
const e = (heatMap && heatMap[f.path]) || {};
const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0;
const reads = readsOf(e);
return { path: f.path, reads, days, danger: reads >= HOT_READS && days >= STALE_DAYS };
});
wrap.appendChild(insightsChart(pts));
const danger = pts.filter((p) => p.danger)
.sort((a, b) => b.reads - a.reads || b.days - a.days).slice(0, 15);
el(wrap, "h3", "dl-h3", "Danger zone — fix these first");
if (!danger.length) {
el(wrap, "div", "dl-empty", "No hot-but-stale files. The knowledge base is healthy.");
return;
}
const list = el(wrap, "div", "dl-items");
for (const p of danger) {
const row = el(list, "div", "dl-row");
row.tabIndex = 0;
row.setAttribute("role", "button");
const icon = el(row, "span", "ticon");
icon.innerHTML = svgIcon("alert");
el(row, "span", "dl-name", p.path);
el(row, "span", "dl-meta",
p.reads + (p.reads === 1 ? " read" : " reads") + " · untouched " + Math.round(p.days) + "d");
row.onclick = () => openFile(p.path);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
}
}
/* Dependency-free SVG scatter: x = days since last write, y = reads, both
log-scaled; threshold lines split the quadrants. */
function insightsChart(pts) {
const W = 720, H = 360, M = { l: 44, r: 16, t: 20, b: 34 };
const maxDays = Math.max(STALE_DAYS * 2, ...pts.map((p) => p.days));
const maxReads = Math.max(HOT_READS * 2, ...pts.map((p) => p.reads));
const lx = (d) => Math.log10(d + 1) / Math.log10(maxDays + 1);
const ly = (r) => Math.log10(r + 1) / Math.log10(maxReads + 1);
const X = (d) => M.l + lx(d) * (W - M.l - M.r);
const Y = (r) => H - M.b - ly(r) * (H - M.t - M.b);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("class", "in-chart");
const add = (tag, attrs, text) => {
const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
if (text != null) n.textContent = text;
svg.appendChild(n);
return n;
};
// danger quadrant shading + threshold lines
add("rect", { x: X(STALE_DAYS), y: M.t, width: W - M.r - X(STALE_DAYS), height: Y(HOT_READS) - M.t, class: "in-danger-zone" });
add("line", { x1: X(STALE_DAYS), y1: M.t, x2: X(STALE_DAYS), y2: H - M.b, class: "in-threshold" });
add("line", { x1: M.l, y1: Y(HOT_READS), x2: W - M.r, y2: Y(HOT_READS), class: "in-threshold" });
// axes
add("line", { x1: M.l, y1: H - M.b, x2: W - M.r, y2: H - M.b, class: "in-axis" });
add("line", { x1: M.l, y1: M.t, x2: M.l, y2: H - M.b, class: "in-axis" });
add("text", { x: (M.l + W - M.r) / 2, y: H - 8, class: "in-label" }, "days since last change →");
add("text", { x: 12, y: (M.t + H - M.b) / 2, class: "in-label", transform: `rotate(-90 12 ${(M.t + H - M.b) / 2})` }, "reads / 30d →");
add("text", { x: W - M.r - 6, y: M.t + 14, class: "in-quad in-quad-danger", "text-anchor": "end" }, "hot + stale");
add("text", { x: M.l + 6, y: M.t + 14, class: "in-quad" }, "hot + fresh");
add("text", { x: W - M.r - 6, y: H - M.b - 8, class: "in-quad", "text-anchor": "end" }, "cold + stale");
for (const p of pts) {
const c = add("circle", {
cx: X(p.days).toFixed(1), cy: Y(p.reads).toFixed(1), r: 5,
class: "in-pt" + (p.danger ? " danger" : p.reads ? "" : " cold"),
});
const tip = document.createElementNS("http://www.w3.org/2000/svg", "title");
tip.textContent = `${p.path}${p.reads} read${p.reads === 1 ? "" : "s"} / 30d · changed ${Math.round(p.days)}d ago`;
c.appendChild(tip);
c.onclick = () => openFile(p.path);
}
return svg;
}
/* ---- history ----
Every change ever made, straight from the journals: who (account), when,
from which device (name, OS, IP as the server saw it), with view/download
@@ -1553,6 +1679,14 @@ function buildMoreMenu() {
b.onclick = () => { $("more-menu").hidden = true; el.click(); };
menu.appendChild(b);
}
if (canSeeInsights()) {
const b = document.createElement("button");
b.className = "more-item";
b.textContent = "Insights";
b.onclick = () => { $("more-menu").hidden = true; showInsights(); };
menu.appendChild(b);
return items.length + 1;
}
return items.length;
}
$("more-btn").addEventListener("click", (e) => {
+19
View File
@@ -281,6 +281,25 @@ button, input, a.btn { font-family: inherit; }
.dl-hlist .hentry:last-child { border-bottom: none; }
.dl-more { margin-top: 10px; }
/* ---- insights (read×write matrix) ---- */
.insights { max-width: 760px; margin: 0 auto; }
.in-title { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.in-lens { display: flex; gap: 6px; margin: 0 0 14px; }
.in-lens-btn { font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: none; color: var(--text-faint); cursor: pointer; }
.in-lens-btn:hover { color: var(--text); }
.in-lens-btn.active { color: var(--accent); border-color: var(--accent); }
.in-chart { width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin-bottom: 6px; }
.in-axis { stroke: var(--border); stroke-width: 1; }
.in-threshold { stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 4; }
.in-danger-zone { fill: rgba(242, 109, 109, .05); }
.in-label { fill: var(--text-ghost); font-size: 11px; }
.in-quad { fill: var(--text-ghost); font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; }
.in-quad-danger { fill: #e07070; }
.in-pt { fill: var(--accent); opacity: .75; cursor: pointer; }
.in-pt:hover { opacity: 1; }
.in-pt.cold { fill: var(--text-ghost); opacity: .35; }
.in-pt.danger { fill: #e05d5d; }
/* ---- history ---- */
.history { max-width: 860px; }
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
+6 -4
View File
@@ -56,10 +56,12 @@ Follow these steps:
detects the agent platforms in use (Claude Code, Codex, Gemini CLI,
Hermes — by their config dirs in the project or home) and idempotently
merges beardrive's sync hooks into each platform's own hook config, so
files pull at every turn start, push after edits, and every change is
stamped with the agent session that made it. Tell the user which
platforms got hooks; if Codex is among them, mention they must run
`/hooks` inside Codex once to trust the project's `.codex` layer.
files pull at every turn start, push after edits, every change is
stamped with the agent session that made it, and agent file reads feed
the hub's read heatmap (queued locally by `bdrive read-log`, reported
on the next sync). Tell the user which platforms got hooks; if Codex is
among them, mention they must run `/hooks` inside Codex once to trust
the project's `.codex` layer.
6. **Verify**: run `bdrive status <folder>` and confirm the daemon is
running and pending is 0. Summarize: project name/id, what syncs, and
+5 -1
View File
@@ -77,7 +77,11 @@ team's latest files), push right after edits (artifacts land on the server
seconds after they're created — daemon or no daemon), and stamp every
change with the agent session that made it (`bdrive sync --note "<agent>
session <id>"` — visible in `bdrive log` and the hub's history views).
They are fast no-ops in folders without `.bdrive/`.
A third hook on each platform's read tool (`bdrive read-log`) queues which
files the agent read, so the hub's read heatmap can show admins what the
team's agents actually consume — reads are reported on the next sync,
never from the hook itself. They are fast no-ops in folders without
`.bdrive/`.
Tell the user which platforms got hooks (`bdrive hooks` shows the status
table). If Codex is among them, mention they must run `/hooks` inside
+12
View File
@@ -12,6 +12,18 @@
]
}
],
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-read.sh\"",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Queue the file reads from a Read tool call for the hub's read heatmap.
# `bdrive read-log` parses the hook's stdin JSON itself and only appends to
# a local spool (drained on the next sync) — no network, no locking, so this
# is safe to run on every Read in every project. Fast no-op outside
# beardrive projects.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
[ -d .bdrive ] || exit 0
command -v bdrive >/dev/null 2>&1 || exit 0
bdrive read-log . >/dev/null 2>&1 || true
+24 -8
View File
@@ -17,7 +17,8 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Run the daemon in the foreground | `bdrive init -f` |
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
| One sync cycle now | `bdrive sync [<folder>]` |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally; drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| This device's identity | `bdrive whoami` |
@@ -130,20 +131,35 @@ conflict-copy ops keep their own `conflict copy of <path>` note.
`bdrive hooks install [<folder>]` registers turn-boundary sync for every
agent platform it detects (by config dir, in the project or home):
| Platform | Config it writes | Pull / push events |
| Platform | Config it writes | Pull / push / read events |
|---|---|---|
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) |
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once |
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) |
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) |
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read) |
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) / `PostToolUse` (read_file, best-effort) — user must `/hooks`-trust the layer once |
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) / `AfterTool` (read_file\|read_many_files) |
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) / `post_tool_call` (read_file) |
Every platform pipes hook JSON with a `session_id`, so one hook command
serves all four: it syncs the project (fast no-op outside bdrive folders)
and stamps changes with `<agent> session <id>`. Merging is idempotent and
preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides
and stamps changes with `<agent> session <id>`. The read hook runs `bdrive
read-log`, which queues the read locally (no network) for the hub's read
heatmap. Merging is idempotent and preserves existing hooks — each hook
carries its own marker, so configs from before the read hook gain just the
missing group on re-install; `--agent claude,codex,gemini,hermes` overrides
detection; bare `bdrive hooks` prints the detection/registration table.
Project-level configs ride the repo, so hooks reach the whole team.
### Read heat (who actually reads what)
Hubs aggregate reads per file — viewer opens and downloads count as human
reads, share-link hits as share reads, and hook-reported agent reads as
agent reads; `/store` sync replication never counts. The web UI shows heat
dots and read counts on folder listings (all members), and admins / org
owners get an **Insights** view (⋯ menu) plotting every file by 30-day
reads × days since last change — the hot-but-stale quadrant is the list of
files to fix first. Counts only, never reader identities. API:
`GET /api/p/<id>/heat?prefix=&days=30`. Server config: `"reads":
{"enabled": true, "retention_days": 400}` (on by default in hub mode).
### Examples to walk a user through
```sh