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.
132 lines
3.3 KiB
Go
132 lines
3.3 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// BlobFile is a shard's append-only sidecar for large text.
|
|
//
|
|
// Prompts, tool payloads, and model responses are the bulk of a trace by bytes
|
|
// and are needed only when someone opens that one trace. Keeping them out of
|
|
// the columnar chunks means a scan over a day of spans touches kilobytes of
|
|
// timestamps and dictionary codes instead of megabytes of prose. The columnar
|
|
// side stores only an offset and a length.
|
|
type BlobFile struct {
|
|
path string
|
|
mu sync.Mutex
|
|
f *os.File
|
|
size int64
|
|
readOnly bool
|
|
}
|
|
|
|
// OpenBlobFileReadOnly opens a blob sidecar for reading only.
|
|
//
|
|
// A missing sidecar is not an error here: a shard whose rows all had empty
|
|
// input and output never creates one, and a reader must not fail on that.
|
|
func OpenBlobFileReadOnly(path string) (*BlobFile, error) {
|
|
f, err := os.Open(path)
|
|
if os.IsNotExist(err) {
|
|
return &BlobFile{path: path, readOnly: true}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return &BlobFile{path: path, f: f, size: st.Size(), readOnly: true}, nil
|
|
}
|
|
|
|
func OpenBlobFile(path string) (*BlobFile, error) {
|
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return &BlobFile{path: path, f: f, size: st.Size()}, nil
|
|
}
|
|
|
|
// Append writes s and returns a reference to it. An empty string gets the zero
|
|
// reference and costs no bytes, which matters because most spans carry neither
|
|
// input nor output.
|
|
func (b *BlobFile) Append(s string) (BlobRef, error) {
|
|
if s == "" {
|
|
return BlobRef{}, nil
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.readOnly {
|
|
return BlobRef{}, fmt.Errorf("blob file %s is open read-only", b.path)
|
|
}
|
|
|
|
off := b.size
|
|
n, err := b.f.WriteString(s)
|
|
if err != nil {
|
|
// A partial write leaves the file longer than our accounting. Trust the
|
|
// file: a later Append must not hand out an offset that overlaps these
|
|
// bytes, even though this blob is now unreferenced garbage.
|
|
b.size += int64(n)
|
|
return BlobRef{}, err
|
|
}
|
|
b.size += int64(n)
|
|
return BlobRef{Off: off, Len: uint32(n)}, nil
|
|
}
|
|
|
|
// Read resolves a reference. Callers get an error rather than a panic on a
|
|
// reference that points past the file, since a damaged chunk can produce one.
|
|
func (b *BlobFile) Read(ref BlobRef) (string, error) {
|
|
if ref.Len == 0 {
|
|
return "", nil
|
|
}
|
|
b.mu.Lock()
|
|
size := b.size
|
|
b.mu.Unlock()
|
|
|
|
if b.f == nil {
|
|
return "", fmt.Errorf("blob file %s was never created", b.path)
|
|
}
|
|
if ref.Off < 0 || ref.Off+int64(ref.Len) > size {
|
|
return "", fmt.Errorf("blob ref %d+%d runs past the %d-byte file %s", ref.Off, ref.Len, size, b.path)
|
|
}
|
|
buf := make([]byte, ref.Len)
|
|
if _, err := b.f.ReadAt(buf, ref.Off); err != nil {
|
|
return "", err
|
|
}
|
|
return string(buf), nil
|
|
}
|
|
|
|
// Sync flushes blob bytes. The shard calls this before syncing its own chunk,
|
|
// so a chunk on disk never references blob bytes that are not.
|
|
func (b *BlobFile) Sync() error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.f == nil || b.readOnly {
|
|
return nil
|
|
}
|
|
return b.f.Sync()
|
|
}
|
|
|
|
func (b *BlobFile) Size() int64 {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.size
|
|
}
|
|
|
|
func (b *BlobFile) Close() error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.f == nil {
|
|
return nil
|
|
}
|
|
return b.f.Close()
|
|
}
|