mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
2ce714ac4d
commit
e3ca821dc4
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +12,18 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-read.sh\"",
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
Executable
+10
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user