A finding you have to remember to go and look at is a finding you do not see. This closes the loop: `fathom insights` reports findings for other programs, a Claude Code Stop hook runs it when a turn ends, and /insights shows the detail on request. The CLI reports as text, --json, or --jsonl, and --new returns only what has not been reported before. The server owns the reported-findings state, because two sessions can end at the same moment and a read-then-write would have both report the same thing. Two rules follow from running on every turn: - It must be fast. --hook asks a running server for an analysis already computed rather than doing one, and never falls back to analysing directly, which could take seconds. Measured at ~20ms whether or not a server is up. - It must never disrupt the session it observes. Every path exits 0 and prints at most one line. A Stop hook exiting 2 tells Claude Code to keep going rather than stop, so a crash here would hijack the session it was supposed to quietly watch. Surfacing is a higher bar than appearing in the tab, and biased towards silence. A tab is consulted; a notification arrives whether or not you wanted it, and the costs are asymmetric -- a missed finding waits patiently, a wrong nudge trains you to ignore all of them. Medium severity and $0.50 minimum, not already reported unless it has recurred since, and at most one nudge every two hours. A local model, if one is running, can veto a finding as not worth interrupting for. It can never promote one that did not clear the bar, is never consulted about findings below it, and a model failure keeps the finding rather than silencing it. Findings it rejects are recorded, so it is not asked the same question every turn. Also fixes a latent hazard this would have made routine. OpenShard runs tail recovery, which truncates, so a CLI opening a store the server is mid-append to could cut off the chunk being written -- the reader destroying the writer's data. Added a read-only open that skips recovery and opens O_RDONLY, and pointed every read-only command at it, including `fathom query`, which had the same problem already. The nudge's cost figure counted each finding separately, so a set of them could claim more money than the window ever spent: $12.52 against a window costing $7.94. Now counted once per trace. claude/ ships the skill and the hook snippet with an installer. The snippet is printed rather than applied, because a script should not blindly edit a settings file that may already have hooks in it, and a mangled settings.json silently disables everything in it.
300 lines
8.6 KiB
Go
300 lines
8.6 KiB
Go
package insights
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Surfacing decides what is worth interrupting someone about.
|
|
//
|
|
// This is a different and much higher bar than appearing in the UI. A tab is
|
|
// consulted; a notification arrives whether or not you wanted it, at the end of
|
|
// a turn, while you are thinking about something else. The cost of a wrong call
|
|
// is asymmetric — a missed finding waits patiently in the tab, while a wrong
|
|
// nudge trains you to ignore all of them — so everything here is biased towards
|
|
// staying quiet.
|
|
|
|
// SurfaceOptions tune the bar.
|
|
type SurfaceOptions struct {
|
|
// MinSeverity is the floor. Anything below is never surfaced, whatever a
|
|
// model thinks of it.
|
|
MinSeverity Severity
|
|
|
|
// MinCostUSD is a second floor, because severity alone rounds too coarsely
|
|
// at the bottom.
|
|
MinCostUSD float64
|
|
|
|
// Cooldown is the minimum gap between nudges, regardless of how many new
|
|
// findings appear. Several notifications in a row is how a feature like
|
|
// this gets turned off.
|
|
Cooldown time.Duration
|
|
}
|
|
|
|
func DefaultSurfaceOptions() SurfaceOptions {
|
|
return SurfaceOptions{
|
|
MinSeverity: SevMedium,
|
|
MinCostUSD: 0.50,
|
|
Cooldown: 2 * time.Hour,
|
|
}
|
|
}
|
|
|
|
// Surfaced is the record of what has already been reported.
|
|
type surfacedState struct {
|
|
// Seen maps insight id to the LastSeen it had when reported. A pattern that
|
|
// recurs after being reported becomes new again, which is the behaviour
|
|
// worth having: a habit you were told about and then kept doing is a
|
|
// stronger signal than the first occurrence was.
|
|
Seen map[string]int64 `json:"seen"`
|
|
|
|
// LastNudge is when the reader was last interrupted, for the cooldown.
|
|
LastNudge int64 `json:"last_nudge"`
|
|
}
|
|
|
|
// Nudge is what the hook reports.
|
|
type Nudge struct {
|
|
// Message is the line shown to the reader. Empty means say nothing.
|
|
Message string `json:"message,omitempty"`
|
|
|
|
// Insights are the findings that cleared the bar, most important first.
|
|
Insights []Insight `json:"insights,omitempty"`
|
|
|
|
// Suppressed explains why nothing is being shown, for debugging a hook
|
|
// that appears to do nothing. It is not shown to the reader.
|
|
Suppressed string `json:"suppressed,omitempty"`
|
|
}
|
|
|
|
func severityRank(s Severity) int {
|
|
switch s {
|
|
case SevHigh:
|
|
return 3
|
|
case SevMedium:
|
|
return 2
|
|
case SevLow:
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// SurfaceNew returns findings not yet reported, and records them.
|
|
//
|
|
// Returning and recording in one call is deliberate. Two sessions can end at
|
|
// the same moment, and a read-then-write would have both report the same
|
|
// finding.
|
|
func (a *Analyzer) SurfaceNew(opts SurfaceOptions) Nudge {
|
|
res, ready := a.Latest()
|
|
if !ready {
|
|
return Nudge{Suppressed: "no analysis has completed yet"}
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
state := a.loadSurfaced()
|
|
now := a.now()
|
|
|
|
if state.LastNudge > 0 && opts.Cooldown > 0 {
|
|
since := now.Sub(time.UnixMicro(state.LastNudge))
|
|
if since < opts.Cooldown {
|
|
return Nudge{Suppressed: fmt.Sprintf(
|
|
"within the %s cooldown (last nudge %s ago)",
|
|
opts.Cooldown.Round(time.Minute), since.Round(time.Minute))}
|
|
}
|
|
}
|
|
|
|
var candidates []Insight
|
|
var belowBar int
|
|
for _, th := range res.Themes {
|
|
for _, in := range th.Insights {
|
|
if severityRank(in.Severity) < severityRank(opts.MinSeverity) || in.CostUSD < opts.MinCostUSD {
|
|
belowBar++
|
|
continue
|
|
}
|
|
if when, ok := state.Seen[in.ID]; ok && in.LastSeen <= when {
|
|
continue // already reported, and it has not happened again since
|
|
}
|
|
candidates = append(candidates, in)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return Nudge{Suppressed: fmt.Sprintf(
|
|
"nothing new above the bar (%d finding(s) below it)", belowBar)}
|
|
}
|
|
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if severityRank(candidates[i].Severity) != severityRank(candidates[j].Severity) {
|
|
return severityRank(candidates[i].Severity) > severityRank(candidates[j].Severity)
|
|
}
|
|
return candidates[i].CostUSD > candidates[j].CostUSD
|
|
})
|
|
|
|
// A local model gets a veto, never a promotion. It can decide a finding
|
|
// that cleared the numeric bar is still not worth an interruption; it can
|
|
// never surface one that did not clear it. Detection and thresholds stay
|
|
// where they can be reasoned about.
|
|
if a.status.Enabled && a.judge != nil {
|
|
kept := candidates[:0]
|
|
for _, in := range candidates {
|
|
worth, reason := a.judgeWorth(in)
|
|
if worth {
|
|
kept = append(kept, in)
|
|
continue
|
|
}
|
|
_ = reason
|
|
}
|
|
if len(kept) == 0 {
|
|
// Record them anyway. The model said these are not worth an
|
|
// interruption; asking it again about the same findings on every
|
|
// turn would burn time to reach the same answer.
|
|
a.recordSurfaced(state, candidates, now, false)
|
|
return Nudge{Suppressed: "the local model judged these not worth interrupting for"}
|
|
}
|
|
candidates = kept
|
|
}
|
|
|
|
a.recordSurfaced(state, candidates, now, true)
|
|
|
|
return Nudge{
|
|
Message: nudgeMessage(candidates),
|
|
Insights: candidates,
|
|
}
|
|
}
|
|
|
|
// nudgeMessage writes the line the reader sees.
|
|
//
|
|
// Deliberately templated rather than model-written. It is the one piece of text
|
|
// that arrives unbidden, so it should read the same way every time and say
|
|
// exactly what it can support: what was found and where to look.
|
|
func nudgeMessage(ins []Insight) string {
|
|
const call = "Run /insights to learn more."
|
|
|
|
if len(ins) == 1 {
|
|
what := ins[0].Label
|
|
if what == "" {
|
|
what = strings.TrimSuffix(ins[0].Title, ".")
|
|
}
|
|
return fmt.Sprintf("You've got insights on your Claude usage — %s (%s). %s",
|
|
what, money(ins[0].CostUSD), call)
|
|
}
|
|
|
|
return fmt.Sprintf("You've got insights on your Claude usage — %d new findings covering %s. %s",
|
|
len(ins), money(coveredCost(ins)), call)
|
|
}
|
|
|
|
// coveredCost is the spend the findings actually speak to, counted once per
|
|
// trace.
|
|
//
|
|
// Summing each finding's cost double-counts, because several findings routinely
|
|
// name the same traces — one run can be both a repeated sequence and a repeated
|
|
// ask. Naively summed, a set of findings can claim more money than was spent in
|
|
// the whole window, which is exactly what the first version of this message
|
|
// did: "$12.52" against a window that cost $7.94.
|
|
func coveredCost(ins []Insight) float64 {
|
|
perTrace := map[string]float64{}
|
|
for _, in := range ins {
|
|
for _, e := range in.Evidence {
|
|
if e.CostUSD > perTrace[e.TraceID] {
|
|
perTrace[e.TraceID] = e.CostUSD
|
|
}
|
|
}
|
|
}
|
|
var total float64
|
|
for _, c := range perTrace {
|
|
total += c
|
|
}
|
|
// A finding with no evidence cannot be attributed to a trace; fall back to
|
|
// its own figure rather than reporting nothing.
|
|
if total == 0 {
|
|
for _, in := range ins {
|
|
total += in.CostUSD
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// judgeWorth asks the local model whether a finding justifies an interruption.
|
|
func (a *Analyzer) judgeWorth(in Insight) (bool, string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
defer cancel()
|
|
|
|
worth, reason, err := a.judge.Worth(ctx, JudgeInput{
|
|
Title: in.Title,
|
|
Body: in.Body,
|
|
Occurrences: in.Occurrences,
|
|
CostUSD: in.CostUSD,
|
|
})
|
|
if err != nil {
|
|
// A model that is slow, missing, or confused must not silence a finding
|
|
// that already cleared every numeric bar. Failure keeps it.
|
|
return true, "model unavailable"
|
|
}
|
|
return worth, reason
|
|
}
|
|
|
|
// JudgeInput is what the model is shown. It gets the finding's own words and
|
|
// numbers and nothing else — no traces, no prompts, no file contents.
|
|
type JudgeInput struct {
|
|
Title string
|
|
Body string
|
|
Occurrences int
|
|
CostUSD float64
|
|
}
|
|
|
|
// Judge decides whether a finding is worth interrupting for.
|
|
type Judge interface {
|
|
Worth(ctx context.Context, in JudgeInput) (bool, string, error)
|
|
}
|
|
|
|
func (a *Analyzer) loadSurfaced() *surfacedState {
|
|
st := &surfacedState{Seen: map[string]int64{}}
|
|
if a.surfacedPath == "" {
|
|
if a.surfaced != nil {
|
|
return a.surfaced
|
|
}
|
|
a.surfaced = st
|
|
return st
|
|
}
|
|
b, err := os.ReadFile(a.surfacedPath)
|
|
if err != nil {
|
|
return st
|
|
}
|
|
if err := json.Unmarshal(b, st); err != nil || st.Seen == nil {
|
|
return &surfacedState{Seen: map[string]int64{}}
|
|
}
|
|
return st
|
|
}
|
|
|
|
func (a *Analyzer) recordSurfaced(state *surfacedState, ins []Insight, now time.Time, nudged bool) {
|
|
for _, in := range ins {
|
|
state.Seen[in.ID] = in.LastSeen
|
|
}
|
|
if nudged {
|
|
state.LastNudge = now.UnixMicro()
|
|
}
|
|
a.surfaced = state
|
|
|
|
if a.surfacedPath == "" {
|
|
return
|
|
}
|
|
b, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmp := a.surfacedPath + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
|
return
|
|
}
|
|
os.Rename(tmp, a.surfacedPath)
|
|
}
|
|
|
|
// ResetSurfaced forgets what has been reported, so everything is new again.
|
|
func (a *Analyzer) ResetSurfaced() {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.recordSurfaced(&surfacedState{Seen: map[string]int64{}}, nil, a.now(), false)
|
|
}
|