Files
beardrive/cmd/bdrive/readlog.go
T
Snow LeeandClaude Fable 5 2ce714ac4d feat(sync): agent read reporting — read-log, spool, hub report, hook matchers
Phase 3 of the read heatmap: `bdrive read-log` parses any platform's hook
event JSON from stdin and queues in-project file reads (mount-relative,
ignore/include-filtered) in a per-volume spool — no network on the hook
path. The sync cycle drains the spool best-effort to the hub's new
POST /api/p/<id>/reads via the remote ReadReporter capability (https
backend only); a failed report retries next cycle and never fails or
offlines the cycle. `bdrive hooks install` now registers a third hook per
platform on its read-tool matcher (claude Read, codex read_file
best-effort, gemini read_file|read_many_files, hermes read_file), each
idempotent on its own marker so sync-only configs upgrade in place. Agent
reads land as agent traffic in the heat view, actor = device id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-11 14:49:02 -07:00

132 lines
3.6 KiB
Go

package main
import (
"encoding/json"
"io"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
// read-log is the agent read hook's command: it runs after every file-read
// tool call, so it must be a fast, silent no-op in every case that isn't
// "a synced file was just read" — no network, no locking, one appended line.
func readLogCmd() *cobra.Command {
return &cobra.Command{
Use: "read-log [folder]",
Short: "Record agent file reads from a hook event (JSON on stdin)",
Long: `Record which project files an agent just read, from the hook event JSON
piped on stdin (any platform's PostToolUse-style payload: file paths are
found wherever they appear in the event).
Reads are queued locally in the volume store and drained to the hub on the
next sync, where they show up as agent traffic in the project's read heat.
Registered automatically by "bdrive hooks install"; there is rarely a reason
to run it by hand.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return nil
}
proj, found, err := config.ResolveMount(folder)
if err != nil || !found {
return nil // not a beardrive project: fast no-op
}
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
paths := extractEventPaths(data)
if len(paths) == 0 {
return nil
}
filter, err := syncer.LoadFilter(folder, proj.Include)
if err != nil {
return nil
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
return nil
}
st, err := store.Open(vdir)
if err != nil {
return nil
}
for _, p := range paths {
abs := p
if !filepath.IsAbs(abs) {
abs = filepath.Join(folder, p)
}
rel, err := filepath.Rel(folder, abs)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
continue // outside the mount
}
rel = filepath.ToSlash(rel)
if filter.Skip(rel) {
continue // not part of the project (ignore/include rules)
}
st.LogRead(rel) // best-effort; the hook must never fail the turn
}
return nil
},
}
}
// Keys that carry file paths in the hook payloads of the supported agent
// platforms (Claude Code tool_input.file_path, Gemini/Hermes read_file
// path/absolute_path, multi-read paths arrays).
var (
eventPathKeys = map[string]bool{"file_path": true, "absolute_path": true, "path": true, "notebook_path": true}
eventPathListKeys = map[string]bool{"paths": true, "file_paths": true}
)
// extractEventPaths pulls candidate file paths out of an arbitrary hook
// event JSON. The hook only fires on read-tool matchers, so any path-shaped
// field is a read; non-project paths are filtered by the caller.
func extractEventPaths(data []byte) []string {
var root any
if json.Unmarshal(data, &root) != nil {
return nil
}
seen := map[string]bool{}
var out []string
add := func(s string) {
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
var walk func(v any)
walk = func(v any) {
switch t := v.(type) {
case map[string]any:
for k, val := range t {
switch {
case eventPathKeys[k]:
if s, ok := val.(string); ok {
add(s)
}
case eventPathListKeys[k]:
if arr, ok := val.([]any); ok {
for _, it := range arr {
if s, ok := it.(string); ok {
add(s)
}
}
}
}
walk(val)
}
case []any:
for _, it := range t {
walk(it)
}
}
}
walk(root)
return out
}