From 7ae7f670ff13c5ab42c314bbe27e80f607130f2e Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:16:58 -0600 Subject: [PATCH] So Many Crows v1: Sentry triage, crows, guardrails, trust ledger Go command-line tool. Sentry classifies each todo item as solo, split, or murder; text-only crows fly under budget, timeout, and flock caps; trust is earned per crow and per task type; abusive requesters get a mild, fading cooldown. Runs offline with a mock brain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF --- .gitignore | 5 + README.md | 56 ++++++++++ brain.go | 141 +++++++++++++++++++++++++ flightlog.go | 52 +++++++++ flock.go | 217 ++++++++++++++++++++++++++++++++++++++ go.mod | 3 + guardrails.go | 38 +++++++ ledger.go | 197 ++++++++++++++++++++++++++++++++++ main.go | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++ todo.txt | 4 + 10 files changed, 999 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 brain.go create mode 100644 flightlog.go create mode 100644 flock.go create mode 100644 go.mod create mode 100644 guardrails.go create mode 100644 ledger.go create mode 100644 main.go create mode 100644 todo.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..518d877 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.crows/ +/crows +*.crows.md +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b7ab5 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# So Many Crows + +A flock of AI agents. Hand it a todo list; a Sentry crow triages each item, crows fly, and trust is earned over time. + +Version one: command-line, text-only crows. No web, files, or APIs until each has guardrails. + +## Run + +``` +go build -o crows . +./crows run todo.txt # offline mock brain (no key needed) +ANTHROPIC_API_KEY=... ./crows run todo.txt # real crows (Claude) +./crows trust # trust ledger +./crows log -n 20 # flight log +``` + +Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; state lives in `.crows/`. + +## How an item flies + +1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently). +2. Split and murder results go to the **Elder**, who merges them and flags disagreement. +3. You approve or reject, depending on trust. + +## Trust ladder + +| Level | Triage | Output | +|----------|-------------------|--------------------------------| +| Stranger | you approve | you approve | +| Regular | you approve | auto-accepted, marked unreviewed | +| Trusted | runs on its own | runs on its own | + +- Promotion: 10 clean approvals in a row (the flock as a whole needs 20). +- Demotion: one rejection drops a level. +- A crow's level blends its own record with the flock's record for that task type, so new crows start on the flock's reputation. +- The Sentry earns triage trust separately, per task type. +- The least-trusted crow on a job sets the approval bar. + +## Guardrails (`guardrails.go`) + +| Guardrail | Default | +|---|---| +| Crows in flight | 5 | +| Murder size | 3 | +| Items per list | 20 | +| Subtasks per split (depth 1, never re-split) | 4 | +| Tokens per item / per list | 20,000 / 100,000 | +| Crow timeout | 60s | +| Requests per minute per requester | 5 | +| Cooldown | 30s, doubling per strike, max 1h | + +Fail-safe: any answer other than `y`, including no input, is a no. + +## Abuse + +Flooding the flock earns a mild prank message and a cooldown. Strikes fade one per clean day. Nothing destructive, nothing public. diff --git a/brain.go b/brain.go new file mode 100644 index 0000000..9a51331 --- /dev/null +++ b/brain.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// Brain is whatever model the crows think with. +type Brain interface { + Name() string + Ask(ctx context.Context, system, prompt string) (text string, tokens int, err error) +} + +// NewBrain uses Claude when ANTHROPIC_API_KEY is set, otherwise an offline mock. +func NewBrain() Brain { + key := os.Getenv("ANTHROPIC_API_KEY") + if key == "" { + return MockBrain{} + } + model := os.Getenv("CROWS_MODEL") + if model == "" { + model = "claude-sonnet-5-5" + } + return &AnthropicBrain{key: key, model: model, client: &http.Client{}} +} + +type AnthropicBrain struct { + key, model string + client *http.Client +} + +func (b *AnthropicBrain) Name() string { return "Claude (" + b.model + ")" } + +func (b *AnthropicBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + body, _ := json.Marshal(map[string]any{ + "model": b.model, + "max_tokens": 2048, + "system": system, + "messages": []map[string]string{{"role": "user", "content": prompt}}, + }) + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req.Header.Set("x-api-key", b.key) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("content-type", "application/json") + + resp, err := b.client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("model API returned %d: %s", resp.StatusCode, raw) + } + var out struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", 0, err + } + var sb strings.Builder + for _, c := range out.Content { + if c.Type == "text" { + sb.WriteString(c.Text) + } + } + return sb.String(), out.Usage.InputTokens + out.Usage.OutputTokens, nil +} + +// MockBrain lets the whole flow run offline with no API key. +type MockBrain struct{} + +func (MockBrain) Name() string { return "offline mock" } + +func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + if err := ctx.Err(); err != nil { + return "", 0, err + } + tokens := (len(system)+len(prompt))/4 + 40 + switch { + case strings.Contains(system, markerSentry): + return mockTriage(after(prompt, "ITEM:")), tokens, nil + case strings.Contains(system, markerElder): + return "[mock] Elder's reconciled answer from " + fmt.Sprint(strings.Count(prompt, "--- ")) + " reports.", tokens, nil + default: + return "[mock] Handled: " + after(prompt, "TASK:"), tokens, nil + } +} + +func after(s, marker string) string { + if i := strings.LastIndex(s, marker); i >= 0 { + return strings.TrimSpace(s[i+len(marker):]) + } + return strings.TrimSpace(s) +} + +func mockTriage(item string) string { + l := strings.ToLower(item) + tt := "other" + switch { + case hasAny(l, "find", "look up", "what is", "who ", "when "): + tt = "retrieval" + case hasAny(l, "write", "draft"): + tt = "writing" + case hasAny(l, "compare", "analyze", "should"): + tt = "analysis" + } + t := Triage{Mode: "solo", TaskType: tt, Reason: "simple, one crow can handle it"} + if parts := strings.Split(item, " and "); len(parts) > 1 { + t = Triage{Mode: "split", TaskType: tt, Subtasks: parts, Reason: "multiple distinct parts"} + } else if hasAny(l, "should", "decide", "best") { + t = Triage{Mode: "murder", TaskType: tt, Reason: "judgment call, worth several opinions"} + } + b, _ := json.Marshal(t) + return string(b) +} + +func hasAny(s string, subs ...string) bool { + for _, x := range subs { + if strings.Contains(s, x) { + return true + } + } + return false +} diff --git a/flightlog.go b/flightlog.go new file mode 100644 index 0000000..b6cdcb8 --- /dev/null +++ b/flightlog.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// Event is one line in the flight log: every triage, flight and verdict. +type Event struct { + Time time.Time `json:"ts"` + Kind string `json:"kind"` + Requester string `json:"requester,omitempty"` + Item string `json:"item,omitempty"` + Crow string `json:"crow,omitempty"` + Mode string `json:"mode,omitempty"` + TaskType string `json:"task_type,omitempty"` + Tokens int `json:"tokens,omitempty"` + Ms int64 `json:"ms,omitempty"` + Verdict string `json:"verdict,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Note string `json:"note,omitempty"` +} + +type FlightLog struct { + mu sync.Mutex + f *os.File +} + +func OpenFlightLog(path string) (*FlightLog, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, err + } + return &FlightLog{f: f}, nil +} + +func (l *FlightLog) Write(e Event) { + l.mu.Lock() + defer l.mu.Unlock() + e.Time = time.Now() + b, _ := json.Marshal(e) + l.f.Write(append(b, '\n')) +} + +func (l *FlightLog) Close() error { return l.f.Close() } diff --git a/flock.go b/flock.go new file mode 100644 index 0000000..fa01e10 --- /dev/null +++ b/flock.go @@ -0,0 +1,217 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +const ( + markerSentry = "You are the Sentry" + markerElder = "You are the Elder" + + sentrySystem = markerSentry + ` of a flock of AI agents called crows. +Classify one todo item. Reply with JSON only: +{"mode":"solo|split|murder","task_type":"retrieval|writing|analysis|other","subtasks":["..."],"reason":"one short sentence"} +- solo: simple; one crow can answer it. +- split: large; break into 2-4 independent subtasks (fill "subtasks"). +- murder: ambiguous or high-stakes; several crows answer independently, then reconcile. +Crows are text-only: no web, no files, no tools.` + + crowSystem = `You are a crow in a flock of AI agents. Complete the task concisely. +You are text-only: no web, no files, no tools. If the task needs something you cannot do, say so plainly instead of guessing.` + + elderSystem = markerElder + ` crow. Several crows reported on one task. +Merge their reports into one answer. Where they disagree, say so and say which view is better supported. Be concise.` +) + +var roster = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet"} + +type Triage struct { + Mode string `json:"mode"` + TaskType string `json:"task_type"` + Subtasks []string `json:"subtasks,omitempty"` + Reason string `json:"reason"` +} + +type Outcome struct { + Answer string + Crows []string // everyone accountable for the result +} + +type Budget struct { + mu sync.Mutex + name string + limit, spent int +} + +func (b *Budget) Check() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.spent >= b.limit { + return fmt.Errorf("%s budget spent (%d/%d tokens)", b.name, b.spent, b.limit) + } + return nil +} + +func (b *Budget) Add(n int) { + b.mu.Lock() + b.spent += n + b.mu.Unlock() +} + +type Flock struct { + brain Brain + g Guardrails + log *FlightLog + sem chan struct{} + + mu sync.Mutex + next int +} + +func NewFlock(brain Brain, g Guardrails, log *FlightLog) *Flock { + return &Flock{brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight)} +} + +// pick rotates through the roster so every crow gets a chance to build a record. +func (f *Flock) pick(n int) []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, n) + for i := range out { + out[i] = roster[f.next%len(roster)] + f.next++ + } + return out +} + +// ask is the only way any crow touches the model: budget, flock cap, timeout, log. +func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ...*Budget) (string, error) { + for _, b := range budgets { + if err := b.Check(); err != nil { + f.log.Write(Event{Kind: "grounded", Crow: crow, Error: err.Error()}) + return "", err + } + } + f.sem <- struct{}{} + defer func() { <-f.sem }() + + cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout) + defer cancel() + start := time.Now() + text, tokens, err := f.brain.Ask(cctx, system, prompt) + for _, b := range budgets { + b.Add(tokens) + } + if errors.Is(err, context.DeadlineExceeded) { + err = fmt.Errorf("%s timed out after %s and was dropped", crow, f.g.CrowTimeout) + } + e := Event{Kind: "flight", Crow: crow, Tokens: tokens, Ms: time.Since(start).Milliseconds(), Output: text} + if err != nil { + e.Error = err.Error() + } + f.log.Write(e) + return text, err +} + +func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Triage, error) { + text, err := f.ask(ctx, "Sentry", sentrySystem, "ITEM: "+item, budgets...) + if err != nil { + return Triage{}, err + } + var t Triage + if i, j := strings.Index(text, "{"), strings.LastIndex(text, "}"); i < 0 || j < i || json.Unmarshal([]byte(text[i:j+1]), &t) != nil { + t = Triage{Mode: "solo", TaskType: "other", Reason: "Sentry's call was unreadable; defaulting to solo"} + } + t.Mode = strings.ToLower(strings.TrimSpace(t.Mode)) + t.TaskType = strings.ToLower(strings.TrimSpace(t.TaskType)) + switch t.TaskType { + case "retrieval", "writing", "analysis", "other": + default: + t.TaskType = "other" + } + switch t.Mode { + case "split": + if len(t.Subtasks) > f.g.MaxSubtasks { + t.Subtasks = t.Subtasks[:f.g.MaxSubtasks] + } + if len(t.Subtasks) < 2 { + t.Mode, t.Subtasks = "solo", nil + } + case "murder": + t.Subtasks = nil + default: + t.Mode, t.Subtasks = "solo", nil + } + return t, nil +} + +func (f *Flock) Execute(ctx context.Context, item string, t Triage, budgets ...*Budget) (Outcome, error) { + switch t.Mode { + case "split": + // Split depth is 1: subtasks go straight to crows, never back to the Sentry. + crows := f.pick(len(t.Subtasks)) + reports, err := f.fanOut(ctx, crows, t.Subtasks, budgets...) + if err != nil { + return Outcome{}, err + } + return f.reconcile(ctx, item, crows, t.Subtasks, reports, budgets...) + case "murder": + n := min(f.g.MurderSize, f.g.MaxCrowsInFlight) + crows := f.pick(n) + tasks := make([]string, n) + for i := range tasks { + tasks[i] = item + } + reports, err := f.fanOut(ctx, crows, tasks, budgets...) + if err != nil { + return Outcome{}, err + } + return f.reconcile(ctx, item, crows, tasks, reports, budgets...) + default: + c := f.pick(1)[0] + ans, err := f.ask(ctx, c, crowSystem, "TASK: "+item, budgets...) + return Outcome{Answer: ans, Crows: []string{c}}, err + } +} + +func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*Budget) ([]string, error) { + reports := make([]string, len(tasks)) + errs := make([]error, len(tasks)) + var wg sync.WaitGroup + for i := range tasks { + wg.Add(1) + go func() { + defer wg.Done() + reports[i], errs[i] = f.ask(ctx, crows[i], crowSystem, "TASK: "+tasks[i], budgets...) + }() + } + wg.Wait() + ok := 0 + for i, err := range errs { + if err != nil { + reports[i] = "(no report: " + err.Error() + ")" + } else { + ok++ + } + } + if ok == 0 { + return nil, fmt.Errorf("no crow came back: %w", errs[0]) + } + return reports, nil +} + +func (f *Flock) reconcile(ctx context.Context, item string, crows, tasks, reports []string, budgets ...*Budget) (Outcome, error) { + var sb strings.Builder + fmt.Fprintf(&sb, "ORIGINAL TASK: %s\n\n", item) + for i := range reports { + fmt.Fprintf(&sb, "--- %s on %q:\n%s\n\n", crows[i], tasks[i], reports[i]) + } + ans, err := f.ask(ctx, "Elder", elderSystem, sb.String(), budgets...) + return Outcome{Answer: ans, Crows: append(append([]string{}, crows...), "Elder")}, err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61dcd86 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module somanycrows + +go 1.24 diff --git a/guardrails.go b/guardrails.go new file mode 100644 index 0000000..08b58c1 --- /dev/null +++ b/guardrails.go @@ -0,0 +1,38 @@ +package main + +import "time" + +// Guardrails exist before capabilities. Version one crows are text-only: +// no web, no files, no APIs. Each future capability gets its own guardrail +// block here before it is switched on. +type Guardrails struct { + MaxCrowsInFlight int // flock cap: concurrent crow calls + MurderSize int // crows that attack one item together + MaxItemsPerList int // todo list size cap + MaxItemChars int // per-item length cap + MaxSubtasks int // split width; subtasks never split again (depth 1) + TokenBudgetPerItem int // cost ceiling per todo item + TokenBudgetPerList int // cost ceiling per list + CrowTimeout time.Duration // a stalled crow is dropped + PromoteAfter int // clean streak needed to earn the next trust level + MaxRequestsPerMin int // requester rate cap + BaseCooldown time.Duration // first offense cooldown; doubles per strike + MaxCooldown time.Duration +} + +func DefaultGuardrails() Guardrails { + return Guardrails{ + MaxCrowsInFlight: 5, + MurderSize: 3, + MaxItemsPerList: 20, + MaxItemChars: 2000, + MaxSubtasks: 4, + TokenBudgetPerItem: 20_000, + TokenBudgetPerList: 100_000, + CrowTimeout: 60 * time.Second, + PromoteAfter: 10, + MaxRequestsPerMin: 5, + BaseCooldown: 30 * time.Second, + MaxCooldown: time.Hour, + } +} diff --git a/ledger.go b/ledger.go new file mode 100644 index 0000000..d0c060e --- /dev/null +++ b/ledger.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "math/rand/v2" + "os" + "path/filepath" + "sync" + "time" +) + +// Trust levels. +const ( + Stranger = 0 // you approve triage and output + Regular = 1 // output auto-accepted, marked unreviewed + Trusted = 2 // runs on its own +) + +// Record is a track record for one holder (a crow, or the whole flock) on one task type. +type Record struct { + Approved int `json:"approved"` + Rejected int `json:"rejected"` + Streak int `json:"streak"` + Level int `json:"level"` +} + +func (r *Record) total() int { return r.Approved + r.Rejected } + +// apply: slow to earn, quick to lose. +func (r *Record) apply(ok bool, promoteAfter int) { + if ok { + r.Approved++ + r.Streak++ + if r.Streak >= promoteAfter && r.Level < Trusted { + r.Level++ + r.Streak = 0 + } + return + } + r.Rejected++ + r.Streak = 0 + if r.Level > Stranger { + r.Level-- + } +} + +// Requester trust: abuse earns a mild, fading penalty. +type Requester struct { + Strikes int `json:"strikes"` + LastStrike time.Time `json:"last_strike"` + CooldownUntil time.Time `json:"cooldown_until"` + Recent []time.Time `json:"recent"` +} + +type Ledger struct { + Crows map[string]*Record `json:"crows"` // key: crow|task_type + Flock map[string]*Record `json:"flock"` // key: task_type + Requesters map[string]*Requester `json:"requesters"` + + path string + mu sync.Mutex +} + +func LoadLedger(path string) (*Ledger, error) { + l := &Ledger{path: path} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if err == nil { + if err := json.Unmarshal(data, l); err != nil { + return nil, fmt.Errorf("ledger is unreadable: %w", err) + } + } + if l.Crows == nil { + l.Crows = map[string]*Record{} + } + if l.Flock == nil { + l.Flock = map[string]*Record{} + } + if l.Requesters == nil { + l.Requesters = map[string]*Requester{} + } + return l, nil +} + +func (l *Ledger) Save() error { + l.mu.Lock() + defer l.mu.Unlock() + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return err + } + tmp := l.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, l.path) +} + +func get(m map[string]*Record, k string) *Record { + r := m[k] + if r == nil { + r = &Record{} + m[k] = r + } + return r +} + +// Level blends a crow's own record with the flock's record for the task type. +// A new crow flies on the flock's reputation; its own record takes over as it grows. +func (l *Ledger) Level(crow, taskType string) int { + l.mu.Lock() + defer l.mu.Unlock() + own := get(l.Crows, crow+"|"+taskType) + flock := get(l.Flock, taskType) + w := float64(own.total()) / float64(own.total()+5) + return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level))) +} + +// Judge records a verdict for every crow involved, and once for the flock. +// The flock needs twice the streak to level up. +func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) { + l.mu.Lock() + defer l.mu.Unlock() + for _, c := range crows { + get(l.Crows, c+"|"+taskType).apply(ok, promoteAfter) + } + get(l.Flock, taskType).apply(ok, promoteAfter*2) +} + +func (l *Ledger) requester(id string) *Requester { + r := l.Requesters[id] + if r == nil { + r = &Requester{} + l.Requesters[id] = r + } + return r +} + +// Admit checks a requester at the door: cooldowns, rate cap, and fading grudges. +func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string) { + l.mu.Lock() + defer l.mu.Unlock() + r := l.requester(id) + + // Grudges fade: one strike forgiven per clean day. + if r.Strikes > 0 { + if days := int(now.Sub(r.LastStrike) / (24 * time.Hour)); days > 0 { + r.Strikes = max(0, r.Strikes-days) + r.LastStrike = r.LastStrike.Add(time.Duration(days) * 24 * time.Hour) + } + } + if now.Before(r.CooldownUntil) { + return false, prank(r.CooldownUntil.Sub(now)) + } + kept := r.Recent[:0] + for _, t := range r.Recent { + if now.Sub(t) < time.Minute { + kept = append(kept, t) + } + } + r.Recent = kept + if len(r.Recent) >= g.MaxRequestsPerMin { + return false, prank(strike(r, now, g)) + } + r.Recent = append(r.Recent, now) + return true, "" +} + +func strike(r *Requester, now time.Time, g Guardrails) time.Duration { + r.Strikes++ + r.LastStrike = now + cd := g.BaseCooldown + for i := 1; i < r.Strikes && cd < g.MaxCooldown; i++ { + cd *= 2 + } + cd = min(cd, g.MaxCooldown) + r.CooldownUntil = now.Add(cd) + return cd +} + +var pranks = []string{ + "A crow has left a small gift on your windshield. The flock will hear you again in %s.", + "The flock turns its back on you. Try again in %s.", + "Something shiny of yours has gone missing. Come back in %s.", + "Caw. (Translation: not for another %s.)", +} + +func prank(d time.Duration) string { + return fmt.Sprintf(pranks[rand.IntN(len(pranks))], d.Round(time.Second)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d495a7f --- /dev/null +++ b/main.go @@ -0,0 +1,286 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "time" +) + +const dataDir = ".crows" + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "run": + cmdRun(os.Args[2:]) + case "trust": + cmdTrust() + case "log": + cmdLog(os.Args[2:]) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `So Many Crows + + crows run [-as name] hand the flock a todo list + crows trust show trust ledger + crows log [-n 20] show recent flight log entries`) + os.Exit(2) +} + +type itemResult struct { + Item, Mode, TaskType, Answer, Verdict, Skipped string + Crows []string +} + +func cmdRun(args []string) { + fs := flag.NewFlagSet("run", flag.ExitOnError) + as := fs.String("as", defaultRequester(), "requester id") + var pos []string + for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) { + pos = append(pos, fs.Arg(0)) + args = fs.Args()[1:] + } + if len(pos) != 1 { + usage() + } + todoPath := pos[0] + g := DefaultGuardrails() + + ledger, err := LoadLedger(filepath.Join(dataDir, "ledger.json")) + must(err) + flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl")) + must(err) + defer flog.Close() + save := func() { + if err := ledger.Save(); err != nil { + fmt.Fprintln(os.Stderr, "could not save ledger:", err) + } + } + + if ok, msg := ledger.Admit(*as, time.Now(), g); !ok { + fmt.Println(msg) + flog.Write(Event{Kind: "denied", Requester: *as, Note: msg}) + save() + os.Exit(1) + } + + items, err := readTodo(todoPath, g) + must(err) + if len(items) > g.MaxItemsPerList { + fmt.Printf("That list has %d items. The flock takes at most %d at a time.\n", len(items), g.MaxItemsPerList) + save() + os.Exit(1) + } + + brain := NewBrain() + flock := NewFlock(brain, g, flog) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + in := bufio.NewReader(os.Stdin) + listBudget := &Budget{name: "list", limit: g.TokenBudgetPerList} + + fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items)) + flog.Write(Event{Kind: "list", Requester: *as, Note: fmt.Sprintf("%s (%d items)", todoPath, len(items))}) + + var results []itemResult + for i, item := range items { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(items), item) + r := itemResult{Item: item} + itemBudget := &Budget{name: "item", limit: g.TokenBudgetPerItem} + + t, err := flock.Triage(ctx, item, itemBudget, listBudget) + if err != nil { + r.Skipped = "triage failed: " + err.Error() + fmt.Println(" " + r.Skipped) + results = append(results, r) + continue + } + r.Mode, r.TaskType = t.Mode, t.TaskType + fmt.Printf(" Sentry: %s · %s · %s\n", t.Mode, t.TaskType, t.Reason) + for _, s := range t.Subtasks { + fmt.Printf(" - %s\n", s) + } + flog.Write(Event{Kind: "triage", Requester: *as, Item: item, Mode: t.Mode, TaskType: t.TaskType, Note: t.Reason}) + + // The Sentry earns autonomy on triage separately from the crows' work. + triageType := "triage/" + t.TaskType + if ledger.Level("Sentry", triageType) < Trusted { + ok := confirm(in, " Approve triage?") + ledger.Judge([]string{"Sentry"}, triageType, ok, g.PromoteAfter) + flog.Write(Event{Kind: "verdict", Item: item, Crow: "Sentry", TaskType: triageType, Verdict: verdict(ok)}) + if !ok { + r.Skipped = "triage rejected" + results = append(results, r) + continue + } + } + + out, err := flock.Execute(ctx, item, t, itemBudget, listBudget) + if err != nil { + r.Skipped = "flight failed: " + err.Error() + fmt.Println(" " + r.Skipped) + results = append(results, r) + continue + } + r.Answer, r.Crows = out.Answer, out.Crows + fmt.Printf(" Crows: %s\n%s\n", strings.Join(out.Crows, ", "), indent(out.Answer, " > ")) + + // The least-trusted crow involved sets the bar. + lowest := Trusted + for _, c := range out.Crows { + lowest = min(lowest, ledger.Level(c, t.TaskType)) + } + if lowest < Regular { + ok := confirm(in, " Accept this answer?") + ledger.Judge(out.Crows, t.TaskType, ok, g.PromoteAfter) + r.Verdict = verdict(ok) + } else if lowest < Trusted { + r.Verdict = "accepted, unreviewed" + } else { + r.Verdict = "accepted, trusted" + } + flog.Write(Event{Kind: "verdict", Item: item, Crow: strings.Join(out.Crows, ","), TaskType: t.TaskType, Verdict: r.Verdict}) + results = append(results, r) + } + + save() + resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md" + must(writeResults(resultPath, todoPath, results)) + fmt.Printf("\nResults: %s · tokens used: %d/%d\n", resultPath, listBudget.spent, listBudget.limit) +} + +func readTodo(path string, g Guardrails) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var items []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + for _, p := range []string{"- [ ]", "- [x]", "-", "*"} { + line = strings.TrimSpace(strings.TrimPrefix(line, p)) + } + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if len(line) > g.MaxItemChars { + return nil, fmt.Errorf("an item is %d characters; the cap is %d", len(line), g.MaxItemChars) + } + items = append(items, line) + } + return items, nil +} + +// confirm fails safe: anything but y/yes, including no input at all, is a no. +func confirm(in *bufio.Reader, prompt string) bool { + fmt.Print(prompt + " [y/N] ") + line, _ := in.ReadString('\n') + a := strings.ToLower(strings.TrimSpace(line)) + if line == "" { + fmt.Println() + } + return a == "y" || a == "yes" +} + +func verdict(ok bool) string { + if ok { + return "approved" + } + return "rejected" +} + +func writeResults(path, source string, results []itemResult) error { + var sb strings.Builder + fmt.Fprintf(&sb, "# So Many Crows: %s\n\n_%s_\n\n", filepath.Base(source), time.Now().Format("2006-01-02 15:04")) + for _, r := range results { + if r.Skipped != "" { + fmt.Fprintf(&sb, "- [ ] %s\n - skipped: %s\n\n", r.Item, r.Skipped) + continue + } + box := "x" + if r.Verdict == "rejected" { + box = " " + } + fmt.Fprintf(&sb, "- [%s] %s\n - %s · %s · %s · %s\n%s\n\n", box, r.Item, r.Mode, r.TaskType, strings.Join(r.Crows, ", "), r.Verdict, indent(r.Answer, " > ")) + } + return os.WriteFile(path, []byte(sb.String()), 0o644) +} + +func cmdTrust() { + l, err := LoadLedger(filepath.Join(dataDir, "ledger.json")) + must(err) + names := []string{"stranger", "regular", "trusted"} + show := func(title string, m map[string]*Record) { + fmt.Println(title) + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + fmt.Println(" (none yet)") + } + for _, k := range keys { + r := m[k] + fmt.Printf(" %-28s %-9s +%d -%d streak %d\n", k, names[r.Level], r.Approved, r.Rejected, r.Streak) + } + } + show("Crows", l.Crows) + show("\nFlock", l.Flock) + fmt.Println("\nRequesters") + for id, r := range l.Requesters { + cd := "none" + if time.Now().Before(r.CooldownUntil) { + cd = time.Until(r.CooldownUntil).Round(time.Second).String() + } + fmt.Printf(" %-20s strikes %d · cooldown %s\n", id, r.Strikes, cd) + } +} + +func cmdLog(args []string) { + fs := flag.NewFlagSet("log", flag.ExitOnError) + n := fs.Int("n", 20, "entries to show") + fs.Parse(args) + data, err := os.ReadFile(filepath.Join(dataDir, "flight.jsonl")) + must(err) + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) > *n { + lines = lines[len(lines)-*n:] + } + fmt.Println(strings.Join(lines, "\n")) +} + +func indent(s, prefix string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := range lines { + lines[i] = prefix + lines[i] + } + return strings.Join(lines, "\n") +} + +func defaultRequester() string { + if u := os.Getenv("USER"); u != "" { + return u + } + return "local" +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..e72d96a --- /dev/null +++ b/todo.txt @@ -0,0 +1,4 @@ +# Sample list for the flock +- [ ] What is the difference between a crow and a raven +- [ ] Draft a tagline for So Many Crows and write a one-line product description +- [ ] Should a new agent framework launch with a CLI or a web app first