From e3ca821dc4297165bd8b7b0aa30c7e7c1f91fc54 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sat, 11 Jul 2026 14:54:32 -0700 Subject: [PATCH] feat(web): Insights quadrant + read-heat docs (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- CLAUDE.md | 2 +- README.md | 17 +++- docs/design/read-heatmap.md | 2 +- internal/webapp/static/app.js | 136 ++++++++++++++++++++++++++++++- internal/webapp/static/style.css | 19 +++++ plugin/commands/init.md | 10 ++- plugin/commands/install.md | 6 +- plugin/hooks/hooks.json | 12 +++ plugin/scripts/beardrive-read.sh | 10 +++ plugin/skills/beardrive/SKILL.md | 32 ++++++-- 10 files changed, 229 insertions(+), 17 deletions(-) create mode 100755 plugin/scripts/beardrive-read.sh diff --git a/CLAUDE.md b/CLAUDE.md index f2e71ab..c73bf36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`. `.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 `//` 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/`), 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//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 (`//` in hub mode, `/` in volume mode, `/join/` 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 `//` 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/`), 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//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 (`//` in hub mode, `/` in volume mode, `/join/` 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//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//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 ` 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 `, 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. diff --git a/README.md b/README.md index d34e85b..52eba19 100644 --- a/README.md +++ b/README.md @@ -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 ` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) | | `bdrive sync [folder]` | Run one sync cycle now. `--note ` 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//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. diff --git a/docs/design/read-heatmap.md b/docs/design/read-heatmap.md index 9a0788a..15772d2 100644 --- a/docs/design/read-heatmap.md +++ b/docs/design/read-heatmap.md @@ -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 diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 8b9dbaf..a7baa5f 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -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) => { diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index c0f5d94..25bf24b 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -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); } diff --git a/plugin/commands/init.md b/plugin/commands/init.md index dd13a7a..e8c7901 100644 --- a/plugin/commands/init.md +++ b/plugin/commands/init.md @@ -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 ` and confirm the daemon is running and pending is 0. Summarize: project name/id, what syncs, and diff --git a/plugin/commands/install.md b/plugin/commands/install.md index 1adaf7d..257c5ec 100644 --- a/plugin/commands/install.md +++ b/plugin/commands/install.md @@ -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 " session "` — 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 diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 68b9cf0..f4d9f89 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -12,6 +12,18 @@ ] } ], + "PostToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-read.sh\"", + "async": true + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/plugin/scripts/beardrive-read.sh b/plugin/scripts/beardrive-read.sh new file mode 100755 index 0000000..114266e --- /dev/null +++ b/plugin/scripts/beardrive-read.sh @@ -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 diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index b7ad61d..25fbadb 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -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 []` (`--forget` also unregisters) | | One sync cycle now | `bdrive sync []` | -| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install []` — 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 []` — 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 []` — 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 []` | | Change history | `bdrive log [] [-p path] [-n N]` | | This device's identity | `bdrive whoami` | @@ -130,20 +131,35 @@ conflict-copy ops keep their own `conflict copy of ` note. `bdrive hooks install []` 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) | `/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) | -| Codex (ChatGPT) | `/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once | -| Gemini CLI | `/.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) | `/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read) | +| Codex (ChatGPT) | `/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) / `PostToolUse` (read_file, best-effort) — user must `/hooks`-trust the layer once | +| Gemini CLI | `/.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 ` session `. Merging is idempotent and -preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides +and stamps changes with ` session `. 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//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