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
142 lines
3.8 KiB
Go
142 lines
3.8 KiB
Go
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
|
|
}
|