diff --git a/README.md b/README.md index 32ad69f..ddf25b3 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Whittle's second surface: a local proxy on `ANTHROPIC_BASE_URL` that sends each - **Multi-signal, not keyword-matching**: a trained 14-subject classifier (probability-mass thresholded, so an *uncertain* classification never escalates), a contrastive difficulty score, and your own keywords. Every log line shows each signal's value against its gate. - **Rewrites the model, never your history**: prompt-cache prefixes survive; capabilities the cheaper model rejects are stripped automatically; credentials pass through untouched. - **Never blocks you**: bad policy, dead classifier, or a rejected rewrite all fall back to your original request. Unset the env var and you're direct again. -- **Savings you can measure**: every request logs requested model, served model, and real token usage. +- **Savings you can measure**: every request logs requested model, served model, and real token usage. `whittle watch` streams both feeds live (routing verdicts + compression carves) in one view. The router itself (engine, policy design, signal composition) is whittle's own; two pretrained models power its ML signals (the 14-subject `domain` classifier and the text embedder, both from [vLLM Semantic Router](https://github.com/vllm-project/semantic-router)). Architecture, signal math, and precise credits: [docs/ROUTER.md](docs/ROUTER.md). diff --git a/cmd/whittle/main.go b/cmd/whittle/main.go index 2698356..4c51056 100644 --- a/cmd/whittle/main.go +++ b/cmd/whittle/main.go @@ -66,6 +66,8 @@ func main() { cmdStatus(os.Args[2:]) case "stats": cmdStats(os.Args[2:]) + case "watch": + cmdWatch(os.Args[2:]) case "mcp": cmdMCP(os.Args[2:]) case "hook": @@ -86,6 +88,7 @@ usage: background service (launchd) - one command whittle status health of router, sidecar, hook whittle stats [-days 7] local savings report (tokens whittled) + whittle watch [-n 8] [-plain] live feed: router verdicts + compression carves whittle stop stop background services whittle cleanup stop + remove hook + unregister service whittle compress [flags] [file] compress a file (or stdin) to stdout diff --git a/cmd/whittle/watch.go b/cmd/whittle/watch.go new file mode 100644 index 0000000..8b72618 --- /dev/null +++ b/cmd/whittle/watch.go @@ -0,0 +1,366 @@ +package main + +// whittle watch — a live, unified view of both event streams: the model +// router's per-request verdicts (~/.whittle/logs/router.log, written by the +// background service) and the compression hook's per-carve records +// (~/.whittle/stats.jsonl). One feed, chips per event, the same visual +// vocabulary as `whittle stats`. +// +// The follower is poll-based (no fsnotify dependency), tolerant of files that +// do not exist yet (it waits for them), and rotation-safe (a shrunk or replaced +// file is reopened from the start). Foreground `whittle route` prints to the +// terminal rather than the log file; watch shows the background service. + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +func cmdWatch(args []string) { + fs := flag.NewFlagSet("watch", flag.ExitOnError) + backlog := fs.Int("n", 8, "recent events to show before following") + plain := fs.Bool("plain", false, "no color (also honors NO_COLOR)") + dir := fs.String("dir", whittleHome(), "whittle home to watch") + _ = fs.Parse(args) + + pal := newPalette(*plain || os.Getenv("NO_COLOR") != "") + routerLog := filepath.Join(*dir, "logs", "router.log") + statsLog := filepath.Join(*dir, "stats.jsonl") + + fmt.Printf("%s🪓 whittle%s %s· watching routes + carves · ^C to stop%s\n", pal.bold, pal.reset, pal.dim, pal.reset) + for _, p := range []string{routerLog, statsLog} { + if _, err := os.Stat(p); err != nil { + fmt.Printf("%s waiting for %s (created on first event)%s\n", pal.dim, p, pal.reset) + } + } + + rf := newFollower(routerLog) + sf := newFollower(statsLog) + for _, ev := range backlogEvents(rf, sf, *backlog, pal) { + fmt.Println(ev) + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + tick := time.NewTicker(300 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-sig: + return + case <-tick.C: + for _, l := range rf.readNew() { + if out := renderRouterLine(l, pal); out != "" { + fmt.Println(out) + } + } + for _, l := range sf.readNew() { + if out := renderCarveLine(l, pal); out != "" { + fmt.Println(out) + } + } + } + } +} + +// ---- palette ----------------------------------------------------------------- + +type palette struct { + bold, dim, italic, green, cyan, blue, red, reset string +} + +func newPalette(plain bool) palette { + if plain { + return palette{} + } + return palette{ + bold: "\x1b[1m", dim: "\x1b[2m", italic: "\x1b[3m", + green: "\x1b[32m", cyan: "\x1b[36m", blue: "\x1b[34m", red: "\x1b[31m", + reset: "\x1b[0m", + } +} + +// ---- follower ------------------------------------------------------------------ + +// follower tails one file: readNew returns complete new lines since the last +// call. A missing file yields nothing until it appears; truncation or +// replacement (rotation) reopens from the start. A trailing partial line is +// buffered until its newline arrives. +type follower struct { + path string + offset int64 + partial string + primed bool // first successful open seeks to end unless backlog consumed it +} + +func newFollower(path string) *follower { return &follower{path: path} } + +func (f *follower) readNew() []string { + st, err := os.Stat(f.path) + if err != nil { + return nil + } + if !f.primed { + // First sighting and no backlog was taken: start at the end, live-only. + f.offset = st.Size() + f.primed = true + return nil + } + if st.Size() < f.offset { + f.offset = 0 // rotated or truncated: start over + f.partial = "" + } + if st.Size() == f.offset { + return nil + } + file, err := os.Open(f.path) + if err != nil { + return nil + } + defer file.Close() + if _, err := file.Seek(f.offset, io.SeekStart); err != nil { + return nil + } + data, err := io.ReadAll(io.LimitReader(file, 4<<20)) + if err != nil { + return nil + } + f.offset += int64(len(data)) + text := f.partial + string(data) + lines := strings.Split(text, "\n") + f.partial = lines[len(lines)-1] // "" when data ended in \n + return lines[:len(lines)-1] +} + +// prime marks the follower as started at its current end minus consumed backlog. +func (f *follower) prime(offset int64) { f.offset = offset; f.primed = true } + +// backlogEvents renders the last n events across both files (by timestamp when +// both are available) and primes the followers at end-of-file. +func backlogEvents(rf, sf *follower, n int, pal palette) []string { + type ev struct { + ts int64 + text string + } + var events []ev + if lines, size := lastLines(rf.path, n); true { + rf.prime(size) + for _, l := range lines { + if out := renderRouterLine(l, pal); out != "" { + events = append(events, ev{routerLineTS(l), out}) + } + } + } + if lines, size := lastLines(sf.path, n); true { + sf.prime(size) + for _, l := range lines { + if out := renderCarveLine(l, pal); out != "" { + events = append(events, ev{carveLineTS(l), out}) + } + } + } + // insertion sort by ts (n is small); zero-ts lines keep relative order at front + for i := 1; i < len(events); i++ { + for j := i; j > 0 && events[j].ts < events[j-1].ts; j-- { + events[j], events[j-1] = events[j-1], events[j] + } + } + if len(events) > n { + events = events[len(events)-n:] + } + out := make([]string, len(events)) + for i, e := range events { + out[i] = e.text + } + return out +} + +// lastLines returns up to n final complete lines of path and the file size. +func lastLines(path string, n int) ([]string, int64) { + f, err := os.Open(path) + if err != nil { + return nil, 0 + } + defer f.Close() + var lines []string + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 1<<20), 1<<20) + var size int64 + for sc.Scan() { + lines = append(lines, sc.Text()) + size += int64(len(sc.Bytes())) + 1 + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines, size +} + +// ---- router line rendering ------------------------------------------------------ + +type routerEvent struct { + Tier string `json:"tier"` + Requested string `json:"requested"` + Model string `json:"model"` + Reason string `json:"reason"` + Signals string `json:"signals"` + Status int `json:"status"` + LatencyMS int64 `json:"latency_ms"` + CtxTokens int `json:"ctx_tokens"` + InTokens int `json:"in_tokens"` + OutTokens int `json:"out_tokens"` +} + +// splitRouterLine separates the stdlib log prefix ("2026/07/10 12:54:33 ") from +// the JSON payload. Returns clock ("12:54:33", may be empty) and the rest. +func splitRouterLine(line string) (clock, rest string) { + rest = line + if len(line) > 20 && line[4] == '/' && line[7] == '/' && line[10] == ' ' && line[19] == ' ' { + return line[11:19], line[20:] + } + return "", rest +} + +func renderRouterLine(line string, pal palette) string { + if strings.TrimSpace(line) == "" { + return "" + } + clock, rest := splitRouterLine(line) + if !strings.HasPrefix(rest, "{") { + // startup / warning lines: pass through dim + return fmt.Sprintf("%s%s · %s%s", pal.dim, clock, rest, pal.reset) + } + var e routerEvent + if json.Unmarshal([]byte(rest), &e) != nil { + return fmt.Sprintf("%s%s · %s%s", pal.dim, clock, rest, pal.reset) + } + + chipColor := pal.dim + switch { + case e.Tier == "-" || e.Model == e.Requested: // no-op / passthrough + chipColor = pal.dim + case strings.Contains(e.Model, "haiku"): + chipColor = pal.green + case strings.Contains(e.Model, "sonnet"): + chipColor = pal.cyan + case strings.Contains(e.Model, "opus"): + chipColor = pal.blue + } + move := fmt.Sprintf("%s%s%s", pal.dim, family(e.Requested), pal.reset) + if family(e.Model) != family(e.Requested) { + move = fmt.Sprintf("%s%s%s→%s%s%s%s", pal.dim, family(e.Requested), pal.reset, pal.bold, chipColor, family(e.Model), pal.reset) + } else { + move += fmt.Sprintf("%s · kept%s", pal.dim, pal.reset) + } + status := "" + if e.Status != 200 { + status = fmt.Sprintf(" %s%d%s", pal.red, e.Status, pal.reset) + } + sig := "" + if e.Signals != "" { + sig = fmt.Sprintf(" %s%s%s", pal.dim, e.Signals, pal.reset) + } + return fmt.Sprintf("%s%s%s %s▸%s %-18s %s%s %s%s tok · %dms%s%s", + pal.dim, clock, pal.reset, + chipColor, pal.reset, + strings.TrimPrefix(strings.SplitN(e.Reason, " ", 2)[0], "route:"), + move, status, + pal.dim, fmtInt(e.CtxTokens), e.LatencyMS, pal.reset, sig) +} + +// routerLineTS extracts a sortable timestamp (unix-ish) from the log prefix. +func routerLineTS(line string) int64 { + if len(line) > 19 { + if t, err := time.ParseInLocation("2006/01/02 15:04:05", line[:19], time.Local); err == nil { + return t.Unix() + } + } + return 0 +} + +func family(model string) string { + for _, f := range []string{"haiku", "sonnet", "opus"} { + if strings.Contains(model, f) { + return f + } + } + if model == "" || model == "-" { + return "-" + } + return model +} + +// ---- carve line rendering --------------------------------------------------------- + +type carveEvent struct { + TS int64 `json:"ts"` + Tool string `json:"tool"` + Strategy string `json:"strategy"` + In int `json:"in_tokens"` + Out int `json:"out_tokens"` +} + +func renderCarveLine(line string, pal palette) string { + if strings.TrimSpace(line) == "" { + return "" + } + var e carveEvent + if json.Unmarshal([]byte(line), &e) != nil || e.Strategy == "" { + return "" + } + clock := time.Unix(e.TS, 0).Format("15:04:05") + if e.Strategy == "retrieve" { + return fmt.Sprintf("%s%s ↩ retrieve · original served back on demand%s", pal.dim, clock, pal.reset) + } + pct := 0 + if e.In > 0 { + pct = int((1 - float64(e.Out)/float64(e.In)) * 100) + } + tool := e.Tool + if tool == "" { + tool = "output" + } + return fmt.Sprintf("%s%s%s 🪓 %-18s %s%s%s → %s%s%s tok %s%s−%d%%%s %s%s%s", + pal.dim, clock, pal.reset, + tool, + pal.dim, fmtInt(e.In), pal.reset, + pal.green, fmtInt(e.Out), pal.reset, + pal.bold, pal.green, pct, pal.reset, + pal.dim, e.Strategy, pal.reset) +} + +func carveLineTS(line string) int64 { + var e carveEvent + if json.Unmarshal([]byte(line), &e) == nil { + return e.TS + } + return 0 +} + +func fmtInt(n int) string { + s := fmt.Sprintf("%d", n) + if n < 0 || len(s) <= 3 { + return s + } + var b strings.Builder + pre := len(s) % 3 + if pre > 0 { + b.WriteString(s[:pre]) + } + for i := pre; i < len(s); i += 3 { + if b.Len() > 0 { + b.WriteByte(',') + } + b.WriteString(s[i : i+3]) + } + return b.String() +} diff --git a/cmd/whittle/watch_test.go b/cmd/whittle/watch_test.go new file mode 100644 index 0000000..996032a --- /dev/null +++ b/cmd/whittle/watch_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +var plain = newPalette(true) + +func TestRenderRouterLine(t *testing.T) { + // A real down-route verdict renders reason, family move, tokens, signals. + line := `2026/07/10 01:22:07 {"tier":"fast","requested":"claude-opus-4-8","model":"claude-haiku-4-5-20251001","reason":"route:casual-easy stripped:context-1m","signals":"cplx:reasoning=-0.273 dom=other@0.995","status":200,"latency_ms":3896,"ctx_tokens":52848,"in_tokens":2225,"out_tokens":158,"session":"3db8cd74"}` + out := renderRouterLine(line, plain) + for _, want := range []string{"01:22:07", "casual-easy", "opus→haiku", "52,848 tok", "3896ms", "dom=other@0.995"} { + if !strings.Contains(out, want) { + t.Errorf("router render missing %q in %q", want, out) + } + } + + // A no-op keeps the family with a "kept" marker, no arrow. + noop := `2026/07/10 12:54:33 {"tier":"-","requested":"claude-opus-4-8","model":"claude-opus-4-8","reason":"no-op:default:requested","signals":"","status":200,"latency_ms":2214,"ctx_tokens":52846,"in_tokens":3169,"out_tokens":20,"session":"x"}` + out = renderRouterLine(noop, plain) + if !strings.Contains(out, "kept") || strings.Contains(out, "→") { + t.Errorf("no-op should render kept without an arrow: %q", out) + } + + // Non-JSON startup lines pass through dim, never dropped. + out = renderRouterLine("2026/07/10 12:54:20 router: smart mode ON (classifier sidecar = http://127.0.0.1:45872)", plain) + if !strings.Contains(out, "smart mode ON") { + t.Errorf("startup lines must pass through: %q", out) + } + + // Non-200 statuses are surfaced. + bad := `2026/07/10 01:00:00 {"tier":"main","requested":"claude-opus-4-8","model":"claude-sonnet-4-5","reason":"default","signals":"","status":429,"latency_ms":601,"ctx_tokens":78,"in_tokens":0,"out_tokens":0,"session":"x"}` + if out = renderRouterLine(bad, plain); !strings.Contains(out, "429") { + t.Errorf("non-200 status must be visible: %q", out) + } +} + +func TestRenderCarveLine(t *testing.T) { + line := `{"id":1,"in_tokens":1107,"out_tokens":145,"session":"s","strategy":"ansi_strip+log_compressor","tool":"Bash","ts":1783666113}` + out := renderCarveLine(line, plain) + for _, want := range []string{"🪓", "Bash", "1,107", "145", "−86%", "ansi_strip+log_compressor"} { + if !strings.Contains(out, want) { + t.Errorf("carve render missing %q in %q", want, out) + } + } + // Retrievals render as their own event type. + if out = renderCarveLine(`{"strategy":"retrieve","ts":1783666113}`, plain); !strings.Contains(out, "retrieve") { + t.Errorf("retrieve event missing: %q", out) + } + // Junk lines are dropped silently. + if out = renderCarveLine("not json", plain); out != "" { + t.Errorf("junk should render empty, got %q", out) + } +} + +// The follower delivers only complete new lines, buffers partials, and +// recovers from truncation (rotation). +func TestFollower(t *testing.T) { + p := filepath.Join(t.TempDir(), "log") + f := newFollower(p) + + if got := f.readNew(); got != nil { + t.Fatalf("missing file should yield nil, got %v", got) + } + os.WriteFile(p, []byte("old1\nold2\n"), 0o644) + if got := f.readNew(); len(got) != 0 { + t.Fatalf("first sighting must start at end (live-only), got %v", got) + } + // Append a complete line and a partial one. + fh, _ := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644) + fh.WriteString("new1\npart") + fh.Close() + if got := f.readNew(); len(got) != 1 || got[0] != "new1" { + t.Fatalf("want [new1], got %v", got) + } + // Complete the partial. + fh, _ = os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644) + fh.WriteString("ial\n") + fh.Close() + if got := f.readNew(); len(got) != 1 || got[0] != "partial" { + t.Fatalf("partial line must be joined, got %v", got) + } + // Rotation: replace with a smaller file; follower restarts from zero. + os.WriteFile(p, []byte("fresh\n"), 0o644) + if got := f.readNew(); len(got) != 1 || got[0] != "fresh" { + t.Fatalf("rotation must reopen from start, got %v", got) + } +} + +func TestBacklogMergesByTime(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "logs"), 0o755) + rp := filepath.Join(dir, "logs", "router.log") + sp := filepath.Join(dir, "stats.jsonl") + // carve at 10:00:00 local-equivalent unix; route at a later wall clock + os.WriteFile(rp, []byte(`2026/07/10 23:59:59 {"tier":"fast","requested":"claude-opus-4-8","model":"claude-haiku-4-5","reason":"route:casual-easy","signals":"","status":200,"latency_ms":10,"ctx_tokens":5,"in_tokens":1,"out_tokens":1,"session":"x"}`+"\n"), 0o644) + os.WriteFile(sp, []byte(`{"in_tokens":100,"out_tokens":10,"strategy":"log_compressor","tool":"Bash","ts":1}`+"\n"), 0o644) + events := backlogEvents(newFollower(rp), newFollower(sp), 8, plain) + if len(events) != 2 { + t.Fatalf("want 2 backlog events, got %d: %v", len(events), events) + } + if !strings.Contains(events[0], "🪓") || !strings.Contains(events[1], "casual-easy") { + t.Errorf("expected carve (ts=1) before route (2026), got %v", events) + } +} + +func TestFmtInt(t *testing.T) { + for n, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 52848: "52,848", 1234567: "1,234,567"} { + if got := fmtInt(n); got != want { + t.Errorf("fmtInt(%d)=%q want %q", n, got, want) + } + } +} diff --git a/docs/POLICY.md b/docs/POLICY.md index a4d3631..27811ba 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -505,7 +505,7 @@ routing), never a 400. A pin never writes the session's tracked tier. it goes to `~/.whittle/logs/router.log`; in the foreground it goes to stderr. ```sh - tail -f ~/.whittle/logs/router.log + whittle watch # or raw: tail -f ~/.whittle/logs/router.log ``` ## Reading the log's `signals` field