- personas.go: backstory, prompt policy and output policy per crow; Hugin and Munin grounded in Grimnismal 20 and Gylfaginning 38; every working crow also follows a common output policy - Personas go into each crow's instructions; Sentry keeps JSON-only - banter.go: retrieve backstory, relationships and recent flock memory, then the speaking crow generates one line in character; max 2 per list against the list budget, never blocks work, unusable lines dropped - crow.banter event, GET /api/crows, `crows personas` -> PERSONAS.md - UI: epithets on tiles and messages, banter as quiet asides Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
239 lines
6.6 KiB
Go
239 lines
6.6 KiB
Go
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 "serve":
|
|
cmdServe(os.Args[2:])
|
|
case "trust":
|
|
cmdTrust()
|
|
case "log":
|
|
cmdLog(os.Args[2:])
|
|
case "personas":
|
|
fmt.Print(personaSheet())
|
|
default:
|
|
usage()
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, `So Many Crows
|
|
|
|
crows run <todo.txt> [-as name] [-nest id] hand the flock a todo list
|
|
crows serve [-addr 127.0.0.1:8080] run the web server (API + SSE)
|
|
crows trust show trust ledger
|
|
crows log [-n 20] show recent flight log entries
|
|
crows personas print the flock's persona sheet (Markdown)`)
|
|
os.Exit(2)
|
|
}
|
|
|
|
func cmdRun(args []string) {
|
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
|
as := fs.String("as", defaultRequester(), "requester id")
|
|
nestID := fs.String("nest", defaultNestID, "nest whose core crows fly first")
|
|
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()
|
|
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
|
|
must(err)
|
|
nest, found := nests.Get(*nestID)
|
|
if !found {
|
|
must(fmt.Errorf("there is no nest called %q", *nestID))
|
|
}
|
|
p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
|
|
|
|
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
|
|
fmt.Println(msg)
|
|
flog.Write(Event{Kind: "denied", Requester: *as, Note: msg})
|
|
p.save()
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, err := os.ReadFile(todoPath)
|
|
must(err)
|
|
items, err := parseTodo(string(data), 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)
|
|
p.save()
|
|
os.Exit(1)
|
|
}
|
|
|
|
brain := NewBrain()
|
|
p.Flock = NewFlock(brain, g, flog)
|
|
p.Flock.SetClassifier(NewJev())
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
|
|
fmt.Printf("Brain: %s · requester: %s · nest: %s · %d items\n", brain.Name(), *as, nest.ID, len(items))
|
|
l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items, Nest: nest}
|
|
results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}})
|
|
|
|
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)
|
|
}
|
|
|
|
// cliEmitter prints the events a person at a terminal cares about.
|
|
type cliEmitter struct {
|
|
items []string
|
|
seen map[string]bool
|
|
}
|
|
|
|
func (c *cliEmitter) Emit(event string, f map[string]any) {
|
|
if id, ok := f["item_id"].(string); ok && !c.seen[id] {
|
|
c.seen[id] = true
|
|
var n int
|
|
fmt.Sscanf(id[strings.LastIndex(id, "-")+1:], "%d", &n)
|
|
fmt.Printf("\n[%d/%d] %s\n", n, len(c.items), c.items[n-1])
|
|
}
|
|
switch event {
|
|
case "item.triaged":
|
|
fmt.Printf(" Sentry: %s · %s · %s\n", f["mode"], f["task_type"], f["reason"])
|
|
for _, s := range f["subtasks"].([]string) {
|
|
fmt.Printf(" - %s\n", s)
|
|
}
|
|
case "item.answered":
|
|
fmt.Printf(" Crows: %s\n%s\n", strings.Join(f["crows"].([]string), ", "), indent(f["answer"].(string), " > "))
|
|
case "trust.changed":
|
|
fmt.Printf(" Trust: %s on %s went %s → %s\n", f["holder"], f["task_type"], levelNames[f["from"].(int)], levelNames[f["to"].(int)])
|
|
case "item.skipped":
|
|
fmt.Printf(" %s\n", f["reason"])
|
|
}
|
|
}
|
|
|
|
var levelNames = []string{"stranger", "regular", "trusted"}
|
|
|
|
// parseTodo turns todo text (markdown checkboxes, bullets or plain lines) into items.
|
|
func parseTodo(text string, g Guardrails) ([]string, error) {
|
|
var items []string
|
|
for _, line := range strings.Split(text, "\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
|
|
}
|
|
|
|
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" || r.Verdict == "timeout" {
|
|
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)
|
|
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, levelNames[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)
|
|
}
|
|
}
|