diff options
Diffstat (limited to '')
| -rw-r--r-- | main.go | 611 |
1 files changed, 611 insertions, 0 deletions
| @@ -0,0 +1,611 @@ | |||
| 1 | // pico — a minimal single-binary CLI coding agent. | ||
| 2 | package main | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "bufio" | ||
| 6 | "bytes" | ||
| 7 | "encoding/json" | ||
| 8 | "fmt" | ||
| 9 | "io" | ||
| 10 | "net/http" | ||
| 11 | "os" | ||
| 12 | "os/exec" | ||
| 13 | "path/filepath" | ||
| 14 | "regexp" | ||
| 15 | "sort" | ||
| 16 | "strconv" | ||
| 17 | "strings" | ||
| 18 | "syscall" | ||
| 19 | "time" | ||
| 20 | "unicode/utf8" | ||
| 21 | ) | ||
| 22 | |||
| 23 | // ------------------------------------------------------------------- palette | ||
| 24 | |||
| 25 | const ( | ||
| 26 | reset = "\x1b[0m" | ||
| 27 | bold = "\x1b[1m" | ||
| 28 | dim = "\x1b[2m" | ||
| 29 | blue = "\x1b[34m" | ||
| 30 | cyan = "\x1b[36m" | ||
| 31 | green = "\x1b[32m" | ||
| 32 | red = "\x1b[31m" | ||
| 33 | ) | ||
| 34 | |||
| 35 | // -------------------------------------------------------------------- config | ||
| 36 | |||
| 37 | var ( | ||
| 38 | apiURL = firstEnv("API_URL", "ANTHROPIC_BASE_URL", "http://localhost:8000") | ||
| 39 | apiKey = firstEnv("API_KEY", "ANTHROPIC_API_KEY", "") | ||
| 40 | model = firstEnv("MODEL", "ANTHROPIC_MODEL", "gpt-oss-120b") | ||
| 41 | ) | ||
| 42 | |||
| 43 | func firstEnv(keys ...string) string { | ||
| 44 | def := keys[len(keys)-1] | ||
| 45 | for _, k := range keys[:len(keys)-1] { | ||
| 46 | if k == "" { | ||
| 47 | continue | ||
| 48 | } | ||
| 49 | if v := os.Getenv(k); v != "" { | ||
| 50 | return strings.TrimRight(v, "/") | ||
| 51 | } | ||
| 52 | } | ||
| 53 | return def | ||
| 54 | } | ||
| 55 | |||
| 56 | // --------------------------------------------------------------------- tools | ||
| 57 | |||
| 58 | type param struct { | ||
| 59 | name string | ||
| 60 | typ string // trailing "?" marks optional | ||
| 61 | } | ||
| 62 | |||
| 63 | type tool struct { | ||
| 64 | name string | ||
| 65 | desc string | ||
| 66 | params []param | ||
| 67 | run func(in map[string]any) (string, error) | ||
| 68 | } | ||
| 69 | |||
| 70 | var tools = []tool{ | ||
| 71 | {"read", "Read file with line numbers (file path, not directory)", | ||
| 72 | []param{{"path", "string"}, {"offset", "integer?"}, {"limit", "integer?"}}, toolRead}, | ||
| 73 | {"write", "Write content to file", | ||
| 74 | []param{{"path", "string"}, {"content", "string"}}, toolWrite}, | ||
| 75 | {"edit", "Replace old with new in file (old must be unique unless all=true)", | ||
| 76 | []param{{"path", "string"}, {"old", "string"}, {"new", "string"}, {"all", "boolean?"}}, toolEdit}, | ||
| 77 | {"glob", "Find files by pattern, sorted by mtime", | ||
| 78 | []param{{"pat", "string"}, {"path", "string?"}}, toolGlob}, | ||
| 79 | {"grep", "Search files for regex pattern", | ||
| 80 | []param{{"pat", "string"}, {"path", "string?"}}, toolGrep}, | ||
| 81 | {"bash", "Run shell command", | ||
| 82 | []param{{"cmd", "string"}}, toolBash}, | ||
| 83 | } | ||
| 84 | |||
| 85 | var byName = func() map[string]*tool { | ||
| 86 | m := map[string]*tool{} | ||
| 87 | for i := range tools { | ||
| 88 | m[tools[i].name] = &tools[i] | ||
| 89 | } | ||
| 90 | return m | ||
| 91 | }() | ||
| 92 | |||
| 93 | // schemas is built once at startup and reused verbatim on every request, so | ||
| 94 | // the serialized prefix stays byte-identical and KV cache holds. | ||
| 95 | // Properties are emitted in declaration order, which map marshaling would not | ||
| 96 | // preserve. Each property carries "type" and nothing else. | ||
| 97 | var schemas = func() []json.RawMessage { | ||
| 98 | out := make([]json.RawMessage, len(tools)) | ||
| 99 | for i, t := range tools { | ||
| 100 | var props, required []string | ||
| 101 | for _, p := range t.params { | ||
| 102 | props = append(props, fmt.Sprintf(`%s:{"type":%s}`, jstr(p.name), jstr(strings.TrimSuffix(p.typ, "?")))) | ||
| 103 | if !strings.HasSuffix(p.typ, "?") { | ||
| 104 | required = append(required, jstr(p.name)) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | out[i] = json.RawMessage(fmt.Sprintf( | ||
| 108 | `{"name":%s,"description":%s,"input_schema":{"type":"object","properties":{%s},"required":[%s]}}`, | ||
| 109 | jstr(t.name), jstr(t.desc), strings.Join(props, ","), strings.Join(required, ","))) | ||
| 110 | } | ||
| 111 | return out | ||
| 112 | }() | ||
| 113 | |||
| 114 | func jstr(s string) string { | ||
| 115 | enc, _ := json.Marshal(s) | ||
| 116 | return string(enc) | ||
| 117 | } | ||
| 118 | |||
| 119 | // ------------------------------------------------------------- arg coercion | ||
| 120 | |||
| 121 | func argStr(in map[string]any, k, def string) string { | ||
| 122 | if s, ok := in[k].(string); ok { | ||
| 123 | return s | ||
| 124 | } | ||
| 125 | return def | ||
| 126 | } | ||
| 127 | |||
| 128 | func argInt(in map[string]any, k string, def int) int { | ||
| 129 | switch v := in[k].(type) { | ||
| 130 | case float64: | ||
| 131 | return int(v) | ||
| 132 | case string: | ||
| 133 | if n, err := strconv.Atoi(v); err == nil { | ||
| 134 | return n | ||
| 135 | } | ||
| 136 | } | ||
| 137 | return def | ||
| 138 | } | ||
| 139 | |||
| 140 | func argBool(in map[string]any, k string, def bool) bool { | ||
| 141 | if b, ok := in[k].(bool); ok { | ||
| 142 | return b | ||
| 143 | } | ||
| 144 | return def | ||
| 145 | } | ||
| 146 | |||
| 147 | // ---------------------------------------------------------------------- read | ||
| 148 | |||
| 149 | func toolRead(in map[string]any) (string, error) { | ||
| 150 | data, err := os.ReadFile(argStr(in, "path", "")) | ||
| 151 | if err != nil { | ||
| 152 | return "", err | ||
| 153 | } | ||
| 154 | // SplitAfter keeps the newline on each line; drop the empty tail element. | ||
| 155 | lines := strings.SplitAfter(string(data), "\n") | ||
| 156 | if lines[len(lines)-1] == "" { | ||
| 157 | lines = lines[:len(lines)-1] | ||
| 158 | } | ||
| 159 | offset := argInt(in, "offset", 0) | ||
| 160 | limit := argInt(in, "limit", len(lines)) | ||
| 161 | start := min(max(offset, 0), len(lines)) | ||
| 162 | end := min(max(offset+limit, start), len(lines)) | ||
| 163 | |||
| 164 | var b strings.Builder | ||
| 165 | for i, ln := range lines[start:end] { | ||
| 166 | fmt.Fprintf(&b, "%4d| %s", start+i+1, ln) | ||
| 167 | } | ||
| 168 | return b.String(), nil | ||
| 169 | } | ||
| 170 | |||
| 171 | // ---------------------------------------------------------------- write, edit | ||
| 172 | |||
| 173 | func toolWrite(in map[string]any) (string, error) { | ||
| 174 | if err := os.WriteFile(argStr(in, "path", ""), []byte(argStr(in, "content", "")), 0644); err != nil { | ||
| 175 | return "", err | ||
| 176 | } | ||
| 177 | return "ok", nil | ||
| 178 | } | ||
| 179 | |||
| 180 | func toolEdit(in map[string]any) (string, error) { | ||
| 181 | path := argStr(in, "path", "") | ||
| 182 | data, err := os.ReadFile(path) | ||
| 183 | if err != nil { | ||
| 184 | return "", err | ||
| 185 | } | ||
| 186 | s, old, new := string(data), argStr(in, "old", ""), argStr(in, "new", "") | ||
| 187 | |||
| 188 | // Both of these are ordinary results, not errors: the model reads them | ||
| 189 | // and retries with a longer `old`. | ||
| 190 | switch n := strings.Count(s, old); { | ||
| 191 | case n == 0: | ||
| 192 | return "error: old_string not found", nil | ||
| 193 | case n > 1 && !argBool(in, "all", false): | ||
| 194 | return fmt.Sprintf("error: old_string appears %d times, must be unique (use all=true)", n), nil | ||
| 195 | } | ||
| 196 | |||
| 197 | if argBool(in, "all", false) { | ||
| 198 | s = strings.ReplaceAll(s, old, new) | ||
| 199 | } else { | ||
| 200 | s = strings.Replace(s, old, new, 1) | ||
| 201 | } | ||
| 202 | if err := os.WriteFile(path, []byte(s), 0644); err != nil { | ||
| 203 | return "", err | ||
| 204 | } | ||
| 205 | return "ok", nil | ||
| 206 | } | ||
| 207 | |||
| 208 | // ---------------------------------------------------------------- glob, grep | ||
| 209 | |||
| 210 | func argPath(in map[string]any) string { | ||
| 211 | if p := argStr(in, "path", "."); p != "" { | ||
| 212 | return p | ||
| 213 | } | ||
| 214 | return "." | ||
| 215 | } | ||
| 216 | |||
| 217 | func toolGlob(in map[string]any) (string, error) { | ||
| 218 | matches := globExpand(strings.ReplaceAll(argPath(in)+"/"+argStr(in, "pat", ""), "//", "/")) | ||
| 219 | if len(matches) == 0 { | ||
| 220 | return "none", nil | ||
| 221 | } | ||
| 222 | mtime := make(map[string]int64, len(matches)) | ||
| 223 | for _, m := range matches { | ||
| 224 | if fi, err := os.Stat(m); err == nil && !fi.IsDir() { | ||
| 225 | mtime[m] = fi.ModTime().UnixNano() | ||
| 226 | } | ||
| 227 | } | ||
| 228 | sort.SliceStable(matches, func(i, j int) bool { return mtime[matches[i]] > mtime[matches[j]] }) | ||
| 229 | return strings.Join(matches, "\n"), nil | ||
| 230 | } | ||
| 231 | |||
| 232 | func toolGrep(in map[string]any) (string, error) { | ||
| 233 | re, err := regexp.Compile(argStr(in, "pat", "")) | ||
| 234 | if err != nil { | ||
| 235 | return "", err | ||
| 236 | } | ||
| 237 | var hits []string | ||
| 238 | for _, p := range globExpand(argPath(in) + "/**") { | ||
| 239 | f, err := os.Open(p) | ||
| 240 | if err != nil { | ||
| 241 | continue | ||
| 242 | } | ||
| 243 | // Directories appear in the ** expansion; scanning one yields nothing. | ||
| 244 | sc := bufio.NewScanner(f) | ||
| 245 | sc.Buffer(make([]byte, 0, 64*1024), 8<<20) | ||
| 246 | for n := 1; sc.Scan(); n++ { | ||
| 247 | if line := sc.Text(); re.MatchString(line) { | ||
| 248 | hits = append(hits, fmt.Sprintf("%s:%d:%s", p, n, strings.TrimRight(line, " \t\r"))) | ||
| 249 | } | ||
| 250 | } | ||
| 251 | f.Close() | ||
| 252 | if len(hits) >= 50 { | ||
| 253 | break | ||
| 254 | } | ||
| 255 | } | ||
| 256 | if len(hits) == 0 { | ||
| 257 | return "none", nil | ||
| 258 | } | ||
| 259 | if len(hits) > 50 { | ||
| 260 | hits = hits[:50] | ||
| 261 | } | ||
| 262 | return strings.Join(hits, "\n"), nil | ||
| 263 | } | ||
| 264 | |||
| 265 | // ---------------------------------------------------------------------- bash | ||
| 266 | |||
| 267 | func toolBash(in map[string]any) (string, error) { | ||
| 268 | pr, pw, err := os.Pipe() | ||
| 269 | if err != nil { | ||
| 270 | return "", err | ||
| 271 | } | ||
| 272 | cmd := exec.Command("sh", "-c", argStr(in, "cmd", "")) | ||
| 273 | cmd.Stdout, cmd.Stderr = pw, pw // one pipe: interleaved in real order | ||
| 274 | // Own process group, so the timeout below can kill the whole tree. | ||
| 275 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| 276 | if err := cmd.Start(); err != nil { | ||
| 277 | pw.Close() | ||
| 278 | pr.Close() | ||
| 279 | return "", err | ||
| 280 | } | ||
| 281 | // The parent MUST drop its own copy of the write end, or the pipe never | ||
| 282 | // reaches EOF and the reader below blocks forever -- even for `true`. | ||
| 283 | pw.Close() | ||
| 284 | |||
| 285 | done := make(chan string, 1) | ||
| 286 | go func() { | ||
| 287 | defer pr.Close() | ||
| 288 | var acc strings.Builder | ||
| 289 | sc := bufio.NewScanner(pr) | ||
| 290 | sc.Buffer(make([]byte, 0, 64*1024), 8<<20) | ||
| 291 | for sc.Scan() { | ||
| 292 | line := sc.Text() | ||
| 293 | fmt.Printf(" %s│ %s%s\n", dim, strings.TrimRight(line, " \t\r"), reset) | ||
| 294 | acc.WriteString(line + "\n") | ||
| 295 | } | ||
| 296 | done <- acc.String() | ||
| 297 | }() | ||
| 298 | |||
| 299 | var out string | ||
| 300 | select { | ||
| 301 | case out = <-done: | ||
| 302 | case <-time.After(30 * time.Second): | ||
| 303 | // Kill the GROUP, not just sh: `sh -c "sleep 60"` forks, and the | ||
| 304 | // grandchild inherits the pipe's write end. Killing sh alone leaves | ||
| 305 | // the pipe open and the drain below blocks until the grandchild | ||
| 306 | // exits on its own -- a 30s timeout that takes 60s. | ||
| 307 | if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { | ||
| 308 | cmd.Process.Kill() | ||
| 309 | } | ||
| 310 | out = <-done // let the reader drain before reporting | ||
| 311 | out += "\n(timed out after 30s)" | ||
| 312 | } | ||
| 313 | cmd.Wait() // always reap | ||
| 314 | |||
| 315 | if out = strings.TrimSpace(out); out == "" { | ||
| 316 | return "(empty)", nil | ||
| 317 | } | ||
| 318 | return out, nil | ||
| 319 | } | ||
| 320 | |||
| 321 | // ------------------------------------------------------------------ dispatch | ||
| 322 | |||
| 323 | func runTool(name string, input json.RawMessage) string { | ||
| 324 | t, ok := byName[name] | ||
| 325 | if !ok { | ||
| 326 | return fmt.Sprintf("error: unknown tool %q", name) | ||
| 327 | } | ||
| 328 | var in map[string]any | ||
| 329 | json.Unmarshal(input, &in) | ||
| 330 | out, err := t.run(in) | ||
| 331 | if err != nil { | ||
| 332 | return "error: " + err.Error() | ||
| 333 | } | ||
| 334 | return out | ||
| 335 | } | ||
| 336 | |||
| 337 | // --------------------------------------------------------------- glob engine | ||
| 338 | |||
| 339 | func globExpand(pattern string) []string { | ||
| 340 | root, rest := ".", pattern | ||
| 341 | if strings.HasPrefix(pattern, "/") { | ||
| 342 | root, rest = "/", pattern[1:] | ||
| 343 | } | ||
| 344 | var out []string | ||
| 345 | globWalk(root, strings.Split(rest, "/"), &out) | ||
| 346 | if root == "." { | ||
| 347 | for i := range out { | ||
| 348 | out[i] = strings.TrimPrefix(out[i], "./") | ||
| 349 | } | ||
| 350 | } | ||
| 351 | return out | ||
| 352 | } | ||
| 353 | |||
| 354 | func globWalk(dir string, segs []string, out *[]string) { | ||
| 355 | if len(segs) == 0 { | ||
| 356 | *out = append(*out, dir) | ||
| 357 | return | ||
| 358 | } | ||
| 359 | seg, rest := segs[0], segs[1:] | ||
| 360 | |||
| 361 | if seg == "" || seg == "." { | ||
| 362 | globWalk(dir, rest, out) | ||
| 363 | return | ||
| 364 | } | ||
| 365 | |||
| 366 | if seg == "**" { | ||
| 367 | globWalk(dir, rest, out) // ** matches zero path segments | ||
| 368 | for _, e := range readDir(dir) { | ||
| 369 | if strings.HasPrefix(e.Name(), ".") { | ||
| 370 | continue | ||
| 371 | } | ||
| 372 | switch p := join(dir, e.Name()); { | ||
| 373 | case e.IsDir(): | ||
| 374 | globWalk(p, segs, out) // keep ** at the head | ||
| 375 | case len(rest) == 0: | ||
| 376 | *out = append(*out, p) | ||
| 377 | } | ||
| 378 | } | ||
| 379 | return | ||
| 380 | } | ||
| 381 | |||
| 382 | for _, e := range readDir(dir) { | ||
| 383 | name := e.Name() | ||
| 384 | if strings.HasPrefix(name, ".") && !strings.HasPrefix(seg, ".") { | ||
| 385 | continue | ||
| 386 | } | ||
| 387 | if ok, _ := filepath.Match(seg, name); !ok { | ||
| 388 | continue | ||
| 389 | } | ||
| 390 | p := join(dir, name) | ||
| 391 | if len(rest) == 0 { | ||
| 392 | *out = append(*out, p) // file or directory | ||
| 393 | } else if e.IsDir() { | ||
| 394 | globWalk(p, rest, out) | ||
| 395 | } | ||
| 396 | } | ||
| 397 | } | ||
| 398 | |||
| 399 | // readDir returns entries sorted by name; unreadable directories yield none. | ||
| 400 | func readDir(dir string) []os.DirEntry { | ||
| 401 | es, err := os.ReadDir(dir) | ||
| 402 | if err != nil { | ||
| 403 | return nil | ||
| 404 | } | ||
| 405 | return es | ||
| 406 | } | ||
| 407 | |||
| 408 | func join(dir, name string) string { | ||
| 409 | if strings.HasSuffix(dir, "/") { | ||
| 410 | return dir + name | ||
| 411 | } | ||
| 412 | return dir + "/" + name | ||
| 413 | } | ||
| 414 | |||
| 415 | // ------------------------------------------------------------------ API call | ||
| 416 | |||
| 417 | type message struct { | ||
| 418 | Role string `json:"role"` | ||
| 419 | Content any `json:"content"` | ||
| 420 | } | ||
| 421 | |||
| 422 | type toolResult struct { | ||
| 423 | Type string `json:"type"` | ||
| 424 | ToolUseID string `json:"tool_use_id"` | ||
| 425 | Content string `json:"content"` | ||
| 426 | } | ||
| 427 | |||
| 428 | type block struct { | ||
| 429 | Type string `json:"type"` | ||
| 430 | Text string `json:"text"` | ||
| 431 | ID string `json:"id"` | ||
| 432 | Name string `json:"name"` | ||
| 433 | Input json.RawMessage `json:"input"` | ||
| 434 | } | ||
| 435 | |||
| 436 | var systemPrompt = func() string { | ||
| 437 | cwd, _ := os.Getwd() | ||
| 438 | return "Concise coding assistant. cwd: " + cwd | ||
| 439 | }() | ||
| 440 | |||
| 441 | func callAPI(messages []message) ([]json.RawMessage, error) { | ||
| 442 | body, err := json.Marshal(map[string]any{ | ||
| 443 | "model": model, | ||
| 444 | "max_tokens": 8192, | ||
| 445 | "system": systemPrompt, | ||
| 446 | "messages": messages, | ||
| 447 | "tools": schemas, | ||
| 448 | }) | ||
| 449 | if err != nil { | ||
| 450 | return nil, err | ||
| 451 | } | ||
| 452 | req, err := http.NewRequest("POST", apiURL+"/v1/messages", bytes.NewReader(body)) | ||
| 453 | if err != nil { | ||
| 454 | return nil, err | ||
| 455 | } | ||
| 456 | // Every header is byte-identical across turns: a varying one would | ||
| 457 | // invalidate llama-swap's KV cache on every request. | ||
| 458 | req.Header.Set("Content-Type", "application/json") | ||
| 459 | req.Header.Set("anthropic-version", "2023-06-01") | ||
| 460 | req.Header.Set("x-api-key", apiKey) | ||
| 461 | |||
| 462 | // No timeout: a cold llama-swap model load costs ~10-25s before prefill | ||
| 463 | // even begins, and llama-swap's own responseHeader timeout is 1440s. | ||
| 464 | resp, err := http.DefaultClient.Do(req) | ||
| 465 | if err != nil { | ||
| 466 | return nil, err | ||
| 467 | } | ||
| 468 | defer resp.Body.Close() | ||
| 469 | raw, err := io.ReadAll(resp.Body) | ||
| 470 | if err != nil { | ||
| 471 | return nil, err | ||
| 472 | } | ||
| 473 | if resp.StatusCode >= 400 { | ||
| 474 | return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500)) | ||
| 475 | } | ||
| 476 | // Content blocks stay raw: thinking/text/tool_use and anything unknown | ||
| 477 | // all round-trip verbatim into the next request. | ||
| 478 | var parsed struct { | ||
| 479 | Content []json.RawMessage `json:"content"` | ||
| 480 | } | ||
| 481 | if err := json.Unmarshal(raw, &parsed); err != nil { | ||
| 482 | return nil, err | ||
| 483 | } | ||
| 484 | return parsed.Content, nil | ||
| 485 | } | ||
| 486 | |||
| 487 | // ---------------------------------------------------------------- agent loop | ||
| 488 | |||
| 489 | func agentLoop(messages *[]message) error { | ||
| 490 | for { | ||
| 491 | blocks, err := callAPI(*messages) | ||
| 492 | if err != nil { | ||
| 493 | return err | ||
| 494 | } | ||
| 495 | var results []toolResult | ||
| 496 | for _, raw := range blocks { | ||
| 497 | var b block | ||
| 498 | if err := json.Unmarshal(raw, &b); err != nil { | ||
| 499 | return err | ||
| 500 | } | ||
| 501 | switch b.Type { | ||
| 502 | case "text": | ||
| 503 | fmt.Printf("\n%s⏺%s %s\n", cyan, reset, renderMarkdown(b.Text)) | ||
| 504 | case "tool_use": | ||
| 505 | fmt.Printf("\n%s⏺ %s%s(%s%s%s)\n", | ||
| 506 | green, capitalize(b.Name), reset, dim, argPreview(b.Name, b.Input), reset) | ||
| 507 | out := runTool(b.Name, b.Input) | ||
| 508 | printResult(out) | ||
| 509 | results = append(results, toolResult{"tool_result", b.ID, out}) | ||
| 510 | } | ||
| 511 | // Other block types (thinking) are not displayed but still echoed. | ||
| 512 | } | ||
| 513 | *messages = append(*messages, message{"assistant", blocks}) | ||
| 514 | if len(results) == 0 { | ||
| 515 | return nil | ||
| 516 | } | ||
| 517 | *messages = append(*messages, message{"user", results}) | ||
| 518 | } | ||
| 519 | } | ||
| 520 | |||
| 521 | // ------------------------------------------------------------------- display | ||
| 522 | |||
| 523 | var boldRe = regexp.MustCompile(`(?s)\*\*(.*?)\*\*`) | ||
| 524 | |||
| 525 | func renderMarkdown(s string) string { return boldRe.ReplaceAllString(s, bold+"$1"+reset) } | ||
| 526 | |||
| 527 | func capitalize(s string) string { | ||
| 528 | if s == "" { | ||
| 529 | return s | ||
| 530 | } | ||
| 531 | return strings.ToUpper(s[:1]) + s[1:] | ||
| 532 | } | ||
| 533 | |||
| 534 | // truncate cuts to n code points, never bytes, and never adds an ellipsis. | ||
| 535 | func truncate(s string, n int) string { | ||
| 536 | if utf8.RuneCountInString(s) <= n { | ||
| 537 | return s | ||
| 538 | } | ||
| 539 | return string([]rune(s)[:n]) | ||
| 540 | } | ||
| 541 | |||
| 542 | func argPreview(name string, input json.RawMessage) string { | ||
| 543 | t, ok := byName[name] | ||
| 544 | if !ok { | ||
| 545 | return "" | ||
| 546 | } | ||
| 547 | var in map[string]any | ||
| 548 | json.Unmarshal(input, &in) | ||
| 549 | for _, p := range t.params { | ||
| 550 | v, present := in[p.name] | ||
| 551 | if !present { | ||
| 552 | continue | ||
| 553 | } | ||
| 554 | if s, isStr := v.(string); isStr { | ||
| 555 | return truncate(s, 50) | ||
| 556 | } | ||
| 557 | enc, _ := json.Marshal(v) | ||
| 558 | return truncate(string(enc), 50) | ||
| 559 | } | ||
| 560 | return "" | ||
| 561 | } | ||
| 562 | |||
| 563 | func printResult(out string) { | ||
| 564 | lines := strings.Split(out, "\n") | ||
| 565 | preview := truncate(lines[0], 60) | ||
| 566 | if len(lines) > 1 { | ||
| 567 | preview += fmt.Sprintf(" ... +%d lines", len(lines)-1) | ||
| 568 | } else if utf8.RuneCountInString(lines[0]) > 60 { | ||
| 569 | preview += "..." | ||
| 570 | } | ||
| 571 | fmt.Printf(" %s⎿ %s%s\n", dim, preview, reset) | ||
| 572 | } | ||
| 573 | |||
| 574 | // ---------------------------------------------------------------------- REPL | ||
| 575 | |||
| 576 | func main() { | ||
| 577 | cwd, _ := os.Getwd() | ||
| 578 | fmt.Printf("%spico%s | %s%s | %s%s\n\n", bold, reset, dim, model, cwd, reset) | ||
| 579 | |||
| 580 | sep := dim + strings.Repeat("─", 80) + reset | ||
| 581 | stdin := bufio.NewScanner(os.Stdin) | ||
| 582 | stdin.Buffer(make([]byte, 0, 64*1024), 8<<20) | ||
| 583 | |||
| 584 | var messages []message | ||
| 585 | for { | ||
| 586 | fmt.Println(sep) | ||
| 587 | fmt.Printf("%s%s❯%s ", bold, blue, reset) | ||
| 588 | if !stdin.Scan() { | ||
| 589 | fmt.Println() | ||
| 590 | return | ||
| 591 | } | ||
| 592 | fmt.Println(sep) | ||
| 593 | |||
| 594 | switch line := strings.TrimSpace(stdin.Text()); line { | ||
| 595 | case "": | ||
| 596 | continue | ||
| 597 | case "/q", "exit": | ||
| 598 | return | ||
| 599 | case "/c": | ||
| 600 | messages = nil | ||
| 601 | fmt.Printf("%s⏺ Cleared conversation%s\n", green, reset) | ||
| 602 | default: | ||
| 603 | messages = append(messages, message{"user", line}) | ||
| 604 | if err := agentLoop(&messages); err != nil { | ||
| 605 | fmt.Printf("%s⏺ Error: %v%s\n", red, err, reset) | ||
| 606 | continue | ||
| 607 | } | ||
| 608 | fmt.Println() | ||
| 609 | } | ||
| 610 | } | ||
| 611 | } | ||
