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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
10 files changed
+999
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.crows/
|
||||
/crows
|
||||
*.crows.md
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 <todo.txt> [-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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user