detached a670442

feat: zotcore SDK + zot rpc subprocess protocol

two new ways to embed the zot agent runtime in third-party apps:

1. pkg/zotcore - public Go SDK
   - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event,
     Cancel, Compact, SetModel, State, Messages, Cost, ListModels,
     Close. Concurrent-safe; one prompt at a time per Runtime,
     ErrBusy if you try to overlap. Spawn multiple Runtimes for
     multiple projects.
   - Public types mirror the JSON-RPC wire schema 1:1 so consumers
     can share parsing code with the out-of-process clients.
   - Internal core/agent/provider stay internal; SDK is a thin
     facade that exposes only what's stable.

2. zot rpc subcommand - newline-delimited JSON on stdin/stdout
   - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a
     subprocess that any language can drive via pipes.
   - Commands: hello, prompt, abort, compact, get_state,
     get_messages, clear, set_model, get_models, ping. Each
     optionally carries an id; the matching response echoes it.
   - Stream notifications: turn_start, user_message,
     assistant_start, text_delta, tool_call, tool_progress,
     tool_result, assistant_message, usage, turn_end, done,
     error, compact_done. Same shape as the existing --json mode
     events (modes.EventToJSON / ContentToJSON were exported
     for reuse).
   - Auth: optional ZOTCORE_RPC_TOKEN env var; first command
     must be hello {token: ...} when set. Without the env var
     the spawning process is implicitly trusted.
   - Concurrency: one prompt or compact at a time per process,
     enforced by a turnMu mutex. abort fires immediately
     regardless. Stdin close exits the process.

3. docs/rpc.md - full schema reference
4. examples/rpc/{python,node,shell,go} - reference clients
5. examples/sdk - in-process Go embedding example
6. README updated with a new modes entry and an embedding section
commit
a670442c9ef04c46b094276013eabe61f27c64c2
author
patriceckhart · · 2026-04-19 10:26 UTC
parent
d6ac856
signature
unsigned
refs
tag: v0.0.13
16 files · +1744 -54
M README.md +11 -1
@@ -151,6 +151,16 @@ when the sandbox is on (see `/lock`), all four tools refuse paths outside the se
151151 - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands.
152152 - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
153153 - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8.
154+- **rpc**: `zot rpc` runs as a long-lived child process; commands in on stdin, events + responses out on stdout, both as ndjson. designed for embedding zot in third-party apps written in any language. see `docs/rpc.md` for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
155+
156+## embedding
157+
158+two ways to drive zot from another program:
159+
160+- **go in-process**: import `github.com/patriceckhart/zot/pkg/zotcore`. one `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. small example in `examples/sdk/`.
161+- **any language out-of-process**: spawn `zot rpc` as a subprocess and exchange newline-delimited json over its stdin/stdout. wire format and event schema in [docs/rpc.md](docs/rpc.md). reference clients live under `examples/rpc/`.
162+
163+both interfaces share the same event schema, so transcripts captured by one can be replayed through the other.
154164
155165 ## slash commands
156166
@@ -235,7 +245,7 @@ slash commands also work while the agent is busy. read-only ones (`/help`, `/jum
235245 | `alt+enter` | newline |
236246 | `tab` | complete the selected slash command |
237247 | `esc` | cancel the current turn (while busy); clear input (while idle) |
238-| `ctrl+c` | exit when idle; cancel the current turn while busy |
248+| `ctrl+c` | clear the input + queue (or cancel the current turn). press again within 2s to exit. |
239249 | `ctrl+d` | exit on empty input |
240250 | `ctrl+l` | redraw the screen |
241251 | `ctrl+o` | expand / collapse long tool results |
@@ -151,6 +151,16 @@ when the sandbox is on (see `/lock`), all four tools refuse paths outside the se
151 - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands.151 - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands.
152 - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.152 - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
153 - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8.153 - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8.
154+- **rpc**: `zot rpc` runs as a long-lived child process; commands in on stdin, events + responses out on stdout, both as ndjson. designed for embedding zot in third-party apps written in any language. see `docs/rpc.md` for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
155+
156+## embedding
157+
158+two ways to drive zot from another program:
159+
160+- **go in-process**: import `github.com/patriceckhart/zot/pkg/zotcore`. one `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. small example in `examples/sdk/`.
161+- **any language out-of-process**: spawn `zot rpc` as a subprocess and exchange newline-delimited json over its stdin/stdout. wire format and event schema in [docs/rpc.md](docs/rpc.md). reference clients live under `examples/rpc/`.
162+
163+both interfaces share the same event schema, so transcripts captured by one can be replayed through the other.
154 164
155 ## slash commands165 ## slash commands
156 166
@@ -235,7 +245,7 @@ slash commands also work while the agent is busy. read-only ones (`/help`, `/jum
235 | `alt+enter` | newline |245 | `alt+enter` | newline |
236 | `tab` | complete the selected slash command |246 | `tab` | complete the selected slash command |
237 | `esc` | cancel the current turn (while busy); clear input (while idle) |247 | `esc` | cancel the current turn (while busy); clear input (while idle) |
238-| `ctrl+c` | exit when idle; cancel the current turn while busy |248+| `ctrl+c` | clear the input + queue (or cancel the current turn). press again within 2s to exit. |
239 | `ctrl+d` | exit on empty input |249 | `ctrl+d` | exit on empty input |
240 | `ctrl+l` | redraw the screen |250 | `ctrl+l` | redraw the screen |
241 | `ctrl+o` | expand / collapse long tool results |251 | `ctrl+o` | expand / collapse long tool results |
A docs/rpc.md +230 -0
@@ -0,0 +1,230 @@
1+# zot RPC
2+
3+`zot rpc` runs the agent runtime as a subprocess that speaks newline-delimited JSON on stdin and stdout. Use it from any language that can spawn a process and read/write its pipes — Go, TypeScript, Python, Rust, shell, anything.
4+
5+For a Go program embedding the runtime in-process, use the `pkg/zotcore` SDK instead. The wire format below mirrors the SDK's types one-for-one so consumers can share parsing code.
6+
7+## Quick start
8+
9+```bash
10+# spawn zot rpc; talk to it from a shell
11+( echo '{"id":"1","type":"prompt","message":"hello"}'; sleep 5 ) \
12+ | zot rpc --provider anthropic
13+```
14+
15+You'll see one JSON object per line on stdout: a response acknowledging the prompt, a stream of events (`text_delta`, `tool_call`, `tool_result`, `usage`), then `done`.
16+
17+## Process model
18+
19+- One `zot rpc` process serves **one cwd, one model, one session**.
20+- For multiple projects, spawn multiple processes.
21+- Concurrency: at most one prompt or compact in flight at a time. A second one queues until the first finishes; aborting fires immediately.
22+- The process exits when stdin closes.
23+
24+## Flags
25+
26+`zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
27+
28+## Auth
29+
30+If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token:
31+
32+```json
33+{"id":"0","type":"hello","token":"shared-secret"}
34+```
35+
36+If absent or wrong, the response carries `success:false` and the process exits. Without `ZOTCORE_RPC_TOKEN` set, no auth is required (the spawning process is implicitly trusted; if it can spawn `zot` it can also read your `auth.json` directly).
37+
38+## Wire format
39+
40+Every line in either direction is one JSON object terminated by `\n`. Object boundaries follow newline boundaries — no multi-line JSON.
41+
42+### Frame types
43+
44+| `type` | Direction | Description |
45+|---|---|---|
46+| any command (`prompt`, `abort`, …) | client → server | Request |
47+| `response` | server → client | Reply to one command, correlated by `id` |
48+| any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) |
49+
50+## Commands
51+
52+All commands share an optional `id` field; if present, the matching `response` echoes it. Use `id` to correlate replies with requests, especially when several requests are in flight.
53+
54+### `hello`
55+
56+```json
57+{"id":"0","type":"hello","token":"shared-secret"}
58+```
59+
60+Response:
61+
62+```json
63+{"type":"response","id":"0","command":"hello","success":true,
64+ "data":{"protocol_version":1,"version":"0.0.4","provider":"anthropic","model":"claude-opus-4-5"}}
65+```
66+
67+Required as the first message when `ZOTCORE_RPC_TOKEN` is set; optional otherwise.
68+
69+### `prompt`
70+
71+```json
72+{"id":"1","type":"prompt","message":"fix the failing test","images":[]}
73+```
74+
75+Optional `images` is `[{"mime_type":"image/png","data":"<base64>"}]`.
76+
77+Response is immediate (the turn is starting):
78+
79+```json
80+{"type":"response","id":"1","command":"prompt","success":true,"data":{"started":true}}
81+```
82+
83+Then a stream of event objects (see below) until the turn ends with `{"type":"done"}`.
84+
85+### `abort`
86+
87+Cancel the active prompt or compact.
88+
89+```json
90+{"id":"2","type":"abort"}
91+```
92+
93+Response: `{"type":"response","id":"2","command":"abort","success":true}`.
94+
95+If the turn was streaming, the next events you see will be a `turn_end` with `stop:"aborted"` then `done`.
96+
97+### `compact`
98+
99+Summarise the current transcript into one synthetic user message. Same lifecycle as `prompt` (immediate response, then events).
100+
101+```json
102+{"id":"3","type":"compact"}
103+```
104+
105+Final event: `{"type":"compact_done","summary":"<text>"}`.
106+
107+### `get_state`
108+
109+Snapshot of the runtime.
110+
111+```json
112+{"id":"4","type":"get_state"}
113+```
114+
115+Response data:
116+
117+```json
118+{
119+ "provider": "anthropic",
120+ "model": "claude-opus-4-5",
121+ "cwd": "/Users/pat/Developer/zot",
122+ "message_count": 12,
123+ "busy": false,
124+ "usage": {"input": 1234, "output": 567, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123}
125+}
126+```
127+
128+### `get_messages`
129+
130+Full transcript.
131+
132+```json
133+{"id":"5","type":"get_messages"}
134+```
135+
136+Response data: `{"messages": [<message>, ...]}`. See **message shape** below.
137+
138+### `clear`
139+
140+Drop the entire transcript. Equivalent to the `/clear` slash command.
141+
142+```json
143+{"id":"6","type":"clear"}
144+```
145+
146+### `set_model`
147+
148+Switch model within the same provider.
149+
150+```json
151+{"id":"7","type":"set_model","model":"claude-sonnet-4-5"}
152+```
153+
154+Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155+
156+### `get_models`
157+
158+List models known for the current provider.
159+
160+```json
161+{"id":"8","type":"get_models"}
162+```
163+
164+Response data: `{"models":[{"id":"...","provider":"...","context_window":200000,"max_output":8192,"reasoning":true}, ...]}`.
165+
166+### `ping`
167+
168+Health check.
169+
170+```json
171+{"id":"9","type":"ping"}
172+```
173+
174+Response: `{"type":"response","id":"9","command":"ping","success":true,"data":{"pong":true}}`.
175+
176+## Events
177+
178+Stream notifications during a `prompt` or `compact`. None carry an `id`.
179+
180+| `type` | Fields | Meaning |
181+|---|---|---|
182+| `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
183+| `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
184+| `assistant_start` | (none) | About to receive assistant streaming |
185+| `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply |
186+| `tool_call` | `id`, `name`, `args` | The model wants to call a tool |
187+| `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
188+| `tool_result` | `id`, `is_error`, `content` | Tool finished |
189+| `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
190+| `usage` | `input`, `output`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost |
191+| `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` |
192+| `done` | (none) | The whole prompt/compact completed (success or error) |
193+| `error` | `message` | Non-fatal error message |
194+| `compact_done` | `summary` | Compaction finished, summary text included |
195+
196+## Message shape
197+
198+Used by `get_messages` and inside `user_message` / `assistant_message` events.
199+
200+```json
201+{
202+ "role": "user",
203+ "content": [<content_block>...],
204+ "time": "2026-04-19T11:30:00Z"
205+}
206+```
207+
208+### Content block types
209+
210+```json
211+{"type": "text", "text": "..."}
212+{"type": "image", "mime_type": "image/png", "bytes": 12345}
213+{"type": "tool_call", "id": "toolu_xyz", "name": "read", "args": {"path": "..."}}
214+{"type": "tool_result", "call_id": "toolu_xyz", "is_error": false, "content": [<content_block>...]}
215+```
216+
217+Image bytes are reported as a count rather than embedded base64 to keep transcript dumps small. Tool results may nest text and image blocks.
218+
219+## Reference clients
220+
221+See `examples/rpc/` for working implementations in:
222+
223+- `shell` — `bash` + `jq` one-liner
224+- `python` — `subprocess.Popen` + `json.loads` per line
225+- `node` — `child_process.spawn` + `readline`
226+- `go` — direct subprocess wrapper (the `pkg/zotcore` SDK is the in-process Go API)
227+
228+## Versioning
229+
230+The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. The set of supported events and commands within a major version only grows.
@@ -0,0 +1,230 @@
1+# zot RPC
2+
3+`zot rpc` runs the agent runtime as a subprocess that speaks newline-delimited JSON on stdin and stdout. Use it from any language that can spawn a process and read/write its pipes — Go, TypeScript, Python, Rust, shell, anything.
4+
5+For a Go program embedding the runtime in-process, use the `pkg/zotcore` SDK instead. The wire format below mirrors the SDK's types one-for-one so consumers can share parsing code.
6+
7+## Quick start
8+
9+```bash
10+# spawn zot rpc; talk to it from a shell
11+( echo '{"id":"1","type":"prompt","message":"hello"}'; sleep 5 ) \
12+ | zot rpc --provider anthropic
13+```
14+
15+You'll see one JSON object per line on stdout: a response acknowledging the prompt, a stream of events (`text_delta`, `tool_call`, `tool_result`, `usage`), then `done`.
16+
17+## Process model
18+
19+- One `zot rpc` process serves **one cwd, one model, one session**.
20+- For multiple projects, spawn multiple processes.
21+- Concurrency: at most one prompt or compact in flight at a time. A second one queues until the first finishes; aborting fires immediately.
22+- The process exits when stdin closes.
23+
24+## Flags
25+
26+`zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
27+
28+## Auth
29+
30+If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token:
31+
32+```json
33+{"id":"0","type":"hello","token":"shared-secret"}
34+```
35+
36+If absent or wrong, the response carries `success:false` and the process exits. Without `ZOTCORE_RPC_TOKEN` set, no auth is required (the spawning process is implicitly trusted; if it can spawn `zot` it can also read your `auth.json` directly).
37+
38+## Wire format
39+
40+Every line in either direction is one JSON object terminated by `\n`. Object boundaries follow newline boundaries — no multi-line JSON.
41+
42+### Frame types
43+
44+| `type` | Direction | Description |
45+|---|---|---|
46+| any command (`prompt`, `abort`, …) | client → server | Request |
47+| `response` | server → client | Reply to one command, correlated by `id` |
48+| any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) |
49+
50+## Commands
51+
52+All commands share an optional `id` field; if present, the matching `response` echoes it. Use `id` to correlate replies with requests, especially when several requests are in flight.
53+
54+### `hello`
55+
56+```json
57+{"id":"0","type":"hello","token":"shared-secret"}
58+```
59+
60+Response:
61+
62+```json
63+{"type":"response","id":"0","command":"hello","success":true,
64+ "data":{"protocol_version":1,"version":"0.0.4","provider":"anthropic","model":"claude-opus-4-5"}}
65+```
66+
67+Required as the first message when `ZOTCORE_RPC_TOKEN` is set; optional otherwise.
68+
69+### `prompt`
70+
71+```json
72+{"id":"1","type":"prompt","message":"fix the failing test","images":[]}
73+```
74+
75+Optional `images` is `[{"mime_type":"image/png","data":"<base64>"}]`.
76+
77+Response is immediate (the turn is starting):
78+
79+```json
80+{"type":"response","id":"1","command":"prompt","success":true,"data":{"started":true}}
81+```
82+
83+Then a stream of event objects (see below) until the turn ends with `{"type":"done"}`.
84+
85+### `abort`
86+
87+Cancel the active prompt or compact.
88+
89+```json
90+{"id":"2","type":"abort"}
91+```
92+
93+Response: `{"type":"response","id":"2","command":"abort","success":true}`.
94+
95+If the turn was streaming, the next events you see will be a `turn_end` with `stop:"aborted"` then `done`.
96+
97+### `compact`
98+
99+Summarise the current transcript into one synthetic user message. Same lifecycle as `prompt` (immediate response, then events).
100+
101+```json
102+{"id":"3","type":"compact"}
103+```
104+
105+Final event: `{"type":"compact_done","summary":"<text>"}`.
106+
107+### `get_state`
108+
109+Snapshot of the runtime.
110+
111+```json
112+{"id":"4","type":"get_state"}
113+```
114+
115+Response data:
116+
117+```json
118+{
119+ "provider": "anthropic",
120+ "model": "claude-opus-4-5",
121+ "cwd": "/Users/pat/Developer/zot",
122+ "message_count": 12,
123+ "busy": false,
124+ "usage": {"input": 1234, "output": 567, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123}
125+}
126+```
127+
128+### `get_messages`
129+
130+Full transcript.
131+
132+```json
133+{"id":"5","type":"get_messages"}
134+```
135+
136+Response data: `{"messages": [<message>, ...]}`. See **message shape** below.
137+
138+### `clear`
139+
140+Drop the entire transcript. Equivalent to the `/clear` slash command.
141+
142+```json
143+{"id":"6","type":"clear"}
144+```
145+
146+### `set_model`
147+
148+Switch model within the same provider.
149+
150+```json
151+{"id":"7","type":"set_model","model":"claude-sonnet-4-5"}
152+```
153+
154+Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155+
156+### `get_models`
157+
158+List models known for the current provider.
159+
160+```json
161+{"id":"8","type":"get_models"}
162+```
163+
164+Response data: `{"models":[{"id":"...","provider":"...","context_window":200000,"max_output":8192,"reasoning":true}, ...]}`.
165+
166+### `ping`
167+
168+Health check.
169+
170+```json
171+{"id":"9","type":"ping"}
172+```
173+
174+Response: `{"type":"response","id":"9","command":"ping","success":true,"data":{"pong":true}}`.
175+
176+## Events
177+
178+Stream notifications during a `prompt` or `compact`. None carry an `id`.
179+
180+| `type` | Fields | Meaning |
181+|---|---|---|
182+| `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
183+| `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
184+| `assistant_start` | (none) | About to receive assistant streaming |
185+| `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply |
186+| `tool_call` | `id`, `name`, `args` | The model wants to call a tool |
187+| `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
188+| `tool_result` | `id`, `is_error`, `content` | Tool finished |
189+| `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
190+| `usage` | `input`, `output`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost |
191+| `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` |
192+| `done` | (none) | The whole prompt/compact completed (success or error) |
193+| `error` | `message` | Non-fatal error message |
194+| `compact_done` | `summary` | Compaction finished, summary text included |
195+
196+## Message shape
197+
198+Used by `get_messages` and inside `user_message` / `assistant_message` events.
199+
200+```json
201+{
202+ "role": "user",
203+ "content": [<content_block>...],
204+ "time": "2026-04-19T11:30:00Z"
205+}
206+```
207+
208+### Content block types
209+
210+```json
211+{"type": "text", "text": "..."}
212+{"type": "image", "mime_type": "image/png", "bytes": 12345}
213+{"type": "tool_call", "id": "toolu_xyz", "name": "read", "args": {"path": "..."}}
214+{"type": "tool_result", "call_id": "toolu_xyz", "is_error": false, "content": [<content_block>...]}
215+```
216+
217+Image bytes are reported as a count rather than embedded base64 to keep transcript dumps small. Tool results may nest text and image blocks.
218+
219+## Reference clients
220+
221+See `examples/rpc/` for working implementations in:
222+
223+- `shell` — `bash` + `jq` one-liner
224+- `python` — `subprocess.Popen` + `json.loads` per line
225+- `node` — `child_process.spawn` + `readline`
226+- `go` — direct subprocess wrapper (the `pkg/zotcore` SDK is the in-process Go API)
227+
228+## Versioning
229+
230+The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. The set of supported events and commands within a major version only grows.
A examples/rpc/go/main.go +75 -0
@@ -0,0 +1,75 @@
1+// Tiny Go program that drives `zot rpc` as a subprocess.
2+//
3+// For an in-process Go embedding, use github.com/patriceckhart/zot/pkg/zotcore
4+// instead — this example exists to show what consumers in OTHER
5+// languages have to do.
6+package main
7+
8+import (
9+ "bufio"
10+ "encoding/json"
11+ "fmt"
12+ "io"
13+ "os"
14+ "os/exec"
15+ "strings"
16+)
17+
18+func main() {
19+ if len(os.Args) < 2 {
20+ fmt.Fprintln(os.Stderr, "usage: rpcdemo <prompt>")
21+ os.Exit(2)
22+ }
23+ prompt := strings.Join(os.Args[1:], " ")
24+
25+ cmd := exec.Command("zot", "rpc")
26+ cmd.Stderr = os.Stderr
27+ stdin, err := cmd.StdinPipe()
28+ must(err)
29+ stdout, err := cmd.StdoutPipe()
30+ must(err)
31+ must(cmd.Start())
32+
33+ if tok := os.Getenv("ZOTCORE_RPC_TOKEN"); tok != "" {
34+ send(stdin, map[string]any{"id": "0", "type": "hello", "token": tok})
35+ }
36+ send(stdin, map[string]any{
37+ "id": "1",
38+ "type": "prompt",
39+ "message": prompt,
40+ })
41+
42+ sc := bufio.NewScanner(stdout)
43+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
44+ for sc.Scan() {
45+ var ev map[string]any
46+ if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
47+ continue
48+ }
49+ switch ev["type"] {
50+ case "text_delta":
51+ fmt.Print(ev["delta"])
52+ case "tool_call":
53+ fmt.Fprintf(os.Stderr, "\n[tool] %v\n", ev["name"])
54+ case "error":
55+ fmt.Fprintf(os.Stderr, "\n[error] %v\n", ev["message"])
56+ case "done":
57+ fmt.Println()
58+ stdin.Close()
59+ _ = cmd.Wait()
60+ return
61+ }
62+ }
63+}
64+
65+func send(w io.Writer, m map[string]any) {
66+ b, _ := json.Marshal(m)
67+ _, _ = w.Write(append(b, '\n'))
68+}
69+
70+func must(err error) {
71+ if err != nil {
72+ fmt.Fprintln(os.Stderr, err)
73+ os.Exit(1)
74+ }
75+}
@@ -0,0 +1,75 @@
1+// Tiny Go program that drives `zot rpc` as a subprocess.
2+//
3+// For an in-process Go embedding, use github.com/patriceckhart/zot/pkg/zotcore
4+// instead — this example exists to show what consumers in OTHER
5+// languages have to do.
6+package main
7+
8+import (
9+ "bufio"
10+ "encoding/json"
11+ "fmt"
12+ "io"
13+ "os"
14+ "os/exec"
15+ "strings"
16+)
17+
18+func main() {
19+ if len(os.Args) < 2 {
20+ fmt.Fprintln(os.Stderr, "usage: rpcdemo <prompt>")
21+ os.Exit(2)
22+ }
23+ prompt := strings.Join(os.Args[1:], " ")
24+
25+ cmd := exec.Command("zot", "rpc")
26+ cmd.Stderr = os.Stderr
27+ stdin, err := cmd.StdinPipe()
28+ must(err)
29+ stdout, err := cmd.StdoutPipe()
30+ must(err)
31+ must(cmd.Start())
32+
33+ if tok := os.Getenv("ZOTCORE_RPC_TOKEN"); tok != "" {
34+ send(stdin, map[string]any{"id": "0", "type": "hello", "token": tok})
35+ }
36+ send(stdin, map[string]any{
37+ "id": "1",
38+ "type": "prompt",
39+ "message": prompt,
40+ })
41+
42+ sc := bufio.NewScanner(stdout)
43+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
44+ for sc.Scan() {
45+ var ev map[string]any
46+ if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
47+ continue
48+ }
49+ switch ev["type"] {
50+ case "text_delta":
51+ fmt.Print(ev["delta"])
52+ case "tool_call":
53+ fmt.Fprintf(os.Stderr, "\n[tool] %v\n", ev["name"])
54+ case "error":
55+ fmt.Fprintf(os.Stderr, "\n[error] %v\n", ev["message"])
56+ case "done":
57+ fmt.Println()
58+ stdin.Close()
59+ _ = cmd.Wait()
60+ return
61+ }
62+ }
63+}
64+
65+func send(w io.Writer, m map[string]any) {
66+ b, _ := json.Marshal(m)
67+ _, _ = w.Write(append(b, '\n'))
68+}
69+
70+func must(err error) {
71+ if err != nil {
72+ fmt.Fprintln(os.Stderr, err)
73+ os.Exit(1)
74+ }
75+}
A examples/rpc/node/zot-client.js +106 -0
@@ -0,0 +1,106 @@
1+// Minimal Node client for the `zot rpc` JSON protocol.
2+//
3+// Usage:
4+// node zot-client.js "fix the failing test"
5+//
6+// Spawns `zot rpc`, sends one prompt, prints assistant text as it
7+// streams, and exits when the turn finishes. Pure stdlib — no
8+// dependencies. See docs/rpc.md for the full schema.
9+"use strict";
10+
11+const { spawn } = require("node:child_process");
12+const readline = require("node:readline");
13+const { randomBytes } = require("node:crypto");
14+
15+class ZotClient {
16+ constructor(...flags) {
17+ this.proc = spawn("zot", ["rpc", ...flags], {
18+ stdio: ["pipe", "pipe", "inherit"],
19+ env: process.env,
20+ });
21+ this.rl = readline.createInterface({
22+ input: this.proc.stdout,
23+ crlfDelay: Infinity,
24+ });
25+ }
26+
27+ send(command) {
28+ if (!command.id) command.id = randomBytes(4).toString("hex");
29+ this.proc.stdin.write(JSON.stringify(command) + "\n");
30+ return command.id;
31+ }
32+
33+ async *events() {
34+ for await (const line of this.rl) {
35+ const trimmed = line.trim();
36+ if (!trimmed) continue;
37+ try {
38+ yield JSON.parse(trimmed);
39+ } catch {
40+ // ignore garbled lines
41+ }
42+ }
43+ }
44+
45+ close() {
46+ try {
47+ this.proc.stdin.end();
48+ } catch {}
49+ }
50+}
51+
52+async function main() {
53+ const prompt = process.argv.slice(2).join(" ");
54+ if (!prompt) {
55+ console.error("usage: node zot-client.js <prompt>");
56+ process.exit(2);
57+ }
58+
59+ const client = new ZotClient();
60+
61+ const token = process.env.ZOTCORE_RPC_TOKEN;
62+ if (token) client.send({ type: "hello", token });
63+
64+ client.send({ type: "prompt", message: prompt });
65+
66+ let exitCode = 0;
67+ try {
68+ for await (const ev of client.events()) {
69+ switch (ev.type) {
70+ case "text_delta":
71+ process.stdout.write(ev.delta || "");
72+ break;
73+ case "tool_call":
74+ process.stderr.write(
75+ `\n[tool] ${ev.name}(${JSON.stringify(ev.args || {})})`,
76+ );
77+ break;
78+ case "tool_result":
79+ if (ev.is_error) process.stderr.write("\n[tool error]");
80+ break;
81+ case "usage": {
82+ const cum = ev.cumulative || {};
83+ process.stderr.write(
84+ `\n[usage] cum input=${cum.input} output=${cum.output} cost=$${(cum.cost_usd || 0).toFixed(4)}`,
85+ );
86+ break;
87+ }
88+ case "error":
89+ process.stderr.write(`\n[error] ${ev.message}\n`);
90+ exitCode = 1;
91+ break;
92+ case "done":
93+ process.stdout.write("\n");
94+ client.close();
95+ process.exit(exitCode);
96+ }
97+ }
98+ } finally {
99+ client.close();
100+ }
101+}
102+
103+main().catch((e) => {
104+ console.error(e);
105+ process.exit(1);
106+});
@@ -0,0 +1,106 @@
1+// Minimal Node client for the `zot rpc` JSON protocol.
2+//
3+// Usage:
4+// node zot-client.js "fix the failing test"
5+//
6+// Spawns `zot rpc`, sends one prompt, prints assistant text as it
7+// streams, and exits when the turn finishes. Pure stdlib — no
8+// dependencies. See docs/rpc.md for the full schema.
9+"use strict";
10+
11+const { spawn } = require("node:child_process");
12+const readline = require("node:readline");
13+const { randomBytes } = require("node:crypto");
14+
15+class ZotClient {
16+ constructor(...flags) {
17+ this.proc = spawn("zot", ["rpc", ...flags], {
18+ stdio: ["pipe", "pipe", "inherit"],
19+ env: process.env,
20+ });
21+ this.rl = readline.createInterface({
22+ input: this.proc.stdout,
23+ crlfDelay: Infinity,
24+ });
25+ }
26+
27+ send(command) {
28+ if (!command.id) command.id = randomBytes(4).toString("hex");
29+ this.proc.stdin.write(JSON.stringify(command) + "\n");
30+ return command.id;
31+ }
32+
33+ async *events() {
34+ for await (const line of this.rl) {
35+ const trimmed = line.trim();
36+ if (!trimmed) continue;
37+ try {
38+ yield JSON.parse(trimmed);
39+ } catch {
40+ // ignore garbled lines
41+ }
42+ }
43+ }
44+
45+ close() {
46+ try {
47+ this.proc.stdin.end();
48+ } catch {}
49+ }
50+}
51+
52+async function main() {
53+ const prompt = process.argv.slice(2).join(" ");
54+ if (!prompt) {
55+ console.error("usage: node zot-client.js <prompt>");
56+ process.exit(2);
57+ }
58+
59+ const client = new ZotClient();
60+
61+ const token = process.env.ZOTCORE_RPC_TOKEN;
62+ if (token) client.send({ type: "hello", token });
63+
64+ client.send({ type: "prompt", message: prompt });
65+
66+ let exitCode = 0;
67+ try {
68+ for await (const ev of client.events()) {
69+ switch (ev.type) {
70+ case "text_delta":
71+ process.stdout.write(ev.delta || "");
72+ break;
73+ case "tool_call":
74+ process.stderr.write(
75+ `\n[tool] ${ev.name}(${JSON.stringify(ev.args || {})})`,
76+ );
77+ break;
78+ case "tool_result":
79+ if (ev.is_error) process.stderr.write("\n[tool error]");
80+ break;
81+ case "usage": {
82+ const cum = ev.cumulative || {};
83+ process.stderr.write(
84+ `\n[usage] cum input=${cum.input} output=${cum.output} cost=$${(cum.cost_usd || 0).toFixed(4)}`,
85+ );
86+ break;
87+ }
88+ case "error":
89+ process.stderr.write(`\n[error] ${ev.message}\n`);
90+ exitCode = 1;
91+ break;
92+ case "done":
93+ process.stdout.write("\n");
94+ client.close();
95+ process.exit(exitCode);
96+ }
97+ }
98+ } finally {
99+ client.close();
100+ }
101+}
102+
103+main().catch((e) => {
104+ console.error(e);
105+ process.exit(1);
106+});
A examples/rpc/python/zot_client.py +114 -0
@@ -0,0 +1,114 @@
1+"""Minimal Python client for the `zot rpc` JSON protocol.
2+
3+Usage:
4+ python zot_client.py "fix the failing test"
5+
6+Spawns `zot rpc`, sends one prompt, prints assistant text as it streams,
7+and exits when the turn finishes. No external dependencies — stdlib
8+only. Implements just enough of the protocol to be useful as a
9+starting point; see docs/rpc.md for the full schema.
10+"""
11+from __future__ import annotations
12+
13+import json
14+import os
15+import subprocess
16+import sys
17+import threading
18+import uuid
19+
20+
21+class ZotClient:
22+ def __init__(self, *flags: str) -> None:
23+ argv = ["zot", "rpc", *flags]
24+ env = os.environ.copy()
25+ self.proc = subprocess.Popen(
26+ argv,
27+ stdin=subprocess.PIPE,
28+ stdout=subprocess.PIPE,
29+ stderr=sys.stderr,
30+ env=env,
31+ text=True,
32+ bufsize=1, # line-buffered
33+ )
34+ self._lock = threading.Lock()
35+
36+ def send(self, **command) -> str:
37+ """Send one command, return its `id`."""
38+ if "id" not in command:
39+ command["id"] = uuid.uuid4().hex[:8]
40+ line = json.dumps(command)
41+ with self._lock:
42+ assert self.proc.stdin is not None
43+ self.proc.stdin.write(line + "\n")
44+ self.proc.stdin.flush()
45+ return command["id"]
46+
47+ def events(self):
48+ """Yield every JSON object the server emits, until stdout closes."""
49+ assert self.proc.stdout is not None
50+ for line in self.proc.stdout:
51+ line = line.strip()
52+ if not line:
53+ continue
54+ try:
55+ yield json.loads(line)
56+ except json.JSONDecodeError:
57+ continue
58+
59+ def close(self) -> None:
60+ if self.proc.stdin is not None:
61+ try:
62+ self.proc.stdin.close()
63+ except Exception:
64+ pass
65+ self.proc.wait(timeout=5)
66+
67+
68+def main() -> int:
69+ if len(sys.argv) < 2:
70+ print("usage: zot_client.py <prompt>", file=sys.stderr)
71+ return 2
72+ prompt = " ".join(sys.argv[1:])
73+
74+ client = ZotClient()
75+ try:
76+ token = os.environ.get("ZOTCORE_RPC_TOKEN")
77+ if token:
78+ client.send(type="hello", token=token)
79+
80+ client.send(type="prompt", message=prompt)
81+
82+ for ev in client.events():
83+ t = ev.get("type")
84+ if t == "text_delta":
85+ sys.stdout.write(ev.get("delta", ""))
86+ sys.stdout.flush()
87+ elif t == "tool_call":
88+ print(
89+ f"\n[tool] {ev.get('name')}({json.dumps(ev.get('args', {}))})",
90+ file=sys.stderr,
91+ )
92+ elif t == "tool_result":
93+ if ev.get("is_error"):
94+ print("[tool error]", file=sys.stderr)
95+ elif t == "usage":
96+ cum = ev.get("cumulative", {})
97+ print(
98+ f"\n[usage] cum input={cum.get('input')} "
99+ f"output={cum.get('output')} cost=${cum.get('cost_usd', 0):.4f}",
100+ file=sys.stderr,
101+ )
102+ elif t == "done":
103+ break
104+ elif t == "error":
105+ print(f"\n[error] {ev.get('message')}", file=sys.stderr)
106+ return 1
107+ print()
108+ return 0
109+ finally:
110+ client.close()
111+
112+
113+if __name__ == "__main__":
114+ sys.exit(main())
@@ -0,0 +1,114 @@
1+"""Minimal Python client for the `zot rpc` JSON protocol.
2+
3+Usage:
4+ python zot_client.py "fix the failing test"
5+
6+Spawns `zot rpc`, sends one prompt, prints assistant text as it streams,
7+and exits when the turn finishes. No external dependencies — stdlib
8+only. Implements just enough of the protocol to be useful as a
9+starting point; see docs/rpc.md for the full schema.
10+"""
11+from __future__ import annotations
12+
13+import json
14+import os
15+import subprocess
16+import sys
17+import threading
18+import uuid
19+
20+
21+class ZotClient:
22+ def __init__(self, *flags: str) -> None:
23+ argv = ["zot", "rpc", *flags]
24+ env = os.environ.copy()
25+ self.proc = subprocess.Popen(
26+ argv,
27+ stdin=subprocess.PIPE,
28+ stdout=subprocess.PIPE,
29+ stderr=sys.stderr,
30+ env=env,
31+ text=True,
32+ bufsize=1, # line-buffered
33+ )
34+ self._lock = threading.Lock()
35+
36+ def send(self, **command) -> str:
37+ """Send one command, return its `id`."""
38+ if "id" not in command:
39+ command["id"] = uuid.uuid4().hex[:8]
40+ line = json.dumps(command)
41+ with self._lock:
42+ assert self.proc.stdin is not None
43+ self.proc.stdin.write(line + "\n")
44+ self.proc.stdin.flush()
45+ return command["id"]
46+
47+ def events(self):
48+ """Yield every JSON object the server emits, until stdout closes."""
49+ assert self.proc.stdout is not None
50+ for line in self.proc.stdout:
51+ line = line.strip()
52+ if not line:
53+ continue
54+ try:
55+ yield json.loads(line)
56+ except json.JSONDecodeError:
57+ continue
58+
59+ def close(self) -> None:
60+ if self.proc.stdin is not None:
61+ try:
62+ self.proc.stdin.close()
63+ except Exception:
64+ pass
65+ self.proc.wait(timeout=5)
66+
67+
68+def main() -> int:
69+ if len(sys.argv) < 2:
70+ print("usage: zot_client.py <prompt>", file=sys.stderr)
71+ return 2
72+ prompt = " ".join(sys.argv[1:])
73+
74+ client = ZotClient()
75+ try:
76+ token = os.environ.get("ZOTCORE_RPC_TOKEN")
77+ if token:
78+ client.send(type="hello", token=token)
79+
80+ client.send(type="prompt", message=prompt)
81+
82+ for ev in client.events():
83+ t = ev.get("type")
84+ if t == "text_delta":
85+ sys.stdout.write(ev.get("delta", ""))
86+ sys.stdout.flush()
87+ elif t == "tool_call":
88+ print(
89+ f"\n[tool] {ev.get('name')}({json.dumps(ev.get('args', {}))})",
90+ file=sys.stderr,
91+ )
92+ elif t == "tool_result":
93+ if ev.get("is_error"):
94+ print("[tool error]", file=sys.stderr)
95+ elif t == "usage":
96+ cum = ev.get("cumulative", {})
97+ print(
98+ f"\n[usage] cum input={cum.get('input')} "
99+ f"output={cum.get('output')} cost=${cum.get('cost_usd', 0):.4f}",
100+ file=sys.stderr,
101+ )
102+ elif t == "done":
103+ break
104+ elif t == "error":
105+ print(f"\n[error] {ev.get('message')}", file=sys.stderr)
106+ return 1
107+ print()
108+ return 0
109+ finally:
110+ client.close()
111+
112+
113+if __name__ == "__main__":
114+ sys.exit(main())
A examples/rpc/shell/prompt.sh +51 -0
@@ -0,0 +1,51 @@
1+#!/usr/bin/env bash
2+# Minimal shell client for the `zot rpc` JSON protocol.
3+#
4+# Usage:
5+# ./prompt.sh "fix the failing test"
6+#
7+# Sends one prompt, streams assistant text deltas to stdout, exits
8+# when the turn finishes. Requires `jq`.
9+
10+set -euo pipefail
11+
12+if [ $# -lt 1 ]; then
13+ echo "usage: $0 <prompt>" >&2
14+ exit 2
15+fi
16+prompt="$*"
17+
18+# Build the prompt command frame with jq so quotes are escaped properly.
19+cmd=$(jq -nc --arg msg "$prompt" '{id:"1",type:"prompt",message:$msg}')
20+
21+# Pipe the command into zot rpc; pipe its stdout through jq to react
22+# to events. The trailing `cat` keeps the input pipe open until the
23+# subprocess exits on its own (after `done`).
24+{
25+ if [ -n "${ZOTCORE_RPC_TOKEN:-}" ]; then
26+ jq -nc --arg t "$ZOTCORE_RPC_TOKEN" '{id:"0",type:"hello",token:$t}'
27+ fi
28+ echo "$cmd"
29+ # Block here so stdin stays open until zot exits.
30+ cat
31+} | zot rpc | while IFS= read -r line; do
32+ type=$(echo "$line" | jq -r '.type // empty')
33+ case "$type" in
34+ text_delta)
35+ echo "$line" | jq -rj '.delta // ""'
36+ ;;
37+ tool_call)
38+ name=$(echo "$line" | jq -r '.name // ""')
39+ echo >&2 ""
40+ echo >&2 "[tool] $name"
41+ ;;
42+ error)
43+ echo >&2 "[error] $(echo "$line" | jq -r '.message // ""')"
44+ exit 1
45+ ;;
46+ done)
47+ echo
48+ exit 0
49+ ;;
50+ esac
51+done
@@ -0,0 +1,51 @@
1+#!/usr/bin/env bash
2+# Minimal shell client for the `zot rpc` JSON protocol.
3+#
4+# Usage:
5+# ./prompt.sh "fix the failing test"
6+#
7+# Sends one prompt, streams assistant text deltas to stdout, exits
8+# when the turn finishes. Requires `jq`.
9+
10+set -euo pipefail
11+
12+if [ $# -lt 1 ]; then
13+ echo "usage: $0 <prompt>" >&2
14+ exit 2
15+fi
16+prompt="$*"
17+
18+# Build the prompt command frame with jq so quotes are escaped properly.
19+cmd=$(jq -nc --arg msg "$prompt" '{id:"1",type:"prompt",message:$msg}')
20+
21+# Pipe the command into zot rpc; pipe its stdout through jq to react
22+# to events. The trailing `cat` keeps the input pipe open until the
23+# subprocess exits on its own (after `done`).
24+{
25+ if [ -n "${ZOTCORE_RPC_TOKEN:-}" ]; then
26+ jq -nc --arg t "$ZOTCORE_RPC_TOKEN" '{id:"0",type:"hello",token:$t}'
27+ fi
28+ echo "$cmd"
29+ # Block here so stdin stays open until zot exits.
30+ cat
31+} | zot rpc | while IFS= read -r line; do
32+ type=$(echo "$line" | jq -r '.type // empty')
33+ case "$type" in
34+ text_delta)
35+ echo "$line" | jq -rj '.delta // ""'
36+ ;;
37+ tool_call)
38+ name=$(echo "$line" | jq -r '.name // ""')
39+ echo >&2 ""
40+ echo >&2 "[tool] $name"
41+ ;;
42+ error)
43+ echo >&2 "[error] $(echo "$line" | jq -r '.message // ""')"
44+ exit 1
45+ ;;
46+ done)
47+ echo
48+ exit 0
49+ ;;
50+ esac
51+done
A examples/sdk/main.go +45 -0
@@ -0,0 +1,45 @@
1+// In-process Go embedding of the zot agent runtime via the zotcore SDK.
2+// Compare to examples/rpc/go which spawns `zot rpc` as a subprocess.
3+package main
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+
11+ "github.com/patriceckhart/zot/pkg/zotcore"
12+)
13+
14+func main() {
15+ if len(os.Args) < 2 {
16+ fmt.Fprintln(os.Stderr, "usage: sdkdemo <prompt>")
17+ os.Exit(2)
18+ }
19+ prompt := strings.Join(os.Args[1:], " ")
20+
21+ rt, err := zotcore.New(zotcore.Config{})
22+ if err != nil {
23+ fmt.Fprintln(os.Stderr, err)
24+ os.Exit(1)
25+ }
26+ defer rt.Close()
27+
28+ events, err := rt.Prompt(context.Background(), prompt, nil)
29+ if err != nil {
30+ fmt.Fprintln(os.Stderr, err)
31+ os.Exit(1)
32+ }
33+ for ev := range events {
34+ switch ev.Type {
35+ case "text_delta":
36+ fmt.Print(ev.Delta)
37+ case "tool_call":
38+ fmt.Fprintf(os.Stderr, "\n[tool] %s\n", ev.Name)
39+ case "error":
40+ fmt.Fprintf(os.Stderr, "\n[error] %s\n", ev.Error)
41+ }
42+ }
43+ fmt.Println()
44+ fmt.Fprintf(os.Stderr, "cost so far: $%.4f\n", rt.Cost().CostUSD)
45+}
@@ -0,0 +1,45 @@
1+// In-process Go embedding of the zot agent runtime via the zotcore SDK.
2+// Compare to examples/rpc/go which spawns `zot rpc` as a subprocess.
3+package main
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+
11+ "github.com/patriceckhart/zot/pkg/zotcore"
12+)
13+
14+func main() {
15+ if len(os.Args) < 2 {
16+ fmt.Fprintln(os.Stderr, "usage: sdkdemo <prompt>")
17+ os.Exit(2)
18+ }
19+ prompt := strings.Join(os.Args[1:], " ")
20+
21+ rt, err := zotcore.New(zotcore.Config{})
22+ if err != nil {
23+ fmt.Fprintln(os.Stderr, err)
24+ os.Exit(1)
25+ }
26+ defer rt.Close()
27+
28+ events, err := rt.Prompt(context.Background(), prompt, nil)
29+ if err != nil {
30+ fmt.Fprintln(os.Stderr, err)
31+ os.Exit(1)
32+ }
33+ for ev := range events {
34+ switch ev.Type {
35+ case "text_delta":
36+ fmt.Print(ev.Delta)
37+ case "tool_call":
38+ fmt.Fprintf(os.Stderr, "\n[tool] %s\n", ev.Name)
39+ case "error":
40+ fmt.Fprintf(os.Stderr, "\n[error] %s\n", ev.Error)
41+ }
42+ }
43+ fmt.Println()
44+ fmt.Fprintf(os.Stderr, "cost so far: $%.4f\n", rt.Cost().CostUSD)
45+}
M internal/agent/args.go +4 -0
@@ -13,6 +13,7 @@ const (
1313 ModeInteractive Mode = "interactive"
1414 ModePrint Mode = "print"
1515 ModeJSON Mode = "json"
16+ ModeRPC Mode = "rpc"
1617 )
1718
1819 // Args holds parsed command-line options.
@@ -68,6 +69,8 @@ func ParseArgs(in []string) (Args, error) {
6869 a.Mode = ModePrint
6970 case "--json":
7071 a.Mode = ModeJSON
72+ case "--rpc":
73+ a.Mode = ModeRPC
7174 case "-c", "--continue":
7275 a.Continue = true
7376 case "-r", "--resume":
@@ -188,6 +191,7 @@ usage:
188191 zot "prompt" interactive, pre-filled prompt
189192 zot -p "prompt" print final text, exit
190193 zot --json "prompt" newline-delimited json events, exit
194+ zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md)
191195 zot telegram-bot setup configure a telegram bot (from BotFather)
192196 zot telegram-bot run foreground bridge (ctrl+c to stop)
193197 zot telegram-bot start background bridge (detached)
@@ -13,6 +13,7 @@ const (
13 ModeInteractive Mode = "interactive"13 ModeInteractive Mode = "interactive"
14 ModePrint Mode = "print"14 ModePrint Mode = "print"
15 ModeJSON Mode = "json"15 ModeJSON Mode = "json"
16+ ModeRPC Mode = "rpc"
16 )17 )
17 18
18 // Args holds parsed command-line options.19 // Args holds parsed command-line options.
@@ -68,6 +69,8 @@ func ParseArgs(in []string) (Args, error) {
68 a.Mode = ModePrint69 a.Mode = ModePrint
69 case "--json":70 case "--json":
70 a.Mode = ModeJSON71 a.Mode = ModeJSON
72+ case "--rpc":
73+ a.Mode = ModeRPC
71 case "-c", "--continue":74 case "-c", "--continue":
72 a.Continue = true75 a.Continue = true
73 case "-r", "--resume":76 case "-r", "--resume":
@@ -188,6 +191,7 @@ usage:
188 zot "prompt" interactive, pre-filled prompt191 zot "prompt" interactive, pre-filled prompt
189 zot -p "prompt" print final text, exit192 zot -p "prompt" print final text, exit
190 zot --json "prompt" newline-delimited json events, exit193 zot --json "prompt" newline-delimited json events, exit
194+ zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md)
191 zot telegram-bot setup configure a telegram bot (from BotFather)195 zot telegram-bot setup configure a telegram bot (from BotFather)
192 zot telegram-bot run foreground bridge (ctrl+c to stop)196 zot telegram-bot run foreground bridge (ctrl+c to stop)
193 zot telegram-bot start background bridge (detached)197 zot telegram-bot start background bridge (detached)
M internal/agent/cli.go +8 -0
@@ -22,6 +22,12 @@ func Run(rawArgs []string, version string) error {
2222 if handled, err := runBotCommand(rawArgs, version); handled {
2323 return err
2424 }
25+ // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
26+ // spawn the binary with a clean argv. Strip the leading 'rpc'
27+ // token and let the rest flow through the normal arg parser.
28+ if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
29+ rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
30+ }
2531
2632 args, err := ParseArgs(rawArgs)
2733 if err != nil {
@@ -56,6 +62,8 @@ func Run(rawArgs []string, version string) error {
5662 return runPrintMode(ctx, args, version)
5763 case ModeJSON:
5864 return runJSONMode(ctx, args, version)
65+ case ModeRPC:
66+ return runRPCMode(ctx, args, version)
5967 default:
6068 return runInteractive(ctx, args, version)
6169 }
@@ -22,6 +22,12 @@ func Run(rawArgs []string, version string) error {
22 if handled, err := runBotCommand(rawArgs, version); handled {22 if handled, err := runBotCommand(rawArgs, version); handled {
23 return err23 return err
24 }24 }
25+ // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
26+ // spawn the binary with a clean argv. Strip the leading 'rpc'
27+ // token and let the rest flow through the normal arg parser.
28+ if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
29+ rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
30+ }
25 31
26 args, err := ParseArgs(rawArgs)32 args, err := ParseArgs(rawArgs)
27 if err != nil {33 if err != nil {
@@ -56,6 +62,8 @@ func Run(rawArgs []string, version string) error {
56 return runPrintMode(ctx, args, version)62 return runPrintMode(ctx, args, version)
57 case ModeJSON:63 case ModeJSON:
58 return runJSONMode(ctx, args, version)64 return runJSONMode(ctx, args, version)
65+ case ModeRPC:
66+ return runRPCMode(ctx, args, version)
59 default:67 default:
60 return runInteractive(ctx, args, version)68 return runInteractive(ctx, args, version)
61 }69 }
M internal/agent/modes/interactive.go +74 -4
@@ -135,6 +135,11 @@ type Interactive struct {
135135 // don't forget they're looking at history.
136136 parkedTurn int
137137 parkedTotal int
138+
139+ // lastCtrlC is when the user last pressed ctrl+c. The first press
140+ // clears the editor / cancels a turn / shows a hint; a second press
141+ // within ctrlCExitWindow exits. Mirrors the python-repl convention.
142+ lastCtrlC time.Time
138143 }
139144
140145 // NewInteractive constructs an Interactive from cfg.
@@ -481,7 +486,7 @@ func (i *Interactive) redraw() {
481486 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
482487 ctxMax = m.ContextWindow
483488 }
484- status := tui.StatusBar(tui.StatusBarParams{
489+ statusLines := tui.StatusBar(tui.StatusBarParams{
485490 Theme: i.cfg.Theme,
486491 Provider: i.cfg.Provider,
487492 Model: i.cfg.Model,
@@ -515,7 +520,7 @@ func (i *Interactive) redraw() {
515520 bottom = append(bottom, dialog...)
516521 bottom = append(bottom, suggest...)
517522 bottom = append(bottom, queue...)
518- bottom = append(bottom, status)
523+ bottom = append(bottom, statusLines...)
519524 bottom = append(bottom, edLines...)
520525
521526 _, rows := i.cfg.Terminal.Size()
@@ -571,7 +576,7 @@ func (i *Interactive) redraw() {
571576 frame = append(frame, visibleChat...)
572577 frame = append(frame, bottom...)
573578
574- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
579+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
575580 cursorCol := curC
576581 i.rend.Draw(frame, cursorRow, cursorCol)
577582 }
@@ -595,7 +600,44 @@ func truncateLine(s string, n int) string {
595600 return string(runes[:n-1]) + "…"
596601 }
597602
603+// ctrlCExitWindow is how long after a ctrl+c press a *second* press
604+// will exit instead of just clearing input. Long enough to be
605+// deliberate (rules out accidental key chord), short enough that the
606+// hint stays meaningful.
607+const ctrlCExitWindow = 2 * time.Second
608+
609+// armCtrlCExit records the timestamp of the current ctrl+c so the next
610+// one within ctrlCExitWindow exits.
611+func (i *Interactive) armCtrlCExit() {
612+ i.mu.Lock()
613+ i.lastCtrlC = time.Now()
614+ i.mu.Unlock()
615+}
616+
617+// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
618+// that another press should now exit.
619+func (i *Interactive) ctrlCExitArmed() bool {
620+ i.mu.Lock()
621+ t := i.lastCtrlC
622+ i.mu.Unlock()
623+ return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
624+}
625+
598626 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
627+ // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
628+ // pressing ctrl+c then typing then ctrl+c much later doesn't quit
629+ // unexpectedly. The hint message also goes stale; clear it.
630+ if k.Kind != tui.KeyCtrlC {
631+ i.mu.Lock()
632+ if !i.lastCtrlC.IsZero() {
633+ i.lastCtrlC = time.Time{}
634+ if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
635+ i.statusOK = ""
636+ }
637+ }
638+ i.mu.Unlock()
639+ }
640+
599641 // Dialogs consume keys while open (except ctrl+c, which always closes them).
600642 if i.dialog.Active() {
601643 if k.Kind == tui.KeyCtrlC {
@@ -651,11 +693,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
651693 // Global keys.
652694 switch k.Kind {
653695 case tui.KeyCtrlC:
696+ // While busy: cancel the active turn (same as esc). The exit
697+ // hint stays armed so a quick second ctrl+c after the turn
698+ // dies still exits, matching habits from other repls.
654699 if i.busy && i.cancelTurn != nil {
655700 i.cancelTurn()
701+ i.armCtrlCExit()
656702 return false
657703 }
658- return true
704+ // Idle: first press clears the editor (and any queued
705+ // follow-up messages); a second press within ctrlCExitWindow
706+ // exits. With both an empty editor and no queue the first
707+ // press still just arms — require a deliberate double-tap.
708+ hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
709+ if hadInput {
710+ i.ed.Clear()
711+ i.suggest.Reset()
712+ i.mu.Lock()
713+ i.queued = nil
714+ i.statusOK = "input cleared — ctrl+c again to exit"
715+ i.statusErr = ""
716+ i.mu.Unlock()
717+ i.armCtrlCExit()
718+ return false
719+ }
720+ if i.ctrlCExitArmed() {
721+ return true
722+ }
723+ i.mu.Lock()
724+ i.statusOK = "press ctrl+c again to exit"
725+ i.statusErr = ""
726+ i.mu.Unlock()
727+ i.armCtrlCExit()
728+ return false
659729 case tui.KeyEsc:
660730 // Esc interrupts a running turn. When idle, fall through so the
661731 // editor can clear itself.
@@ -135,6 +135,11 @@ type Interactive struct {
135 // don't forget they're looking at history.135 // don't forget they're looking at history.
136 parkedTurn int136 parkedTurn int
137 parkedTotal int137 parkedTotal int
138+
139+ // lastCtrlC is when the user last pressed ctrl+c. The first press
140+ // clears the editor / cancels a turn / shows a hint; a second press
141+ // within ctrlCExitWindow exits. Mirrors the python-repl convention.
142+ lastCtrlC time.Time
138 }143 }
139 144
140 // NewInteractive constructs an Interactive from cfg.145 // NewInteractive constructs an Interactive from cfg.
@@ -481,7 +486,7 @@ func (i *Interactive) redraw() {
481 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {486 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
482 ctxMax = m.ContextWindow487 ctxMax = m.ContextWindow
483 }488 }
484- status := tui.StatusBar(tui.StatusBarParams{489+ statusLines := tui.StatusBar(tui.StatusBarParams{
485 Theme: i.cfg.Theme,490 Theme: i.cfg.Theme,
486 Provider: i.cfg.Provider,491 Provider: i.cfg.Provider,
487 Model: i.cfg.Model,492 Model: i.cfg.Model,
@@ -515,7 +520,7 @@ func (i *Interactive) redraw() {
515 bottom = append(bottom, dialog...)520 bottom = append(bottom, dialog...)
516 bottom = append(bottom, suggest...)521 bottom = append(bottom, suggest...)
517 bottom = append(bottom, queue...)522 bottom = append(bottom, queue...)
518- bottom = append(bottom, status)523+ bottom = append(bottom, statusLines...)
519 bottom = append(bottom, edLines...)524 bottom = append(bottom, edLines...)
520 525
521 _, rows := i.cfg.Terminal.Size()526 _, rows := i.cfg.Terminal.Size()
@@ -571,7 +576,7 @@ func (i *Interactive) redraw() {
571 frame = append(frame, visibleChat...)576 frame = append(frame, visibleChat...)
572 frame = append(frame, bottom...)577 frame = append(frame, bottom...)
573 578
574- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR579+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
575 cursorCol := curC580 cursorCol := curC
576 i.rend.Draw(frame, cursorRow, cursorCol)581 i.rend.Draw(frame, cursorRow, cursorCol)
577 }582 }
@@ -595,7 +600,44 @@ func truncateLine(s string, n int) string {
595 return string(runes[:n-1]) + "…"600 return string(runes[:n-1]) + "…"
596 }601 }
597 602
603+// ctrlCExitWindow is how long after a ctrl+c press a *second* press
604+// will exit instead of just clearing input. Long enough to be
605+// deliberate (rules out accidental key chord), short enough that the
606+// hint stays meaningful.
607+const ctrlCExitWindow = 2 * time.Second
608+
609+// armCtrlCExit records the timestamp of the current ctrl+c so the next
610+// one within ctrlCExitWindow exits.
611+func (i *Interactive) armCtrlCExit() {
612+ i.mu.Lock()
613+ i.lastCtrlC = time.Now()
614+ i.mu.Unlock()
615+}
616+
617+// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
618+// that another press should now exit.
619+func (i *Interactive) ctrlCExitArmed() bool {
620+ i.mu.Lock()
621+ t := i.lastCtrlC
622+ i.mu.Unlock()
623+ return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
624+}
625+
598 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {626 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
627+ // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
628+ // pressing ctrl+c then typing then ctrl+c much later doesn't quit
629+ // unexpectedly. The hint message also goes stale; clear it.
630+ if k.Kind != tui.KeyCtrlC {
631+ i.mu.Lock()
632+ if !i.lastCtrlC.IsZero() {
633+ i.lastCtrlC = time.Time{}
634+ if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
635+ i.statusOK = ""
636+ }
637+ }
638+ i.mu.Unlock()
639+ }
640+
599 // Dialogs consume keys while open (except ctrl+c, which always closes them).641 // Dialogs consume keys while open (except ctrl+c, which always closes them).
600 if i.dialog.Active() {642 if i.dialog.Active() {
601 if k.Kind == tui.KeyCtrlC {643 if k.Kind == tui.KeyCtrlC {
@@ -651,11 +693,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
651 // Global keys.693 // Global keys.
652 switch k.Kind {694 switch k.Kind {
653 case tui.KeyCtrlC:695 case tui.KeyCtrlC:
696+ // While busy: cancel the active turn (same as esc). The exit
697+ // hint stays armed so a quick second ctrl+c after the turn
698+ // dies still exits, matching habits from other repls.
654 if i.busy && i.cancelTurn != nil {699 if i.busy && i.cancelTurn != nil {
655 i.cancelTurn()700 i.cancelTurn()
701+ i.armCtrlCExit()
656 return false702 return false
657 }703 }
658- return true704+ // Idle: first press clears the editor (and any queued
705+ // follow-up messages); a second press within ctrlCExitWindow
706+ // exits. With both an empty editor and no queue the first
707+ // press still just arms — require a deliberate double-tap.
708+ hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
709+ if hadInput {
710+ i.ed.Clear()
711+ i.suggest.Reset()
712+ i.mu.Lock()
713+ i.queued = nil
714+ i.statusOK = "input cleared — ctrl+c again to exit"
715+ i.statusErr = ""
716+ i.mu.Unlock()
717+ i.armCtrlCExit()
718+ return false
719+ }
720+ if i.ctrlCExitArmed() {
721+ return true
722+ }
723+ i.mu.Lock()
724+ i.statusOK = "press ctrl+c again to exit"
725+ i.statusErr = ""
726+ i.mu.Unlock()
727+ i.armCtrlCExit()
728+ return false
659 case tui.KeyEsc:729 case tui.KeyEsc:
660 // Esc interrupts a running turn. When idle, fall through so the730 // Esc interrupts a running turn. When idle, fall through so the
661 // editor can clear itself.731 // editor can clear itself.
M internal/agent/modes/json.go +13 -9
@@ -20,7 +20,7 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
2020
2121 var runErr error
2222 sink := func(ev core.AgentEvent) {
23- write(eventToJSON(ev))
23+ write(EventToJSON(ev))
2424 }
2525
2626 if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
@@ -33,18 +33,19 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
3333 return runErr
3434 }
3535
36-// eventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
37-// schema is deliberately simple and flat.
38-func eventToJSON(ev core.AgentEvent) map[string]any {
36+// EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
37+// schema is deliberately simple and flat. Exported so the RPC mode can
38+// reuse the same serialisation as `zot --json`.
39+func EventToJSON(ev core.AgentEvent) map[string]any {
3940 m := map[string]any{"type": ev.Type()}
4041 switch e := ev.(type) {
4142 case core.EvTurnStart:
4243 m["step"] = e.Step
4344 case core.EvUserMessage:
44- m["content"] = contentToJSON(e.Message.Content)
45+ m["content"] = ContentToJSON(e.Message.Content)
4546 m["time"] = e.Message.Time
4647 case core.EvAssistantMessage:
47- m["content"] = contentToJSON(e.Message.Content)
48+ m["content"] = ContentToJSON(e.Message.Content)
4849 m["time"] = e.Message.Time
4950 case core.EvTextDelta:
5051 m["delta"] = e.Delta
@@ -60,7 +61,7 @@ func eventToJSON(ev core.AgentEvent) map[string]any {
6061 case core.EvToolResult:
6162 m["id"] = e.ID
6263 m["is_error"] = e.Result.IsError
63- m["content"] = contentToJSON(e.Result.Content)
64+ m["content"] = ContentToJSON(e.Result.Content)
6465 case core.EvUsage:
6566 m["input"] = e.Usage.InputTokens
6667 m["output"] = e.Usage.OutputTokens
@@ -83,7 +84,10 @@ func eventToJSON(ev core.AgentEvent) map[string]any {
8384 return m
8485 }
8586
86-func contentToJSON(blocks []provider.Content) []map[string]any {
87+// ContentToJSON serialises a transcript content slice into the same
88+// shape used in EventToJSON. Exported alongside EventToJSON for the
89+// RPC mode.
90+func ContentToJSON(blocks []provider.Content) []map[string]any {
8791 out := make([]map[string]any, 0, len(blocks))
8892 for _, b := range blocks {
8993 switch v := b.(type) {
@@ -100,7 +104,7 @@ func contentToJSON(blocks []provider.Content) []map[string]any {
100104 "type": "tool_result",
101105 "call_id": v.CallID,
102106 "is_error": v.IsError,
103- "content": contentToJSON(v.Content),
107+ "content": ContentToJSON(v.Content),
104108 })
105109 }
106110 }
@@ -20,7 +20,7 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
20 20
21 var runErr error21 var runErr error
22 sink := func(ev core.AgentEvent) {22 sink := func(ev core.AgentEvent) {
23- write(eventToJSON(ev))23+ write(EventToJSON(ev))
24 }24 }
25 25
26 if err := ag.Prompt(ctx, prompt, images, sink); err != nil {26 if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
@@ -33,18 +33,19 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
33 return runErr33 return runErr
34 }34 }
35 35
36-// eventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire36+// EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
37-// schema is deliberately simple and flat.37+// schema is deliberately simple and flat. Exported so the RPC mode can
38-func eventToJSON(ev core.AgentEvent) map[string]any {38+// reuse the same serialisation as `zot --json`.
39+func EventToJSON(ev core.AgentEvent) map[string]any {
39 m := map[string]any{"type": ev.Type()}40 m := map[string]any{"type": ev.Type()}
40 switch e := ev.(type) {41 switch e := ev.(type) {
41 case core.EvTurnStart:42 case core.EvTurnStart:
42 m["step"] = e.Step43 m["step"] = e.Step
43 case core.EvUserMessage:44 case core.EvUserMessage:
44- m["content"] = contentToJSON(e.Message.Content)45+ m["content"] = ContentToJSON(e.Message.Content)
45 m["time"] = e.Message.Time46 m["time"] = e.Message.Time
46 case core.EvAssistantMessage:47 case core.EvAssistantMessage:
47- m["content"] = contentToJSON(e.Message.Content)48+ m["content"] = ContentToJSON(e.Message.Content)
48 m["time"] = e.Message.Time49 m["time"] = e.Message.Time
49 case core.EvTextDelta:50 case core.EvTextDelta:
50 m["delta"] = e.Delta51 m["delta"] = e.Delta
@@ -60,7 +61,7 @@ func eventToJSON(ev core.AgentEvent) map[string]any {
60 case core.EvToolResult:61 case core.EvToolResult:
61 m["id"] = e.ID62 m["id"] = e.ID
62 m["is_error"] = e.Result.IsError63 m["is_error"] = e.Result.IsError
63- m["content"] = contentToJSON(e.Result.Content)64+ m["content"] = ContentToJSON(e.Result.Content)
64 case core.EvUsage:65 case core.EvUsage:
65 m["input"] = e.Usage.InputTokens66 m["input"] = e.Usage.InputTokens
66 m["output"] = e.Usage.OutputTokens67 m["output"] = e.Usage.OutputTokens
@@ -83,7 +84,10 @@ func eventToJSON(ev core.AgentEvent) map[string]any {
83 return m84 return m
84 }85 }
85 86
86-func contentToJSON(blocks []provider.Content) []map[string]any {87+// ContentToJSON serialises a transcript content slice into the same
88+// shape used in EventToJSON. Exported alongside EventToJSON for the
89+// RPC mode.
90+func ContentToJSON(blocks []provider.Content) []map[string]any {
87 out := make([]map[string]any, 0, len(blocks))91 out := make([]map[string]any, 0, len(blocks))
88 for _, b := range blocks {92 for _, b := range blocks {
89 switch v := b.(type) {93 switch v := b.(type) {
@@ -100,7 +104,7 @@ func contentToJSON(blocks []provider.Content) []map[string]any {
100 "type": "tool_result",104 "type": "tool_result",
101 "call_id": v.CallID,105 "call_id": v.CallID,
102 "is_error": v.IsError,106 "is_error": v.IsError,
103- "content": contentToJSON(v.Content),107+ "content": ContentToJSON(v.Content),
104 })108 })
105 }109 }
106 }110 }
A internal/agent/rpc.go +344 -0
@@ -0,0 +1,344 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+
13+ "github.com/patriceckhart/zot/internal/agent/modes"
14+ "github.com/patriceckhart/zot/internal/core"
15+ "github.com/patriceckhart/zot/internal/provider"
16+)
17+
18+// runRPCMode implements the JSON-over-stdin/stdout RPC protocol.
19+//
20+// Wire format: one JSON object per line in both directions.
21+//
22+// Commands (stdin):
23+//
24+// {"id":"1","type":"prompt","message":"hello","images":[]}
25+// {"id":"2","type":"abort"}
26+// {"id":"3","type":"compact"}
27+// {"id":"4","type":"get_state"}
28+// {"id":"5","type":"set_model","model":"claude-opus-4-5"}
29+// {"id":"6","type":"get_messages"}
30+// {"id":"7","type":"clear"}
31+// {"id":"8","type":"get_models"}
32+//
33+// Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true}
34+// Events (stdout): one JSON object per AgentEvent (same schema as --json mode).
35+//
36+// Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be
37+// {"type":"hello","token":"..."} or the connection is closed.
38+func runRPCMode(ctx context.Context, args Args, version string) error {
39+ r, err := Resolve(args, true)
40+ if err != nil {
41+ return err
42+ }
43+ ag := r.NewAgent()
44+
45+ server := &rpcServer{
46+ ctx: ctx,
47+ args: args,
48+ agent: ag,
49+ provider: r.Provider,
50+ model: r.Model,
51+ out: os.Stdout,
52+ version: version,
53+ }
54+ return server.run(os.Stdin)
55+}
56+
57+type rpcServer struct {
58+ ctx context.Context
59+ args Args
60+ agent *core.Agent
61+ provider string
62+ model string
63+ out io.Writer
64+ version string
65+
66+ writeMu sync.Mutex
67+ turnMu sync.Mutex // serialises one prompt at a time
68+ activeCancel context.CancelFunc
69+ authed bool
70+}
71+
72+// run reads NDJSON commands from in and dispatches them. Returns when
73+// in is closed. Errors writing to stdout are fatal.
74+func (s *rpcServer) run(in io.Reader) error {
75+ requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
76+ s.authed = !requireToken
77+
78+ sc := bufio.NewScanner(in)
79+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
80+ for sc.Scan() {
81+ line := strings.TrimSpace(sc.Text())
82+ if line == "" {
83+ continue
84+ }
85+ var head struct {
86+ Type string `json:"type"`
87+ ID string `json:"id,omitempty"`
88+ }
89+ if err := json.Unmarshal([]byte(line), &head); err != nil {
90+ s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
91+ continue
92+ }
93+ if !s.authed {
94+ if head.Type != "hello" {
95+ s.writeError(head.ID, head.Type, "auth required: send hello with token first")
96+ continue
97+ }
98+ var hello struct {
99+ Token string `json:"token"`
100+ }
101+ _ = json.Unmarshal([]byte(line), &hello)
102+ if hello.Token != os.Getenv("ZOTCORE_RPC_TOKEN") {
103+ s.writeError(head.ID, head.Type, "invalid token")
104+ return fmt.Errorf("rpc: bad auth token")
105+ }
106+ s.authed = true
107+ s.writeResponse(head.ID, head.Type, map[string]any{
108+ "protocol_version": 1,
109+ "version": s.version,
110+ "provider": s.provider,
111+ "model": s.model,
112+ })
113+ continue
114+ }
115+ s.dispatch(head.Type, head.ID, []byte(line))
116+ }
117+ return sc.Err()
118+}
119+
120+// dispatch routes a command. Long-running commands (prompt, compact)
121+// run on their own goroutine so the read loop stays responsive.
122+func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
123+ switch cmd {
124+ case "hello":
125+ s.writeResponse(id, cmd, map[string]any{
126+ "protocol_version": 1,
127+ "version": s.version,
128+ "provider": s.provider,
129+ "model": s.model,
130+ })
131+ case "prompt":
132+ var req struct {
133+ Message string `json:"message"`
134+ Images []struct {
135+ MimeType string `json:"mime_type"`
136+ Data []byte `json:"data"`
137+ } `json:"images"`
138+ }
139+ if err := json.Unmarshal(raw, &req); err != nil {
140+ s.writeError(id, cmd, err.Error())
141+ return
142+ }
143+ go s.runPrompt(id, req.Message, req.Images)
144+
145+ case "abort":
146+ if c := s.takeCancel(); c != nil {
147+ c()
148+ }
149+ s.writeResponse(id, cmd, nil)
150+
151+ case "compact":
152+ go s.runCompact(id)
153+
154+ case "get_state":
155+ s.writeResponse(id, cmd, s.snapshotState())
156+
157+ case "get_messages":
158+ s.writeResponse(id, cmd, map[string]any{
159+ "messages": messagesToJSON(s.agent.Messages()),
160+ })
161+
162+ case "clear":
163+ s.agent.SetMessages(nil)
164+ s.writeResponse(id, cmd, nil)
165+
166+ case "set_model":
167+ var req struct {
168+ Model string `json:"model"`
169+ }
170+ if err := json.Unmarshal(raw, &req); err != nil {
171+ s.writeError(id, cmd, err.Error())
172+ return
173+ }
174+ if _, err := provider.FindModel(s.provider, req.Model); err != nil {
175+ s.writeError(id, cmd, err.Error())
176+ return
177+ }
178+ s.agent.Model = req.Model
179+ s.model = req.Model
180+ s.writeResponse(id, cmd, map[string]any{"model": req.Model})
181+
182+ case "get_models":
183+ out := []map[string]any{}
184+ for _, m := range provider.ModelsForProvider(s.provider) {
185+ out = append(out, map[string]any{
186+ "id": m.ID,
187+ "provider": m.Provider,
188+ "context_window": m.ContextWindow,
189+ "max_output": m.MaxOutput,
190+ "reasoning": m.Reasoning,
191+ })
192+ }
193+ s.writeResponse(id, cmd, map[string]any{"models": out})
194+
195+ case "ping":
196+ s.writeResponse(id, cmd, map[string]any{"pong": true})
197+
198+ default:
199+ s.writeError(id, cmd, "unknown command")
200+ }
201+}
202+
203+// runPrompt executes a single prompt turn and streams events out.
204+// Holds turnMu so a second concurrent prompt blocks until this one
205+// finishes; the user can abort with the abort command.
206+func (s *rpcServer) runPrompt(id, message string, images []struct {
207+ MimeType string `json:"mime_type"`
208+ Data []byte `json:"data"`
209+}) {
210+ s.turnMu.Lock()
211+ defer s.turnMu.Unlock()
212+
213+ subCtx, cancel := context.WithCancel(s.ctx)
214+ s.setCancel(cancel)
215+ defer s.setCancel(nil)
216+
217+ s.writeResponse(id, "prompt", map[string]any{"started": true})
218+
219+ imgs := make([]provider.ImageBlock, 0, len(images))
220+ for _, im := range images {
221+ imgs = append(imgs, provider.ImageBlock{MimeType: im.MimeType, Data: im.Data})
222+ }
223+
224+ err := s.agent.Prompt(subCtx, message, imgs, func(ev core.AgentEvent) {
225+ s.writeEvent(modes.EventToJSON(ev))
226+ })
227+ if err != nil {
228+ s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
229+ }
230+ s.writeEvent(map[string]any{"type": "done"})
231+}
232+
233+// runCompact mirrors runPrompt but for compaction.
234+func (s *rpcServer) runCompact(id string) {
235+ s.turnMu.Lock()
236+ defer s.turnMu.Unlock()
237+
238+ subCtx, cancel := context.WithCancel(s.ctx)
239+ s.setCancel(cancel)
240+ defer s.setCancel(nil)
241+
242+ s.writeResponse(id, "compact", map[string]any{"started": true})
243+ summary, err := s.agent.Compact(subCtx, 4, nil)
244+ if err != nil {
245+ s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
246+ return
247+ }
248+ s.writeEvent(map[string]any{
249+ "type": "compact_done",
250+ "summary": summary,
251+ })
252+}
253+
254+// snapshotState builds the get_state response.
255+func (s *rpcServer) snapshotState() map[string]any {
256+ cum := s.agent.Cost()
257+ return map[string]any{
258+ "provider": s.provider,
259+ "model": s.model,
260+ "cwd": s.args.CWD,
261+ "message_count": len(s.agent.Messages()),
262+ "busy": s.activeCancel != nil,
263+ "usage": map[string]any{
264+ "input": cum.InputTokens,
265+ "output": cum.OutputTokens,
266+ "cache_read": cum.CacheReadTokens,
267+ "cache_write": cum.CacheWriteTokens,
268+ "cost_usd": cum.CostUSD,
269+ },
270+ }
271+}
272+
273+// ---- write helpers (single-line JSON, mutex-guarded) ----
274+
275+func (s *rpcServer) writeResponse(id, cmd string, data any) {
276+ frame := map[string]any{
277+ "type": "response",
278+ "command": cmd,
279+ "success": true,
280+ }
281+ if id != "" {
282+ frame["id"] = id
283+ }
284+ if data != nil {
285+ frame["data"] = data
286+ }
287+ s.write(frame)
288+}
289+
290+func (s *rpcServer) writeError(id, cmd, msg string) {
291+ frame := map[string]any{
292+ "type": "response",
293+ "command": cmd,
294+ "success": false,
295+ "error": msg,
296+ }
297+ if id != "" {
298+ frame["id"] = id
299+ }
300+ s.write(frame)
301+}
302+
303+func (s *rpcServer) writeEvent(payload map[string]any) {
304+ s.write(payload)
305+}
306+
307+func (s *rpcServer) write(v any) {
308+ b, err := json.Marshal(v)
309+ if err != nil {
310+ return
311+ }
312+ s.writeMu.Lock()
313+ defer s.writeMu.Unlock()
314+ _, _ = s.out.Write(b)
315+ _, _ = s.out.Write([]byte("\n"))
316+}
317+
318+func (s *rpcServer) setCancel(c context.CancelFunc) {
319+ s.writeMu.Lock()
320+ s.activeCancel = c
321+ s.writeMu.Unlock()
322+}
323+
324+func (s *rpcServer) takeCancel() context.CancelFunc {
325+ s.writeMu.Lock()
326+ defer s.writeMu.Unlock()
327+ c := s.activeCancel
328+ s.activeCancel = nil
329+ return c
330+}
331+
332+// messagesToJSON serialises a transcript using the same schema as the
333+// --json event mode for cross-format consistency.
334+func messagesToJSON(msgs []provider.Message) []map[string]any {
335+ out := make([]map[string]any, 0, len(msgs))
336+ for _, m := range msgs {
337+ out = append(out, map[string]any{
338+ "role": string(m.Role),
339+ "content": modes.ContentToJSON(m.Content),
340+ "time": m.Time,
341+ })
342+ }
343+ return out
344+}
@@ -0,0 +1,344 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+
13+ "github.com/patriceckhart/zot/internal/agent/modes"
14+ "github.com/patriceckhart/zot/internal/core"
15+ "github.com/patriceckhart/zot/internal/provider"
16+)
17+
18+// runRPCMode implements the JSON-over-stdin/stdout RPC protocol.
19+//
20+// Wire format: one JSON object per line in both directions.
21+//
22+// Commands (stdin):
23+//
24+// {"id":"1","type":"prompt","message":"hello","images":[]}
25+// {"id":"2","type":"abort"}
26+// {"id":"3","type":"compact"}
27+// {"id":"4","type":"get_state"}
28+// {"id":"5","type":"set_model","model":"claude-opus-4-5"}
29+// {"id":"6","type":"get_messages"}
30+// {"id":"7","type":"clear"}
31+// {"id":"8","type":"get_models"}
32+//
33+// Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true}
34+// Events (stdout): one JSON object per AgentEvent (same schema as --json mode).
35+//
36+// Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be
37+// {"type":"hello","token":"..."} or the connection is closed.
38+func runRPCMode(ctx context.Context, args Args, version string) error {
39+ r, err := Resolve(args, true)
40+ if err != nil {
41+ return err
42+ }
43+ ag := r.NewAgent()
44+
45+ server := &rpcServer{
46+ ctx: ctx,
47+ args: args,
48+ agent: ag,
49+ provider: r.Provider,
50+ model: r.Model,
51+ out: os.Stdout,
52+ version: version,
53+ }
54+ return server.run(os.Stdin)
55+}
56+
57+type rpcServer struct {
58+ ctx context.Context
59+ args Args
60+ agent *core.Agent
61+ provider string
62+ model string
63+ out io.Writer
64+ version string
65+
66+ writeMu sync.Mutex
67+ turnMu sync.Mutex // serialises one prompt at a time
68+ activeCancel context.CancelFunc
69+ authed bool
70+}
71+
72+// run reads NDJSON commands from in and dispatches them. Returns when
73+// in is closed. Errors writing to stdout are fatal.
74+func (s *rpcServer) run(in io.Reader) error {
75+ requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
76+ s.authed = !requireToken
77+
78+ sc := bufio.NewScanner(in)
79+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
80+ for sc.Scan() {
81+ line := strings.TrimSpace(sc.Text())
82+ if line == "" {
83+ continue
84+ }
85+ var head struct {
86+ Type string `json:"type"`
87+ ID string `json:"id,omitempty"`
88+ }
89+ if err := json.Unmarshal([]byte(line), &head); err != nil {
90+ s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
91+ continue
92+ }
93+ if !s.authed {
94+ if head.Type != "hello" {
95+ s.writeError(head.ID, head.Type, "auth required: send hello with token first")
96+ continue
97+ }
98+ var hello struct {
99+ Token string `json:"token"`
100+ }
101+ _ = json.Unmarshal([]byte(line), &hello)
102+ if hello.Token != os.Getenv("ZOTCORE_RPC_TOKEN") {
103+ s.writeError(head.ID, head.Type, "invalid token")
104+ return fmt.Errorf("rpc: bad auth token")
105+ }
106+ s.authed = true
107+ s.writeResponse(head.ID, head.Type, map[string]any{
108+ "protocol_version": 1,
109+ "version": s.version,
110+ "provider": s.provider,
111+ "model": s.model,
112+ })
113+ continue
114+ }
115+ s.dispatch(head.Type, head.ID, []byte(line))
116+ }
117+ return sc.Err()
118+}
119+
120+// dispatch routes a command. Long-running commands (prompt, compact)
121+// run on their own goroutine so the read loop stays responsive.
122+func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
123+ switch cmd {
124+ case "hello":
125+ s.writeResponse(id, cmd, map[string]any{
126+ "protocol_version": 1,
127+ "version": s.version,
128+ "provider": s.provider,
129+ "model": s.model,
130+ })
131+ case "prompt":
132+ var req struct {
133+ Message string `json:"message"`
134+ Images []struct {
135+ MimeType string `json:"mime_type"`
136+ Data []byte `json:"data"`
137+ } `json:"images"`
138+ }
139+ if err := json.Unmarshal(raw, &req); err != nil {
140+ s.writeError(id, cmd, err.Error())
141+ return
142+ }
143+ go s.runPrompt(id, req.Message, req.Images)
144+
145+ case "abort":
146+ if c := s.takeCancel(); c != nil {
147+ c()
148+ }
149+ s.writeResponse(id, cmd, nil)
150+
151+ case "compact":
152+ go s.runCompact(id)
153+
154+ case "get_state":
155+ s.writeResponse(id, cmd, s.snapshotState())
156+
157+ case "get_messages":
158+ s.writeResponse(id, cmd, map[string]any{
159+ "messages": messagesToJSON(s.agent.Messages()),
160+ })
161+
162+ case "clear":
163+ s.agent.SetMessages(nil)
164+ s.writeResponse(id, cmd, nil)
165+
166+ case "set_model":
167+ var req struct {
168+ Model string `json:"model"`
169+ }
170+ if err := json.Unmarshal(raw, &req); err != nil {
171+ s.writeError(id, cmd, err.Error())
172+ return
173+ }
174+ if _, err := provider.FindModel(s.provider, req.Model); err != nil {
175+ s.writeError(id, cmd, err.Error())
176+ return
177+ }
178+ s.agent.Model = req.Model
179+ s.model = req.Model
180+ s.writeResponse(id, cmd, map[string]any{"model": req.Model})
181+
182+ case "get_models":
183+ out := []map[string]any{}
184+ for _, m := range provider.ModelsForProvider(s.provider) {
185+ out = append(out, map[string]any{
186+ "id": m.ID,
187+ "provider": m.Provider,
188+ "context_window": m.ContextWindow,
189+ "max_output": m.MaxOutput,
190+ "reasoning": m.Reasoning,
191+ })
192+ }
193+ s.writeResponse(id, cmd, map[string]any{"models": out})
194+
195+ case "ping":
196+ s.writeResponse(id, cmd, map[string]any{"pong": true})
197+
198+ default:
199+ s.writeError(id, cmd, "unknown command")
200+ }
201+}
202+
203+// runPrompt executes a single prompt turn and streams events out.
204+// Holds turnMu so a second concurrent prompt blocks until this one
205+// finishes; the user can abort with the abort command.
206+func (s *rpcServer) runPrompt(id, message string, images []struct {
207+ MimeType string `json:"mime_type"`
208+ Data []byte `json:"data"`
209+}) {
210+ s.turnMu.Lock()
211+ defer s.turnMu.Unlock()
212+
213+ subCtx, cancel := context.WithCancel(s.ctx)
214+ s.setCancel(cancel)
215+ defer s.setCancel(nil)
216+
217+ s.writeResponse(id, "prompt", map[string]any{"started": true})
218+
219+ imgs := make([]provider.ImageBlock, 0, len(images))
220+ for _, im := range images {
221+ imgs = append(imgs, provider.ImageBlock{MimeType: im.MimeType, Data: im.Data})
222+ }
223+
224+ err := s.agent.Prompt(subCtx, message, imgs, func(ev core.AgentEvent) {
225+ s.writeEvent(modes.EventToJSON(ev))
226+ })
227+ if err != nil {
228+ s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
229+ }
230+ s.writeEvent(map[string]any{"type": "done"})
231+}
232+
233+// runCompact mirrors runPrompt but for compaction.
234+func (s *rpcServer) runCompact(id string) {
235+ s.turnMu.Lock()
236+ defer s.turnMu.Unlock()
237+
238+ subCtx, cancel := context.WithCancel(s.ctx)
239+ s.setCancel(cancel)
240+ defer s.setCancel(nil)
241+
242+ s.writeResponse(id, "compact", map[string]any{"started": true})
243+ summary, err := s.agent.Compact(subCtx, 4, nil)
244+ if err != nil {
245+ s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
246+ return
247+ }
248+ s.writeEvent(map[string]any{
249+ "type": "compact_done",
250+ "summary": summary,
251+ })
252+}
253+
254+// snapshotState builds the get_state response.
255+func (s *rpcServer) snapshotState() map[string]any {
256+ cum := s.agent.Cost()
257+ return map[string]any{
258+ "provider": s.provider,
259+ "model": s.model,
260+ "cwd": s.args.CWD,
261+ "message_count": len(s.agent.Messages()),
262+ "busy": s.activeCancel != nil,
263+ "usage": map[string]any{
264+ "input": cum.InputTokens,
265+ "output": cum.OutputTokens,
266+ "cache_read": cum.CacheReadTokens,
267+ "cache_write": cum.CacheWriteTokens,
268+ "cost_usd": cum.CostUSD,
269+ },
270+ }
271+}
272+
273+// ---- write helpers (single-line JSON, mutex-guarded) ----
274+
275+func (s *rpcServer) writeResponse(id, cmd string, data any) {
276+ frame := map[string]any{
277+ "type": "response",
278+ "command": cmd,
279+ "success": true,
280+ }
281+ if id != "" {
282+ frame["id"] = id
283+ }
284+ if data != nil {
285+ frame["data"] = data
286+ }
287+ s.write(frame)
288+}
289+
290+func (s *rpcServer) writeError(id, cmd, msg string) {
291+ frame := map[string]any{
292+ "type": "response",
293+ "command": cmd,
294+ "success": false,
295+ "error": msg,
296+ }
297+ if id != "" {
298+ frame["id"] = id
299+ }
300+ s.write(frame)
301+}
302+
303+func (s *rpcServer) writeEvent(payload map[string]any) {
304+ s.write(payload)
305+}
306+
307+func (s *rpcServer) write(v any) {
308+ b, err := json.Marshal(v)
309+ if err != nil {
310+ return
311+ }
312+ s.writeMu.Lock()
313+ defer s.writeMu.Unlock()
314+ _, _ = s.out.Write(b)
315+ _, _ = s.out.Write([]byte("\n"))
316+}
317+
318+func (s *rpcServer) setCancel(c context.CancelFunc) {
319+ s.writeMu.Lock()
320+ s.activeCancel = c
321+ s.writeMu.Unlock()
322+}
323+
324+func (s *rpcServer) takeCancel() context.CancelFunc {
325+ s.writeMu.Lock()
326+ defer s.writeMu.Unlock()
327+ c := s.activeCancel
328+ s.activeCancel = nil
329+ return c
330+}
331+
332+// messagesToJSON serialises a transcript using the same schema as the
333+// --json event mode for cross-format consistency.
334+func messagesToJSON(msgs []provider.Message) []map[string]any {
335+ out := make([]map[string]any, 0, len(msgs))
336+ for _, m := range msgs {
337+ out = append(out, map[string]any{
338+ "role": string(m.Role),
339+ "content": modes.ContentToJSON(m.Content),
340+ "time": m.Time,
341+ })
342+ }
343+ return out
344+}
M internal/provider/anthropic.go +65 -10
@@ -102,22 +102,25 @@ type anthImageSource struct {
102102 }
103103
104104 type anthImageBlock struct {
105- Type string `json:"type"` // "image"
106- Source anthImageSource `json:"source"`
105+ Type string `json:"type"` // "image"
106+ Source anthImageSource `json:"source"`
107+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
107108 }
108109
109110 type anthToolUseBlock struct {
110- Type string `json:"type"` // "tool_use"
111- ID string `json:"id"`
112- Name string `json:"name"`
113- Input json.RawMessage `json:"input"`
111+ Type string `json:"type"` // "tool_use"
112+ ID string `json:"id"`
113+ Name string `json:"name"`
114+ Input json.RawMessage `json:"input"`
115+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
114116 }
115117
116118 type anthToolResultBlock struct {
117- Type string `json:"type"` // "tool_result"
118- ToolUseID string `json:"tool_use_id"`
119- Content json.RawMessage `json:"content"` // string or array of blocks
120- IsError bool `json:"is_error,omitempty"`
119+ Type string `json:"type"` // "tool_result"
120+ ToolUseID string `json:"tool_use_id"`
121+ Content json.RawMessage `json:"content"` // string or array of blocks
122+ IsError bool `json:"is_error,omitempty"`
123+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
121124 }
122125
123126 type anthCacheCtrl struct {
@@ -255,9 +258,61 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
255258 }
256259 }
257260
261+ // Mark the last two user messages with cache_control so anthropic
262+ // caches the running conversation prefix. Combined with the system
263+ // + tools breakpoints above this is the recommended layout for
264+ // multi-turn caching: turn N writes a cache that turn N+1 reads,
265+ // dropping per-turn input tokens from "system + history" down to
266+ // just the new user message. Anthropic allows up to 4 breakpoints
267+ // per request; we use system + tools + 2 conversation = 4.
268+ tagUserCache(out.Messages)
269+
258270 return out, nil
259271 }
260272
273+// tagUserCache attaches a cache_control marker to the last block of
274+// the most recent (and second-most-recent, if any) user message in
275+// msgs. The marker tells the api to checkpoint the prefix at that
276+// point so subsequent requests can replay everything up to and
277+// including that block as a cache hit.
278+func tagUserCache(msgs []anthMessage) {
279+ indexes := make([]int, 0, 2)
280+ for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- {
281+ if msgs[i].Role == "user" {
282+ indexes = append(indexes, i)
283+ }
284+ }
285+ for _, idx := range indexes {
286+ markLastBlockEphemeral(msgs[idx].Content)
287+ }
288+}
289+
290+// markLastBlockEphemeral sets CacheControl on the last entry in blocks
291+// regardless of whether it's a text, image, tool_use, or tool_result.
292+// Each block type carries its own CacheControl pointer so we type-
293+// switch + reassign the slice element.
294+func markLastBlockEphemeral(blocks []interface{}) {
295+ if len(blocks) == 0 {
296+ return
297+ }
298+ i := len(blocks) - 1
299+ cc := &anthCacheCtrl{Type: "ephemeral"}
300+ switch v := blocks[i].(type) {
301+ case anthTextBlock:
302+ v.CacheControl = cc
303+ blocks[i] = v
304+ case anthImageBlock:
305+ v.CacheControl = cc
306+ blocks[i] = v
307+ case anthToolUseBlock:
308+ v.CacheControl = cc
309+ blocks[i] = v
310+ case anthToolResultBlock:
311+ v.CacheControl = cc
312+ blocks[i] = v
313+ }
314+}
315+
261316 func anthropicReasoningBudget(level string) int {
262317 switch strings.ToLower(level) {
263318 case "low":
@@ -102,22 +102,25 @@ type anthImageSource struct {
102 }102 }
103 103
104 type anthImageBlock struct {104 type anthImageBlock struct {
105- Type string `json:"type"` // "image"105+ Type string `json:"type"` // "image"
106- Source anthImageSource `json:"source"`106+ Source anthImageSource `json:"source"`
107+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
107 }108 }
108 109
109 type anthToolUseBlock struct {110 type anthToolUseBlock struct {
110- Type string `json:"type"` // "tool_use"111+ Type string `json:"type"` // "tool_use"
111- ID string `json:"id"`112+ ID string `json:"id"`
112- Name string `json:"name"`113+ Name string `json:"name"`
113- Input json.RawMessage `json:"input"`114+ Input json.RawMessage `json:"input"`
115+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
114 }116 }
115 117
116 type anthToolResultBlock struct {118 type anthToolResultBlock struct {
117- Type string `json:"type"` // "tool_result"119+ Type string `json:"type"` // "tool_result"
118- ToolUseID string `json:"tool_use_id"`120+ ToolUseID string `json:"tool_use_id"`
119- Content json.RawMessage `json:"content"` // string or array of blocks121+ Content json.RawMessage `json:"content"` // string or array of blocks
120- IsError bool `json:"is_error,omitempty"`122+ IsError bool `json:"is_error,omitempty"`
123+ CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
121 }124 }
122 125
123 type anthCacheCtrl struct {126 type anthCacheCtrl struct {
@@ -255,9 +258,61 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
255 }258 }
256 }259 }
257 260
261+ // Mark the last two user messages with cache_control so anthropic
262+ // caches the running conversation prefix. Combined with the system
263+ // + tools breakpoints above this is the recommended layout for
264+ // multi-turn caching: turn N writes a cache that turn N+1 reads,
265+ // dropping per-turn input tokens from "system + history" down to
266+ // just the new user message. Anthropic allows up to 4 breakpoints
267+ // per request; we use system + tools + 2 conversation = 4.
268+ tagUserCache(out.Messages)
269+
258 return out, nil270 return out, nil
259 }271 }
260 272
273+// tagUserCache attaches a cache_control marker to the last block of
274+// the most recent (and second-most-recent, if any) user message in
275+// msgs. The marker tells the api to checkpoint the prefix at that
276+// point so subsequent requests can replay everything up to and
277+// including that block as a cache hit.
278+func tagUserCache(msgs []anthMessage) {
279+ indexes := make([]int, 0, 2)
280+ for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- {
281+ if msgs[i].Role == "user" {
282+ indexes = append(indexes, i)
283+ }
284+ }
285+ for _, idx := range indexes {
286+ markLastBlockEphemeral(msgs[idx].Content)
287+ }
288+}
289+
290+// markLastBlockEphemeral sets CacheControl on the last entry in blocks
291+// regardless of whether it's a text, image, tool_use, or tool_result.
292+// Each block type carries its own CacheControl pointer so we type-
293+// switch + reassign the slice element.
294+func markLastBlockEphemeral(blocks []interface{}) {
295+ if len(blocks) == 0 {
296+ return
297+ }
298+ i := len(blocks) - 1
299+ cc := &anthCacheCtrl{Type: "ephemeral"}
300+ switch v := blocks[i].(type) {
301+ case anthTextBlock:
302+ v.CacheControl = cc
303+ blocks[i] = v
304+ case anthImageBlock:
305+ v.CacheControl = cc
306+ blocks[i] = v
307+ case anthToolUseBlock:
308+ v.CacheControl = cc
309+ blocks[i] = v
310+ case anthToolResultBlock:
311+ v.CacheControl = cc
312+ blocks[i] = v
313+ }
314+}
315+
261 func anthropicReasoningBudget(level string) int {316 func anthropicReasoningBudget(level string) int {
262 switch strings.ToLower(level) {317 switch strings.ToLower(level) {
263 case "low":318 case "low":
M internal/tui/view.go +40 -30
@@ -795,11 +795,25 @@ type StatusBarParams struct {
795795 Cols int // terminal width; drives right-alignment of cwd
796796 }
797797
798-// StatusBar builds the single-line status shown above the editor.
799-// Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax
800-func StatusBar(p StatusBarParams) string {
798+// StatusBar builds the status shown above the editor. Returns one or
799+// two lines depending on whether the (provider, model, stats, cwd)
800+// payload fits the terminal width.
801+//
802+// Layout:
803+//
804+// <busyPrefix> (provider) model stats cwd <- one line if it fits
805+//
806+// or, when it doesn't:
807+//
808+// <busyPrefix> (provider) model stats <- line 1
809+// cwd <- line 2
810+//
811+// The right-side gap is a fixed three spaces, not a stretched-to-fill
812+// fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone
813+// entirely — the slash-command popup and the queued/sliding-in chips
814+// already cover the discoverability of those keybindings.
815+func StatusBar(p StatusBarParams) []string {
801816 th := p.Theme
802- sep := " - "
803817
804818 // Token stats: only include each segment when non-zero (pi's
805819 // behavior). Keeps the bar compact on brand-new sessions.
@@ -817,11 +831,16 @@ func StatusBar(p StatusBarParams) string {
817831 stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens)))
818832 }
819833
834+ // Cost: always show the dollar value computed from token counts,
835+ // even on subscription — lets you see what the equivalent api cost
836+ // would be (handy for gauging subscription value). Append "(sub)"
837+ // only as a hint that no real money moved.
820838 var costStr string
821- if p.Subscription {
822- costStr = "$0.000 (sub)"
823- } else if p.Usage.CostUSD > 0 {
839+ if p.Usage.CostUSD > 0 || p.Subscription {
824840 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
841+ if p.Subscription {
842+ costStr += " (sub)"
843+ }
825844 }
826845 if costStr != "" {
827846 stats = append(stats, costStr)
@@ -839,13 +858,6 @@ func StatusBar(p StatusBarParams) string {
839858 left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)
840859 middle := " " + strings.Join(stats, " ") + " "
841860
842- var hint string
843- if p.Busy {
844- hint = " esc cancel "
845- } else {
846- hint = " ctrl+c exit " + sep + " /help "
847- }
848-
849861 var leftBuilder strings.Builder
850862 if p.BusyPrefix != "" {
851863 leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))
@@ -855,28 +867,26 @@ func StatusBar(p StatusBarParams) string {
855867 leftBuilder.WriteString(" ")
856868 // `middle` already has colorized context segments; wrap the rest in muted.
857869 leftBuilder.WriteString(th.FG256(th.Muted, middle))
858- leftBuilder.WriteString(" ")
859- leftBuilder.WriteString(th.FG256(th.Muted, hint))
860870
861- // Compose cwd on the right side. shortenHome abbreviates $HOME to "~".
862- right := shortenHome(p.CWD)
863- if p.Locked && right != "" {
864- right = "· locked · " + right
871+ cwd := shortenHome(p.CWD)
872+ if p.Locked && cwd != "" {
873+ cwd = "· locked · " + cwd
865874 }
866- if right == "" || p.Cols <= 0 {
867- return leftBuilder.String()
875+
876+ primary := leftBuilder.String()
877+ if cwd == "" {
878+ return []string{primary}
868879 }
869880
870- leftRendered := leftBuilder.String()
871- leftWidth := visibleWidth(leftRendered)
872- rightStr := " " + right + " "
873- rightWidth := visibleWidth(rightStr)
881+ cwdRendered := th.FG256(th.Muted, cwd)
882+ const gap = " " // exactly three spaces between stats and cwd
883+ combined := primary + gap + cwdRendered
874884
875- gap := p.Cols - leftWidth - rightWidth
876- if gap < 1 {
877- return leftRendered
885+ // Wrap to a second line when the combined width would overflow.
886+ if p.Cols > 0 && visibleWidth(combined) > p.Cols {
887+ return []string{primary, cwdRendered}
878888 }
879- return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr)
889+ return []string{combined}
880890 }
881891
882892 // piContextUsage renders the "N%/ctxMax" fragment, returning the
@@ -795,11 +795,25 @@ type StatusBarParams struct {
795 Cols int // terminal width; drives right-alignment of cwd795 Cols int // terminal width; drives right-alignment of cwd
796 }796 }
797 797
798-// StatusBar builds the single-line status shown above the editor.798+// StatusBar builds the status shown above the editor. Returns one or
799-// Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax799+// two lines depending on whether the (provider, model, stats, cwd)
800-func StatusBar(p StatusBarParams) string {800+// payload fits the terminal width.
801+//
802+// Layout:
803+//
804+// <busyPrefix> (provider) model stats cwd <- one line if it fits
805+//
806+// or, when it doesn't:
807+//
808+// <busyPrefix> (provider) model stats <- line 1
809+// cwd <- line 2
810+//
811+// The right-side gap is a fixed three spaces, not a stretched-to-fill
812+// fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone
813+// entirely — the slash-command popup and the queued/sliding-in chips
814+// already cover the discoverability of those keybindings.
815+func StatusBar(p StatusBarParams) []string {
801 th := p.Theme816 th := p.Theme
802- sep := " - "
803 817
804 // Token stats: only include each segment when non-zero (pi's818 // Token stats: only include each segment when non-zero (pi's
805 // behavior). Keeps the bar compact on brand-new sessions.819 // behavior). Keeps the bar compact on brand-new sessions.
@@ -817,11 +831,16 @@ func StatusBar(p StatusBarParams) string {
817 stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens)))831 stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens)))
818 }832 }
819 833
834+ // Cost: always show the dollar value computed from token counts,
835+ // even on subscription — lets you see what the equivalent api cost
836+ // would be (handy for gauging subscription value). Append "(sub)"
837+ // only as a hint that no real money moved.
820 var costStr string838 var costStr string
821- if p.Subscription {839+ if p.Usage.CostUSD > 0 || p.Subscription {
822- costStr = "$0.000 (sub)"
823- } else if p.Usage.CostUSD > 0 {
824 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)840 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
841+ if p.Subscription {
842+ costStr += " (sub)"
843+ }
825 }844 }
826 if costStr != "" {845 if costStr != "" {
827 stats = append(stats, costStr)846 stats = append(stats, costStr)
@@ -839,13 +858,6 @@ func StatusBar(p StatusBarParams) string {
839 left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)858 left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)
840 middle := " " + strings.Join(stats, " ") + " "859 middle := " " + strings.Join(stats, " ") + " "
841 860
842- var hint string
843- if p.Busy {
844- hint = " esc cancel "
845- } else {
846- hint = " ctrl+c exit " + sep + " /help "
847- }
848-
849 var leftBuilder strings.Builder861 var leftBuilder strings.Builder
850 if p.BusyPrefix != "" {862 if p.BusyPrefix != "" {
851 leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))863 leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))
@@ -855,28 +867,26 @@ func StatusBar(p StatusBarParams) string {
855 leftBuilder.WriteString(" ")867 leftBuilder.WriteString(" ")
856 // `middle` already has colorized context segments; wrap the rest in muted.868 // `middle` already has colorized context segments; wrap the rest in muted.
857 leftBuilder.WriteString(th.FG256(th.Muted, middle))869 leftBuilder.WriteString(th.FG256(th.Muted, middle))
858- leftBuilder.WriteString(" ")
859- leftBuilder.WriteString(th.FG256(th.Muted, hint))
860 870
861- // Compose cwd on the right side. shortenHome abbreviates $HOME to "~".871+ cwd := shortenHome(p.CWD)
862- right := shortenHome(p.CWD)872+ if p.Locked && cwd != "" {
863- if p.Locked && right != "" {873+ cwd = "· locked · " + cwd
864- right = "· locked · " + right
865 }874 }
866- if right == "" || p.Cols <= 0 {875+
867- return leftBuilder.String()876+ primary := leftBuilder.String()
877+ if cwd == "" {
878+ return []string{primary}
868 }879 }
869 880
870- leftRendered := leftBuilder.String()881+ cwdRendered := th.FG256(th.Muted, cwd)
871- leftWidth := visibleWidth(leftRendered)882+ const gap = " " // exactly three spaces between stats and cwd
872- rightStr := " " + right + " "883+ combined := primary + gap + cwdRendered
873- rightWidth := visibleWidth(rightStr)
874 884
875- gap := p.Cols - leftWidth - rightWidth885+ // Wrap to a second line when the combined width would overflow.
876- if gap < 1 {886+ if p.Cols > 0 && visibleWidth(combined) > p.Cols {
877- return leftRendered887+ return []string{primary, cwdRendered}
878 }888 }
879- return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr)889+ return []string{combined}
880 }890 }
881 891
882 // piContextUsage renders the "N%/ctxMax" fragment, returning the892 // piContextUsage renders the "N%/ctxMax" fragment, returning the
A pkg/zotcore/types.go +214 -0
@@ -0,0 +1,214 @@
1+package zotcore
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/patriceckhart/zot/internal/core"
7+ "github.com/patriceckhart/zot/internal/provider"
8+)
9+
10+// Event is the unit emitted by Runtime.Prompt's channel. Type
11+// identifies the event kind; remaining fields are populated based on
12+// kind. The wire schema is the same as the JSON-RPC stream notifications
13+// emitted by `zot rpc`, so consumers can share parsing code.
14+type Event struct {
15+ Type string `json:"type"`
16+
17+ // Common fields.
18+ Step int `json:"step,omitempty"`
19+ Time string `json:"time,omitempty"`
20+
21+ // text_delta
22+ Delta string `json:"delta,omitempty"`
23+
24+ // tool_call
25+ ID string `json:"id,omitempty"`
26+ Name string `json:"name,omitempty"`
27+ Args json.RawMessage `json:"args,omitempty"`
28+
29+ // tool_progress
30+ Text string `json:"text,omitempty"`
31+
32+ // tool_result
33+ IsError bool `json:"is_error,omitempty"`
34+ Result []ContentBlock `json:"content,omitempty"`
35+
36+ // usage
37+ Usage *Usage `json:"usage,omitempty"`
38+ Cumulative *Usage `json:"cumulative,omitempty"`
39+
40+ // user_message / assistant_message
41+ Message *Message `json:"message,omitempty"`
42+
43+ // turn_end
44+ Stop string `json:"stop,omitempty"`
45+
46+ // error
47+ Error string `json:"error,omitempty"`
48+}
49+
50+// Message is one transcript entry — a user prompt, assistant reply,
51+// or tool result.
52+type Message struct {
53+ Role string `json:"role"`
54+ Content []ContentBlock `json:"content"`
55+ Time string `json:"time,omitempty"`
56+}
57+
58+// ContentBlock is one piece of message content. Discriminate on Type:
59+// - "text" → Text
60+// - "image" → MimeType + Data (or Bytes for size-only)
61+// - "tool_call" → ID + Name + Args
62+// - "tool_result" → CallID + IsError + Content (recursive)
63+type ContentBlock struct {
64+ Type string `json:"type"`
65+
66+ Text string `json:"text,omitempty"`
67+ MimeType string `json:"mime_type,omitempty"`
68+ Data []byte `json:"data,omitempty"`
69+ Bytes int `json:"bytes,omitempty"`
70+ ID string `json:"id,omitempty"`
71+ Name string `json:"name,omitempty"`
72+ Args json.RawMessage `json:"args,omitempty"`
73+ CallID string `json:"call_id,omitempty"`
74+ IsError bool `json:"is_error,omitempty"`
75+ Content []ContentBlock `json:"content,omitempty"`
76+}
77+
78+// Image is a single user-attached image for Prompt.
79+type Image struct {
80+ MimeType string
81+ Data []byte
82+}
83+
84+// Usage is per-turn or cumulative token / cost counts.
85+type Usage struct {
86+ Input int `json:"input"`
87+ Output int `json:"output"`
88+ CacheRead int `json:"cache_read"`
89+ CacheWrite int `json:"cache_write"`
90+ CostUSD float64 `json:"cost_usd"`
91+}
92+
93+// State is a snapshot of the runtime's current state.
94+type State struct {
95+ Provider string `json:"provider"`
96+ Model string `json:"model"`
97+ CWD string `json:"cwd"`
98+ Busy bool `json:"busy"`
99+ MessageCount int `json:"message_count"`
100+}
101+
102+// CompactResult describes the outcome of Compact.
103+type CompactResult struct {
104+ Summary string `json:"summary"`
105+ Messages []Message `json:"messages"`
106+}
107+
108+// ModelInfo describes one model known to the runtime.
109+type ModelInfo struct {
110+ ID string `json:"id"`
111+ Provider string `json:"provider"`
112+ ContextWindow int `json:"context_window"`
113+ MaxOutput int `json:"max_output"`
114+ Reasoning bool `json:"reasoning"`
115+}
116+
117+// ---- internal converters ----
118+
119+func toEvent(ev core.AgentEvent) Event {
120+ out := Event{Type: ev.Type()}
121+ switch e := ev.(type) {
122+ case core.EvTurnStart:
123+ out.Step = e.Step
124+ case core.EvUserMessage:
125+ out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
126+ case core.EvAssistantMessage:
127+ out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
128+ case core.EvTextDelta:
129+ out.Delta = e.Delta
130+ case core.EvToolCall:
131+ out.ID = e.ID
132+ out.Name = e.Name
133+ out.Args = e.Args
134+ case core.EvToolProgress:
135+ out.ID = e.ID
136+ out.Text = e.Text
137+ case core.EvToolResult:
138+ out.ID = e.ID
139+ out.IsError = e.Result.IsError
140+ out.Result = convertContent(e.Result.Content)
141+ case core.EvUsage:
142+ out.Usage = &Usage{
143+ Input: e.Usage.InputTokens,
144+ Output: e.Usage.OutputTokens,
145+ CacheRead: e.Usage.CacheReadTokens,
146+ CacheWrite: e.Usage.CacheWriteTokens,
147+ CostUSD: e.Usage.CostUSD,
148+ }
149+ out.Cumulative = &Usage{
150+ Input: e.Cumulative.InputTokens,
151+ Output: e.Cumulative.OutputTokens,
152+ CacheRead: e.Cumulative.CacheReadTokens,
153+ CacheWrite: e.Cumulative.CacheWriteTokens,
154+ CostUSD: e.Cumulative.CostUSD,
155+ }
156+ case core.EvTurnEnd:
157+ out.Stop = string(e.Stop)
158+ if e.Err != nil {
159+ out.Error = e.Err.Error()
160+ }
161+ case core.EvError:
162+ if e.Err != nil {
163+ out.Error = e.Err.Error()
164+ }
165+ }
166+ return out
167+}
168+
169+func convertContent(blocks []provider.Content) []ContentBlock {
170+ out := make([]ContentBlock, 0, len(blocks))
171+ for _, b := range blocks {
172+ switch v := b.(type) {
173+ case provider.TextBlock:
174+ out = append(out, ContentBlock{Type: "text", Text: v.Text})
175+ case provider.ImageBlock:
176+ out = append(out, ContentBlock{Type: "image", MimeType: v.MimeType, Bytes: len(v.Data)})
177+ case provider.ToolCallBlock:
178+ out = append(out, ContentBlock{Type: "tool_call", ID: v.ID, Name: v.Name, Args: v.Arguments})
179+ case provider.ToolResultBlock:
180+ out = append(out, ContentBlock{
181+ Type: "tool_result",
182+ CallID: v.CallID,
183+ IsError: v.IsError,
184+ Content: convertContent(v.Content),
185+ })
186+ }
187+ }
188+ return out
189+}
190+
191+func rebuildContent(blocks []ContentBlock) []provider.Content {
192+ out := make([]provider.Content, 0, len(blocks))
193+ for _, b := range blocks {
194+ switch b.Type {
195+ case "text":
196+ out = append(out, provider.TextBlock{Text: b.Text})
197+ case "image":
198+ out = append(out, provider.ImageBlock{MimeType: b.MimeType, Data: b.Data})
199+ case "tool_call":
200+ args := b.Args
201+ if len(args) == 0 {
202+ args = json.RawMessage("{}")
203+ }
204+ out = append(out, provider.ToolCallBlock{ID: b.ID, Name: b.Name, Arguments: args})
205+ case "tool_result":
206+ out = append(out, provider.ToolResultBlock{
207+ CallID: b.CallID,
208+ IsError: b.IsError,
209+ Content: rebuildContent(b.Content),
210+ })
211+ }
212+ }
213+ return out
214+}
@@ -0,0 +1,214 @@
1+package zotcore
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/patriceckhart/zot/internal/core"
7+ "github.com/patriceckhart/zot/internal/provider"
8+)
9+
10+// Event is the unit emitted by Runtime.Prompt's channel. Type
11+// identifies the event kind; remaining fields are populated based on
12+// kind. The wire schema is the same as the JSON-RPC stream notifications
13+// emitted by `zot rpc`, so consumers can share parsing code.
14+type Event struct {
15+ Type string `json:"type"`
16+
17+ // Common fields.
18+ Step int `json:"step,omitempty"`
19+ Time string `json:"time,omitempty"`
20+
21+ // text_delta
22+ Delta string `json:"delta,omitempty"`
23+
24+ // tool_call
25+ ID string `json:"id,omitempty"`
26+ Name string `json:"name,omitempty"`
27+ Args json.RawMessage `json:"args,omitempty"`
28+
29+ // tool_progress
30+ Text string `json:"text,omitempty"`
31+
32+ // tool_result
33+ IsError bool `json:"is_error,omitempty"`
34+ Result []ContentBlock `json:"content,omitempty"`
35+
36+ // usage
37+ Usage *Usage `json:"usage,omitempty"`
38+ Cumulative *Usage `json:"cumulative,omitempty"`
39+
40+ // user_message / assistant_message
41+ Message *Message `json:"message,omitempty"`
42+
43+ // turn_end
44+ Stop string `json:"stop,omitempty"`
45+
46+ // error
47+ Error string `json:"error,omitempty"`
48+}
49+
50+// Message is one transcript entry — a user prompt, assistant reply,
51+// or tool result.
52+type Message struct {
53+ Role string `json:"role"`
54+ Content []ContentBlock `json:"content"`
55+ Time string `json:"time,omitempty"`
56+}
57+
58+// ContentBlock is one piece of message content. Discriminate on Type:
59+// - "text" → Text
60+// - "image" → MimeType + Data (or Bytes for size-only)
61+// - "tool_call" → ID + Name + Args
62+// - "tool_result" → CallID + IsError + Content (recursive)
63+type ContentBlock struct {
64+ Type string `json:"type"`
65+
66+ Text string `json:"text,omitempty"`
67+ MimeType string `json:"mime_type,omitempty"`
68+ Data []byte `json:"data,omitempty"`
69+ Bytes int `json:"bytes,omitempty"`
70+ ID string `json:"id,omitempty"`
71+ Name string `json:"name,omitempty"`
72+ Args json.RawMessage `json:"args,omitempty"`
73+ CallID string `json:"call_id,omitempty"`
74+ IsError bool `json:"is_error,omitempty"`
75+ Content []ContentBlock `json:"content,omitempty"`
76+}
77+
78+// Image is a single user-attached image for Prompt.
79+type Image struct {
80+ MimeType string
81+ Data []byte
82+}
83+
84+// Usage is per-turn or cumulative token / cost counts.
85+type Usage struct {
86+ Input int `json:"input"`
87+ Output int `json:"output"`
88+ CacheRead int `json:"cache_read"`
89+ CacheWrite int `json:"cache_write"`
90+ CostUSD float64 `json:"cost_usd"`
91+}
92+
93+// State is a snapshot of the runtime's current state.
94+type State struct {
95+ Provider string `json:"provider"`
96+ Model string `json:"model"`
97+ CWD string `json:"cwd"`
98+ Busy bool `json:"busy"`
99+ MessageCount int `json:"message_count"`
100+}
101+
102+// CompactResult describes the outcome of Compact.
103+type CompactResult struct {
104+ Summary string `json:"summary"`
105+ Messages []Message `json:"messages"`
106+}
107+
108+// ModelInfo describes one model known to the runtime.
109+type ModelInfo struct {
110+ ID string `json:"id"`
111+ Provider string `json:"provider"`
112+ ContextWindow int `json:"context_window"`
113+ MaxOutput int `json:"max_output"`
114+ Reasoning bool `json:"reasoning"`
115+}
116+
117+// ---- internal converters ----
118+
119+func toEvent(ev core.AgentEvent) Event {
120+ out := Event{Type: ev.Type()}
121+ switch e := ev.(type) {
122+ case core.EvTurnStart:
123+ out.Step = e.Step
124+ case core.EvUserMessage:
125+ out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
126+ case core.EvAssistantMessage:
127+ out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
128+ case core.EvTextDelta:
129+ out.Delta = e.Delta
130+ case core.EvToolCall:
131+ out.ID = e.ID
132+ out.Name = e.Name
133+ out.Args = e.Args
134+ case core.EvToolProgress:
135+ out.ID = e.ID
136+ out.Text = e.Text
137+ case core.EvToolResult:
138+ out.ID = e.ID
139+ out.IsError = e.Result.IsError
140+ out.Result = convertContent(e.Result.Content)
141+ case core.EvUsage:
142+ out.Usage = &Usage{
143+ Input: e.Usage.InputTokens,
144+ Output: e.Usage.OutputTokens,
145+ CacheRead: e.Usage.CacheReadTokens,
146+ CacheWrite: e.Usage.CacheWriteTokens,
147+ CostUSD: e.Usage.CostUSD,
148+ }
149+ out.Cumulative = &Usage{
150+ Input: e.Cumulative.InputTokens,
151+ Output: e.Cumulative.OutputTokens,
152+ CacheRead: e.Cumulative.CacheReadTokens,
153+ CacheWrite: e.Cumulative.CacheWriteTokens,
154+ CostUSD: e.Cumulative.CostUSD,
155+ }
156+ case core.EvTurnEnd:
157+ out.Stop = string(e.Stop)
158+ if e.Err != nil {
159+ out.Error = e.Err.Error()
160+ }
161+ case core.EvError:
162+ if e.Err != nil {
163+ out.Error = e.Err.Error()
164+ }
165+ }
166+ return out
167+}
168+
169+func convertContent(blocks []provider.Content) []ContentBlock {
170+ out := make([]ContentBlock, 0, len(blocks))
171+ for _, b := range blocks {
172+ switch v := b.(type) {
173+ case provider.TextBlock:
174+ out = append(out, ContentBlock{Type: "text", Text: v.Text})
175+ case provider.ImageBlock:
176+ out = append(out, ContentBlock{Type: "image", MimeType: v.MimeType, Bytes: len(v.Data)})
177+ case provider.ToolCallBlock:
178+ out = append(out, ContentBlock{Type: "tool_call", ID: v.ID, Name: v.Name, Args: v.Arguments})
179+ case provider.ToolResultBlock:
180+ out = append(out, ContentBlock{
181+ Type: "tool_result",
182+ CallID: v.CallID,
183+ IsError: v.IsError,
184+ Content: convertContent(v.Content),
185+ })
186+ }
187+ }
188+ return out
189+}
190+
191+func rebuildContent(blocks []ContentBlock) []provider.Content {
192+ out := make([]provider.Content, 0, len(blocks))
193+ for _, b := range blocks {
194+ switch b.Type {
195+ case "text":
196+ out = append(out, provider.TextBlock{Text: b.Text})
197+ case "image":
198+ out = append(out, provider.ImageBlock{MimeType: b.MimeType, Data: b.Data})
199+ case "tool_call":
200+ args := b.Args
201+ if len(args) == 0 {
202+ args = json.RawMessage("{}")
203+ }
204+ out = append(out, provider.ToolCallBlock{ID: b.ID, Name: b.Name, Arguments: args})
205+ case "tool_result":
206+ out = append(out, provider.ToolResultBlock{
207+ CallID: b.CallID,
208+ IsError: b.IsError,
209+ Content: rebuildContent(b.Content),
210+ })
211+ }
212+ }
213+ return out
214+}
A pkg/zotcore/zotcore.go +350 -0
@@ -0,0 +1,350 @@
1+// Package zotcore is the public Go SDK for embedding the zot agent
2+// runtime in third-party programs. It is the only stable, importable
3+// surface the project exposes; everything under internal/ is subject
4+// to change without notice.
5+//
6+// Lifecycle:
7+//
8+// rt, err := zotcore.New(zotcore.Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
9+// if err != nil { ... }
10+// defer rt.Close()
11+//
12+// events, err := rt.Prompt(ctx, "fix the failing test", nil)
13+// if err != nil { ... }
14+// for ev := range events {
15+// fmt.Printf("%s: %+v\n", ev.Type(), ev)
16+// }
17+//
18+// Concurrency model: one prompt at a time per Runtime. Spawn one
19+// Runtime per project / cwd. The Cancel call interrupts the active
20+// prompt; subsequent prompts work normally.
21+//
22+// For a non-Go consumer, run `zot rpc` and speak the same JSON
23+// schema over stdin/stdout. See docs/rpc.md.
24+package zotcore
25+
26+import (
27+ "context"
28+ "errors"
29+ "fmt"
30+ "sync"
31+
32+ "github.com/patriceckhart/zot/internal/agent"
33+ "github.com/patriceckhart/zot/internal/core"
34+ "github.com/patriceckhart/zot/internal/provider"
35+)
36+
37+// Config configures a Runtime. All fields are optional; sensible
38+// defaults are read from $ZOT_HOME/config.json, env vars, and the
39+// resolver chain (the same one the cli uses).
40+type Config struct {
41+ // Provider is "anthropic" or "openai". Empty = use the user's
42+ // default from config.json or env.
43+ Provider string
44+
45+ // Model is the model id. Empty = use the provider's default.
46+ Model string
47+
48+ // CWD is the working directory the agent operates in. Tools are
49+ // confined to this directory when Lock is true. Empty = current
50+ // process cwd.
51+ CWD string
52+
53+ // SystemPrompt overrides the built-in system prompt.
54+ SystemPrompt string
55+
56+ // AppendSystemPrompt is appended to the built-in (or overridden)
57+ // system prompt. Useful for project-specific instructions.
58+ AppendSystemPrompt []string
59+
60+ // Reasoning sets the reasoning effort for models that support it
61+ // ("low", "medium", "high"). Empty = no reasoning.
62+ Reasoning string
63+
64+ // MaxSteps caps the agent loop iterations per Prompt call.
65+ // 0 uses the default (50).
66+ MaxSteps int
67+
68+ // APIKey overrides the credential lookup chain.
69+ APIKey string
70+
71+ // BaseURL overrides the provider base url (for tests / proxies).
72+ BaseURL string
73+
74+ // Tools is the list of tools to enable. Nil/empty = all four
75+ // (read, write, edit, bash). Pass an empty-but-non-nil slice
76+ // (e.g. []string{}) plus NoTools=true to disable everything.
77+ Tools []string
78+
79+ // NoTools disables every tool. Useful for chat-only embeddings.
80+ NoTools bool
81+
82+ // Lock confines tools to CWD. Same effect as the /lock command.
83+ Lock bool
84+}
85+
86+// Runtime is one zot agent session. Safe for use from one goroutine
87+// at a time per Runtime; create separate Runtimes for parallel work.
88+type Runtime struct {
89+ mu sync.Mutex
90+ agent *core.Agent
91+ provider string
92+ model string
93+ cwd string
94+
95+ // activeCancel is set while a Prompt is streaming.
96+ activeCancel context.CancelFunc
97+
98+ // closed signals that Close has been called.
99+ closed bool
100+}
101+
102+// New constructs a Runtime from cfg. Returns an error if no
103+// credential is available for the requested provider.
104+func New(cfg Config) (*Runtime, error) {
105+ args := agent.Args{
106+ Mode: agent.ModeJSON, // headless
107+ Provider: cfg.Provider,
108+ Model: cfg.Model,
109+ CWD: cfg.CWD,
110+ APIKey: cfg.APIKey,
111+ BaseURL: cfg.BaseURL,
112+ SystemPrompt: cfg.SystemPrompt,
113+ AppendSystemPrompt: cfg.AppendSystemPrompt,
114+ Reasoning: cfg.Reasoning,
115+ MaxSteps: cfg.MaxSteps,
116+ Tools: cfg.Tools,
117+ NoTools: cfg.NoTools,
118+ NoSess: true, // SDK callers manage persistence themselves
119+ }
120+ if args.MaxSteps == 0 {
121+ args.MaxSteps = 50
122+ }
123+ r, err := agent.Resolve(args, true)
124+ if err != nil {
125+ return nil, err
126+ }
127+ if cfg.Lock && r.Sandbox != nil {
128+ r.Sandbox.Lock()
129+ }
130+ ag := r.NewAgent()
131+ return &Runtime{
132+ agent: ag,
133+ provider: r.Provider,
134+ model: r.Model,
135+ cwd: r.CWD,
136+ }, nil
137+}
138+
139+// Provider returns the active provider id.
140+func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider }
141+
142+// Model returns the active model id.
143+func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model }
144+
145+// CWD returns the working directory the agent operates in.
146+func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd }
147+
148+// Messages returns a copy of the current transcript.
149+func (r *Runtime) Messages() []Message {
150+ if r.agent == nil {
151+ return nil
152+ }
153+ src := r.agent.Messages()
154+ out := make([]Message, len(src))
155+ for i, m := range src {
156+ out[i] = Message{
157+ Role: string(m.Role),
158+ Content: convertContent(m.Content),
159+ Time: m.Time.Format("2006-01-02T15:04:05Z"),
160+ }
161+ }
162+ return out
163+}
164+
165+// SetMessages replaces the transcript. Use to seed history from a
166+// session file or to clear with nil.
167+func (r *Runtime) SetMessages(msgs []Message) {
168+ if r.agent == nil {
169+ return
170+ }
171+ out := make([]provider.Message, 0, len(msgs))
172+ for _, m := range msgs {
173+ out = append(out, provider.Message{
174+ Role: provider.Role(m.Role),
175+ Content: rebuildContent(m.Content),
176+ })
177+ }
178+ r.agent.SetMessages(out)
179+}
180+
181+// Cost returns the cumulative usage and dollar cost for this Runtime.
182+func (r *Runtime) Cost() Usage {
183+ if r.agent == nil {
184+ return Usage{}
185+ }
186+ u := r.agent.Cost()
187+ return Usage{
188+ Input: u.InputTokens,
189+ Output: u.OutputTokens,
190+ CacheRead: u.CacheReadTokens,
191+ CacheWrite: u.CacheWriteTokens,
192+ CostUSD: u.CostUSD,
193+ }
194+}
195+
196+// Prompt sends a user message and runs the agent loop. Returns a
197+// channel that emits one Event per agent action, closed when the
198+// turn finishes (cleanly or with error). Only one Prompt may be
199+// active at a time per Runtime; concurrent calls return ErrBusy.
200+func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) {
201+ r.mu.Lock()
202+ if r.closed {
203+ r.mu.Unlock()
204+ return nil, ErrClosed
205+ }
206+ if r.activeCancel != nil {
207+ r.mu.Unlock()
208+ return nil, ErrBusy
209+ }
210+ if r.agent == nil {
211+ r.mu.Unlock()
212+ return nil, fmt.Errorf("zotcore: no agent (login first via /login or set credentials)")
213+ }
214+ subCtx, cancel := context.WithCancel(ctx)
215+ r.activeCancel = cancel
216+ r.mu.Unlock()
217+
218+ imgBlocks := make([]provider.ImageBlock, 0, len(images))
219+ for _, img := range images {
220+ imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data})
221+ }
222+
223+ out := make(chan Event, 16)
224+ go func() {
225+ defer close(out)
226+ defer func() {
227+ r.mu.Lock()
228+ r.activeCancel = nil
229+ r.mu.Unlock()
230+ }()
231+ err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {
232+ out <- toEvent(ev)
233+ })
234+ if err != nil && !errors.Is(err, context.Canceled) {
235+ out <- Event{Type: "error", Error: err.Error()}
236+ }
237+ }()
238+ return out, nil
239+}
240+
241+// Cancel interrupts the active Prompt, if any. Safe to call when no
242+// prompt is in flight (no-op).
243+func (r *Runtime) Cancel() {
244+ r.mu.Lock()
245+ cancel := r.activeCancel
246+ r.mu.Unlock()
247+ if cancel != nil {
248+ cancel()
249+ }
250+}
251+
252+// Compact summarises the current transcript into a single synthetic
253+// user message, freeing up context. Blocks until done.
254+func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) {
255+ if r.agent == nil {
256+ return CompactResult{}, fmt.Errorf("zotcore: no agent")
257+ }
258+ r.mu.Lock()
259+ if r.activeCancel != nil {
260+ r.mu.Unlock()
261+ return CompactResult{}, ErrBusy
262+ }
263+ subCtx, cancel := context.WithCancel(ctx)
264+ r.activeCancel = cancel
265+ r.mu.Unlock()
266+ defer func() {
267+ r.mu.Lock()
268+ r.activeCancel = nil
269+ r.mu.Unlock()
270+ }()
271+
272+ summary, err := r.agent.Compact(subCtx, 4, nil)
273+ if err != nil {
274+ return CompactResult{}, err
275+ }
276+ return CompactResult{Summary: summary, Messages: r.Messages()}, nil
277+}
278+
279+// SetModel switches the active model. Same provider only; for cross-
280+// provider switches, create a new Runtime.
281+func (r *Runtime) SetModel(model string) error {
282+ r.mu.Lock()
283+ defer r.mu.Unlock()
284+ if r.agent == nil {
285+ return fmt.Errorf("zotcore: no agent")
286+ }
287+ if _, err := provider.FindModel(r.provider, model); err != nil {
288+ return err
289+ }
290+ r.agent.Model = model
291+ r.model = model
292+ return nil
293+}
294+
295+// State returns a snapshot of the runtime's current state. Useful for
296+// driving UIs.
297+func (r *Runtime) State() State {
298+ r.mu.Lock()
299+ defer r.mu.Unlock()
300+ return State{
301+ Provider: r.provider,
302+ Model: r.model,
303+ CWD: r.cwd,
304+ Busy: r.activeCancel != nil,
305+ MessageCount: len(r.Messages()),
306+ }
307+}
308+
309+// Close releases resources held by the Runtime. Cancels any active
310+// prompt. Subsequent calls return ErrClosed.
311+func (r *Runtime) Close() error {
312+ r.mu.Lock()
313+ if r.closed {
314+ r.mu.Unlock()
315+ return nil
316+ }
317+ r.closed = true
318+ cancel := r.activeCancel
319+ r.mu.Unlock()
320+ if cancel != nil {
321+ cancel()
322+ }
323+ return nil
324+}
325+
326+// ListModels returns every model known to the runtime for the
327+// current provider (catalog + live discovery if cached).
328+func (r *Runtime) ListModels() []ModelInfo {
329+ models := provider.ModelsForProvider(r.Provider())
330+ out := make([]ModelInfo, 0, len(models))
331+ for _, m := range models {
332+ out = append(out, ModelInfo{
333+ ID: m.ID,
334+ Provider: m.Provider,
335+ ContextWindow: m.ContextWindow,
336+ MaxOutput: m.MaxOutput,
337+ Reasoning: m.Reasoning,
338+ })
339+ }
340+ return out
341+}
342+
343+// ---- public errors ----
344+
345+// ErrBusy is returned when a Prompt or Compact is started while
346+// another is in flight on the same Runtime.
347+var ErrBusy = errors.New("zotcore: runtime is busy")
348+
349+// ErrClosed is returned by methods on a Runtime after Close.
350+var ErrClosed = errors.New("zotcore: runtime is closed")
@@ -0,0 +1,350 @@
1+// Package zotcore is the public Go SDK for embedding the zot agent
2+// runtime in third-party programs. It is the only stable, importable
3+// surface the project exposes; everything under internal/ is subject
4+// to change without notice.
5+//
6+// Lifecycle:
7+//
8+// rt, err := zotcore.New(zotcore.Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
9+// if err != nil { ... }
10+// defer rt.Close()
11+//
12+// events, err := rt.Prompt(ctx, "fix the failing test", nil)
13+// if err != nil { ... }
14+// for ev := range events {
15+// fmt.Printf("%s: %+v\n", ev.Type(), ev)
16+// }
17+//
18+// Concurrency model: one prompt at a time per Runtime. Spawn one
19+// Runtime per project / cwd. The Cancel call interrupts the active
20+// prompt; subsequent prompts work normally.
21+//
22+// For a non-Go consumer, run `zot rpc` and speak the same JSON
23+// schema over stdin/stdout. See docs/rpc.md.
24+package zotcore
25+
26+import (
27+ "context"
28+ "errors"
29+ "fmt"
30+ "sync"
31+
32+ "github.com/patriceckhart/zot/internal/agent"
33+ "github.com/patriceckhart/zot/internal/core"
34+ "github.com/patriceckhart/zot/internal/provider"
35+)
36+
37+// Config configures a Runtime. All fields are optional; sensible
38+// defaults are read from $ZOT_HOME/config.json, env vars, and the
39+// resolver chain (the same one the cli uses).
40+type Config struct {
41+ // Provider is "anthropic" or "openai". Empty = use the user's
42+ // default from config.json or env.
43+ Provider string
44+
45+ // Model is the model id. Empty = use the provider's default.
46+ Model string
47+
48+ // CWD is the working directory the agent operates in. Tools are
49+ // confined to this directory when Lock is true. Empty = current
50+ // process cwd.
51+ CWD string
52+
53+ // SystemPrompt overrides the built-in system prompt.
54+ SystemPrompt string
55+
56+ // AppendSystemPrompt is appended to the built-in (or overridden)
57+ // system prompt. Useful for project-specific instructions.
58+ AppendSystemPrompt []string
59+
60+ // Reasoning sets the reasoning effort for models that support it
61+ // ("low", "medium", "high"). Empty = no reasoning.
62+ Reasoning string
63+
64+ // MaxSteps caps the agent loop iterations per Prompt call.
65+ // 0 uses the default (50).
66+ MaxSteps int
67+
68+ // APIKey overrides the credential lookup chain.
69+ APIKey string
70+
71+ // BaseURL overrides the provider base url (for tests / proxies).
72+ BaseURL string
73+
74+ // Tools is the list of tools to enable. Nil/empty = all four
75+ // (read, write, edit, bash). Pass an empty-but-non-nil slice
76+ // (e.g. []string{}) plus NoTools=true to disable everything.
77+ Tools []string
78+
79+ // NoTools disables every tool. Useful for chat-only embeddings.
80+ NoTools bool
81+
82+ // Lock confines tools to CWD. Same effect as the /lock command.
83+ Lock bool
84+}
85+
86+// Runtime is one zot agent session. Safe for use from one goroutine
87+// at a time per Runtime; create separate Runtimes for parallel work.
88+type Runtime struct {
89+ mu sync.Mutex
90+ agent *core.Agent
91+ provider string
92+ model string
93+ cwd string
94+
95+ // activeCancel is set while a Prompt is streaming.
96+ activeCancel context.CancelFunc
97+
98+ // closed signals that Close has been called.
99+ closed bool
100+}
101+
102+// New constructs a Runtime from cfg. Returns an error if no
103+// credential is available for the requested provider.
104+func New(cfg Config) (*Runtime, error) {
105+ args := agent.Args{
106+ Mode: agent.ModeJSON, // headless
107+ Provider: cfg.Provider,
108+ Model: cfg.Model,
109+ CWD: cfg.CWD,
110+ APIKey: cfg.APIKey,
111+ BaseURL: cfg.BaseURL,
112+ SystemPrompt: cfg.SystemPrompt,
113+ AppendSystemPrompt: cfg.AppendSystemPrompt,
114+ Reasoning: cfg.Reasoning,
115+ MaxSteps: cfg.MaxSteps,
116+ Tools: cfg.Tools,
117+ NoTools: cfg.NoTools,
118+ NoSess: true, // SDK callers manage persistence themselves
119+ }
120+ if args.MaxSteps == 0 {
121+ args.MaxSteps = 50
122+ }
123+ r, err := agent.Resolve(args, true)
124+ if err != nil {
125+ return nil, err
126+ }
127+ if cfg.Lock && r.Sandbox != nil {
128+ r.Sandbox.Lock()
129+ }
130+ ag := r.NewAgent()
131+ return &Runtime{
132+ agent: ag,
133+ provider: r.Provider,
134+ model: r.Model,
135+ cwd: r.CWD,
136+ }, nil
137+}
138+
139+// Provider returns the active provider id.
140+func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider }
141+
142+// Model returns the active model id.
143+func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model }
144+
145+// CWD returns the working directory the agent operates in.
146+func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd }
147+
148+// Messages returns a copy of the current transcript.
149+func (r *Runtime) Messages() []Message {
150+ if r.agent == nil {
151+ return nil
152+ }
153+ src := r.agent.Messages()
154+ out := make([]Message, len(src))
155+ for i, m := range src {
156+ out[i] = Message{
157+ Role: string(m.Role),
158+ Content: convertContent(m.Content),
159+ Time: m.Time.Format("2006-01-02T15:04:05Z"),
160+ }
161+ }
162+ return out
163+}
164+
165+// SetMessages replaces the transcript. Use to seed history from a
166+// session file or to clear with nil.
167+func (r *Runtime) SetMessages(msgs []Message) {
168+ if r.agent == nil {
169+ return
170+ }
171+ out := make([]provider.Message, 0, len(msgs))
172+ for _, m := range msgs {
173+ out = append(out, provider.Message{
174+ Role: provider.Role(m.Role),
175+ Content: rebuildContent(m.Content),
176+ })
177+ }
178+ r.agent.SetMessages(out)
179+}
180+
181+// Cost returns the cumulative usage and dollar cost for this Runtime.
182+func (r *Runtime) Cost() Usage {
183+ if r.agent == nil {
184+ return Usage{}
185+ }
186+ u := r.agent.Cost()
187+ return Usage{
188+ Input: u.InputTokens,
189+ Output: u.OutputTokens,
190+ CacheRead: u.CacheReadTokens,
191+ CacheWrite: u.CacheWriteTokens,
192+ CostUSD: u.CostUSD,
193+ }
194+}
195+
196+// Prompt sends a user message and runs the agent loop. Returns a
197+// channel that emits one Event per agent action, closed when the
198+// turn finishes (cleanly or with error). Only one Prompt may be
199+// active at a time per Runtime; concurrent calls return ErrBusy.
200+func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) {
201+ r.mu.Lock()
202+ if r.closed {
203+ r.mu.Unlock()
204+ return nil, ErrClosed
205+ }
206+ if r.activeCancel != nil {
207+ r.mu.Unlock()
208+ return nil, ErrBusy
209+ }
210+ if r.agent == nil {
211+ r.mu.Unlock()
212+ return nil, fmt.Errorf("zotcore: no agent (login first via /login or set credentials)")
213+ }
214+ subCtx, cancel := context.WithCancel(ctx)
215+ r.activeCancel = cancel
216+ r.mu.Unlock()
217+
218+ imgBlocks := make([]provider.ImageBlock, 0, len(images))
219+ for _, img := range images {
220+ imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data})
221+ }
222+
223+ out := make(chan Event, 16)
224+ go func() {
225+ defer close(out)
226+ defer func() {
227+ r.mu.Lock()
228+ r.activeCancel = nil
229+ r.mu.Unlock()
230+ }()
231+ err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {
232+ out <- toEvent(ev)
233+ })
234+ if err != nil && !errors.Is(err, context.Canceled) {
235+ out <- Event{Type: "error", Error: err.Error()}
236+ }
237+ }()
238+ return out, nil
239+}
240+
241+// Cancel interrupts the active Prompt, if any. Safe to call when no
242+// prompt is in flight (no-op).
243+func (r *Runtime) Cancel() {
244+ r.mu.Lock()
245+ cancel := r.activeCancel
246+ r.mu.Unlock()
247+ if cancel != nil {
248+ cancel()
249+ }
250+}
251+
252+// Compact summarises the current transcript into a single synthetic
253+// user message, freeing up context. Blocks until done.
254+func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) {
255+ if r.agent == nil {
256+ return CompactResult{}, fmt.Errorf("zotcore: no agent")
257+ }
258+ r.mu.Lock()
259+ if r.activeCancel != nil {
260+ r.mu.Unlock()
261+ return CompactResult{}, ErrBusy
262+ }
263+ subCtx, cancel := context.WithCancel(ctx)
264+ r.activeCancel = cancel
265+ r.mu.Unlock()
266+ defer func() {
267+ r.mu.Lock()
268+ r.activeCancel = nil
269+ r.mu.Unlock()
270+ }()
271+
272+ summary, err := r.agent.Compact(subCtx, 4, nil)
273+ if err != nil {
274+ return CompactResult{}, err
275+ }
276+ return CompactResult{Summary: summary, Messages: r.Messages()}, nil
277+}
278+
279+// SetModel switches the active model. Same provider only; for cross-
280+// provider switches, create a new Runtime.
281+func (r *Runtime) SetModel(model string) error {
282+ r.mu.Lock()
283+ defer r.mu.Unlock()
284+ if r.agent == nil {
285+ return fmt.Errorf("zotcore: no agent")
286+ }
287+ if _, err := provider.FindModel(r.provider, model); err != nil {
288+ return err
289+ }
290+ r.agent.Model = model
291+ r.model = model
292+ return nil
293+}
294+
295+// State returns a snapshot of the runtime's current state. Useful for
296+// driving UIs.
297+func (r *Runtime) State() State {
298+ r.mu.Lock()
299+ defer r.mu.Unlock()
300+ return State{
301+ Provider: r.provider,
302+ Model: r.model,
303+ CWD: r.cwd,
304+ Busy: r.activeCancel != nil,
305+ MessageCount: len(r.Messages()),
306+ }
307+}
308+
309+// Close releases resources held by the Runtime. Cancels any active
310+// prompt. Subsequent calls return ErrClosed.
311+func (r *Runtime) Close() error {
312+ r.mu.Lock()
313+ if r.closed {
314+ r.mu.Unlock()
315+ return nil
316+ }
317+ r.closed = true
318+ cancel := r.activeCancel
319+ r.mu.Unlock()
320+ if cancel != nil {
321+ cancel()
322+ }
323+ return nil
324+}
325+
326+// ListModels returns every model known to the runtime for the
327+// current provider (catalog + live discovery if cached).
328+func (r *Runtime) ListModels() []ModelInfo {
329+ models := provider.ModelsForProvider(r.Provider())
330+ out := make([]ModelInfo, 0, len(models))
331+ for _, m := range models {
332+ out = append(out, ModelInfo{
333+ ID: m.ID,
334+ Provider: m.Provider,
335+ ContextWindow: m.ContextWindow,
336+ MaxOutput: m.MaxOutput,
337+ Reasoning: m.Reasoning,
338+ })
339+ }
340+ return out
341+}
342+
343+// ---- public errors ----
344+
345+// ErrBusy is returned when a Prompt or Compact is started while
346+// another is in flight on the same Runtime.
347+var ErrBusy = errors.New("zotcore: runtime is busy")
348+
349+// ErrClosed is returned by methods on a Runtime after Close.
350+var ErrClosed = errors.New("zotcore: runtime is closed")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close