mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
132 lines
3.6 KiB
Go
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
|
|
}
|