# Plan: `prime:` SDK backend and `mini:` model prefix rename
## User Request
Add `prime:` as a backend via the SDK so these work:
- `malvin --model=prime:openai/gpt-5.5 PROMPT`
- `malvin --model=cursor:auto PROMPT`
- `malvin --model=prime:openrouter/anthropic/claude-3-haiku PROMPT`
Rename malvin-mini model options away from bare `openrouter:` / `local:` to nested under `mini:`:
- `malvin --model=mini:openrouter/anthropic/claude-3-haiku PROMPT`
- `malvin --model=mini:local/qwen35_9b_q4 PROMPT`
Do not confuse Prime’s `openrouter/…` provider slug with today’s malvin-mini `openrouter:` prefix.
## Current State
### Model id routing
`src/model_id.rs` is the single gate. Accepted prefixes today:
| Prefix | Backend |
|--------|---------|
| `cursor:` | `AgentBackend::CursorSdk` (Node JSONL → `@cursor/sdk`) |
| `openrouter:` | `AgentBackend::Mini` + OpenRouter HTTP |
| `local:` | `AgentBackend::Mini` + in-process llama.cpp |
Bare ids are rejected. Default CLI model is `cursor:auto` (`support_paths::DEFAULT_CLI_MODEL`). There is no `prime:` or `mini:` prefix.
Factory (`src/agent_backend/factory.rs`):
1. `uses_mini_backend(model)` → Mini
2. else if `MALVIN_AGENT_ACP_BIN` set → Acp (tests/legacy)
3. else → CursorSdk
Anything non-mini that parses today is treated as Cursor.
### Cursor SDK path (template for Prime)
- Rust: `src/cursor_sdk/` (`CursorSdkClient`, JSONL protocol in `protocol.rs`)
- Node: `cursor-sdk-bridge/` (`Agent.create` / `send` / stream; Node ≥ 22.13)
- Bridge resolve: `MALVIN_CURSOR_SDK_BRIDGE` or `cursor-sdk-bridge/dist/bridge.js`
- Auth: `CURSOR_API_KEY` / `CURSOR_AGENT_API_KEY` / `AGENT_API_KEY`
- Keeps one coder session across Continues (`keeps_coder_session_for_process_life`)
- Logging: `log_adapter` tees stdout + appends `trace.jsonl`; `session_io` calls `record_sdk_usage` on `run_done.usage` for TOKENS / run_timing
- Node resolve: `src/cursor_sdk/node_resolve.rs` (sticky `~/.malvin_home/node_bin`; today checks major ≥ 22 only)
### Mini (malvin-mini) path
- In-process `MiniAgentClient` (`src/mini_agent/`) with bash-fence loop
- Transport chosen in `build_llm_backend` from `openrouter:` vs `local:` (else branch currently assumes OpenRouter for any non-local id)
- `openrouter:auto` → `MINI_DEFAULT_MODEL` (`nvidia/nemotron-3-ultra-550b-a55b:free`)
- Cost: OpenRouter `UseReported`; local `Zero`
- Hidden CLI knobs: `--mini-max-*`, `--no-download`
- Docs/tests/helptext widely say `openrouter:` / `local:`
- `malvin models download` accepts `local:<id>` **or** a bare known slug (`download.rs`)
### Prime today (not wired to `--model`)
- Design doc: `prime.tex` / `prime.pdf` — recommends `prime:` as peer of `cursor:` via SDK bridge, not ACP (diagram still labels Mini as `openrouter:`/`local:`)
- Host install: `prime-agent` 0.7.0 under `~/.local/share/prime-agent-node/…/node_modules/prime-agent` (needs Node ≥ 22.8; `/usr/bin/node` is too old)
- Package exports `createAgentSession`, `AuthStorage`, `ModelRegistry`, `SessionManager` (same surface as `@earendil-works/pi-coding-agent` docs)
- Model namespace is `provider/model` (confirmed: `openai/gpt-5.5`, `openrouter/…` via `prime-agent model list`; catalog is ~300+ rows, mostly OpenRouter)
- Fast-task `--agent=prime` (`src/python/fast_task.py`) is unrelated Docker CLI wiring — leave alone
- Experimental `_prime_acp_wrapper.sh` is ACP-only; cancel semantics mismatch; not the product path
### Cost config keys
`parse_model_token_cost_rates` builds map keys as `{provider}:{model_name}` from `[agent.<provider>.<name>]`. Lookup uses the full prefixed CLI model id (e.g. `cursor:auto`).
### Listing
`malvin models` (`src/cli/models_cmd.rs`) prints `cursor:`, `openrouter:`, and (on Metal) `local:` sections. Download: `malvin models download local:<id>` (bare slug also works today).
## Target grammar (after change)
| Id | Backend | Notes |
|----|---------|-------|
| `cursor:…` | CursorSdk | unchanged; default `cursor:auto` |
| `prime:<provider>/<model…>` | PrimeSdk | peer of Cursor; never Mini/ACP |
| `mini:openrouter/<slug…>` | Mini + OpenRouter HTTP | `mini:openrouter/auto` → `MINI_DEFAULT_MODEL` |
| `mini:local/<slug>` | Mini + local llama.cpp | Metal listings only |
| `openrouter:…` / `local:…` | rejected | rename hint to `mini:…` |
Factory order: Mini → Prime → (ACP if `MALVIN_AGENT_ACP_BIN` set) → CursorSdk.
## Requested Changes
1. Accept `prime:<provider>/<model>` and route it to a new Prime SDK backend (peer of Cursor SDK), not Mini and not ACP-by-default.
2. Rename mini model ids to `mini:openrouter/<slug>` and `mini:local/<slug>`; hard-break bare `openrouter:` / `local:` with a rename hint.
3. Keep `cursor:` unchanged (including `cursor:auto` as default).
4. Prime runs must write `trace.jsonl`, tee VISION-style logs (assistant / thoughts / tools), and record TOKENS via the Cursor-style `record_sdk_usage` path (not the log adapter alone).
5. `malvin models` must list a **bounded** `prime:` section in the same change; download accepts only `mini:local/<id>` (no bare slug, no `local:`).
6. Update parsing, factory, cost policy, docs (including `assets/default_malvin_home_config.toml` and `prime.tex`), and tests to match.
7. Node resolve must respect Cursor ≥ 22.13 and Prime ≥ 22.8 without a sticky-bin collision.
## Q&A
### Q1. What happens to legacy `openrouter:` / `local:` prefixes?
**Answer:** Hard break. Reject them with an error that names the new forms (`mini:openrouter/…`, `mini:local/…`).
### Q2. Cost-rate TOML key shape after rename?
**Answer:** Use the full prefixed model id, matching today’s `{provider}:{rest}` map:
- `[agent.cursor.auto]` → `cursor:auto`
- `[agent.mini."openrouter/org/model"]` → `mini:openrouter/org/model`
- `[agent.mini."local/qwen35_9b_q4"]` → `mini:local/qwen35_9b_q4`
- `[agent.prime."openai/gpt-5.5"]` → `prime:openai/gpt-5.5`
Migrate any checked-in / documented `[agent.openrouter.*]` / `[agent.local.*]` examples to the `mini` form.
### Q3. How does `malvin models download` change?
**Answer:** Accept only `mini:local/<id>`. Reject bare slugs and old `local:<id>` with a pointer to `mini:local/<id>`. Drop the bare-slug branch in `download.rs`.
### Q4. npm dependency for the Prime bridge?
**Answer:** `prime-agent` is **not** on the public npm registry (install.sh / R2 tarballs). `prime-sdk-bridge/package.json` must depend on the release tarball, e.g. `https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev/releases/v0.7.0/prime-agent-0.7.0.tgz` (pin version; when bumping, refresh from `https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev/latest.json` — root manifest, not under `/releases/`). That package exports `createAgentSession`. Bridge engines: Node ≥ 22.8. Build/docs parallel to `cursor-sdk-bridge`. Never forward Cursor credentials into Prime.
### Q5. Include `prime:` in `malvin models` in the same change?
**Answer:** Yes. List via a Prime models script (bridge/`ModelRegistry` or `prime-agent model list`), printed as `prime:<provider>/<model>`. Best-effort like OpenRouter: section failure must not block other sections. Do **not** dump the full catalog (~300+ rows, mostly `openrouter`): group by provider and/or cap (e.g. show `openai` + a short OpenRouter sample, with a one-line hint that more exist via `prime-agent model list`).
### Q6. Does `MALVIN_AGENT_ACP_BIN` override `prime:`?
**Answer:** No. ACP mock override remains for Cursor/test paths only. Factory order becomes: Mini → Prime → (ACP if env set) → CursorSdk. `prime:` never falls through to CursorSdk or Acp.
### Q7. Session lifetime for Prime?
**Answer:** Keep one Node bridge process and one in-memory `AgentSession` across Continues (`keeps_coder_session_for_process_life` true for `PrimeSdk`). Use `SessionManager.inMemory()` (no workspace session files). There is **no** Cursor-style `Agent.resume` / `resume` bridge op: if the bridge process dies, create a fresh session (do not invent a resume id path in v1).
### Q8. How is `mini:` slug parsed vs Prime `openrouter/…`?
**Answer:**
| Id | Backend | Meaning |
|----|---------|---------|
| `mini:openrouter/anthropic/claude-3-haiku` | Mini + OpenRouter HTTP | transport `openrouter`, HTTP model `anthropic/claude-3-haiku` |
| `mini:local/qwen35_9b_q4` | Mini + local | transport `local`, slug `qwen35_9b_q4` |
| `prime:openrouter/anthropic/claude-3-haiku` | Prime SDK | Prime provider `openrouter`, model id as Prime expects |
| `prime:openai/gpt-5.5` | Prime SDK | `getModel("openai", "gpt-5.5")` (or ModelRegistry equivalent) |
After `mini:`, split on the first `/` into transport + remainder. Only `openrouter` and `local` transports are valid. Reject missing `/` or empty segments. `mini:openrouter/auto` keeps today’s auto → `MINI_DEFAULT_MODEL` behavior.
### Q9. Will Prime collect traces and TOKENS?
**Answer:** Yes. Mirror Cursor’s split:
1. **Log adapter** — tee assistant / thoughts / tools; append sdk-shaped lines to `run_dir/trace.jsonl` via `AcpJsonlTrace`.
2. **Session drain** — record TOKENS via `record_sdk_usage` (or shared helper) from `run_done.usage` / `usage` events. The log adapter alone does **not** fill run_timing.
Set `prompts_log_run_dir` before `begin_coder_session` (flows already call `AgentBackend::set_prompts_log_run_dir`; add the `PrimeSdk` match arm). Bridge must emit at least `assistant`, `thinking`, `tool_call`, `usage`, and `run_done`.
### Q10. Will Prime use the malvin logger (file + stdout: output, thoughts, tool lines)?
**Answer:** Yes. Reuse the Cursor SDK log-adapter behavior (or share it): coalesced assistant → `WHO_M` / stdout.log; thinking → thoughts when `show_thoughts_on_stdout`; tool start/end → `t|` tool summary lines via `print_stdout_acp_tool_summary_tee`. Respect `AgentIoOptions` (`no_tee`, `raw_output`, `emit_stdout_markdown`). Goal is VISION parity: Prime logs should look like Cursor SDK logs to the user.
### Q11. Which Prime tools are enabled?
**Answer:** Default allowlist `tools: ["ipython"]` (Prime’s coding surface per `prime.tex` / SDK). Do not enable Prime `--autonomous*`. Broader tool sets are out of scope unless requested later.
### Q12. Cost policy for `prime:openrouter/…` vs `mini:openrouter/…`?
**Answer:** Discriminate by backend/transport, not by the substring `openrouter`. `mini:openrouter/…` → `CostPolicy::UseReported`; `mini:local/…` → `Zero`; `prime:…` (including `prime:openrouter/…`) → `EstimateFromRates`. Add unit tests for both openrouter forms.
### Q13. How should Node resolution work for Cursor vs Prime?
**Answer:** Cursor floor ≥ 22.13; Prime floor ≥ 22.8. Do **not** naively share `~/.malvin_home/node_bin` across unequal floors (a 22.8 sticky bin would later break Cursor). Prefer either separate sticky keys/resolvers, or one shared resolver that never persists a bin below 22.13. Prefer Prime’s bundled Node under `~/.local/share/prime-agent-node/` when resolving for the Prime bridge.
## Plan
### Phase 1 — Model id grammar (`mini:` + `prime:`)
- [x] Extend `ModelBackend` with `Prime` and replace `OpenRouter`/`Local` with a Mini shape that carries transport (`OpenRouter` | `Local`) under prefix `mini:`
- [x] Add `PRIME_PREFIX = "prime:"`, `MINI_PREFIX = "mini:"`; hard-reject bare `OPENROUTER_PREFIX` / `LOCAL_PREFIX` with a rename hint
- [x] Parse `mini:<transport>/<slug…>` and `prime:<provider>/<model…>` (reject empty segments / missing `/`)
- [x] Update `canonical()`, `provider_slug` / mini resolve, `uses_mini_backend`, `uses_openrouter_backend` (**Mini OpenRouter transport only**), `uses_local_backend`, `cost_policy_for_model`, `UNPREFIXED_MODEL_MESSAGE`
- [x] Update `build_llm_backend` to select Local vs OpenRouter **only** via mini transport helpers; reject non-mini ids (no else→OpenRouter footgun)
- [x] Update `local_llm/engine.rs::local_slug`, `download.rs` (drop bare-slug accept path), `registry.rs` user-facing strings → `mini:local/…`, shared-opts help, config validation messages
- [x] Tests: `cost_policy_for_model("mini:openrouter/x") == UseReported`, `("prime:openrouter/x") == EstimateFromRates`, `("mini:local/x") == Zero`
- [x] Rewrite unit tests in `model_id.rs`, `llm_backend.rs`, `config_defaults_tests*`, `tokens_tests`, `backend_tests`, local download/engine tests
**Validation:** `cargo test model_id`; `cargo test build_llm_backend`; `cargo test cost_policy`; assert `openrouter:x` and `local:x` fail with rename hint; assert `mini:openrouter/x`, `mini:local/x`, `prime:openai/gpt-5.5` parse and route helpers agree; assert bare download slug fails.
### Phase 2 — Prime SDK bridge + Rust client
- [x] Add `prime-sdk-bridge/` mirrored on `cursor-sdk-bridge/`: JSONL ops `create` / `send` / `cancel` / `close` (**no `resume` in v1**)
- [x] `package.json` depends on the `prime-agent` R2 tarball URL (not the public npm registry); Node ≥ 22.8
- [x] Map `create` slug `provider/model` → Prime model selection; `SessionManager.inMemory()`; `tools: ["ipython"]`; `session.prompt`; subscribe → `assistant` / `thinking` / `tool_call` / `usage` / `run_done` / `fatal` events
- [x] Auth via Prime `AuthStorage` / runtime API keys for the provider; never forward Cursor API keys
- [x] Add `src/prime_sdk/` (client, spawn, bridge path `MALVIN_PRIME_SDK_BRIDGE`)
- [x] Log adapter: append sdk-shaped events to `trace.jsonl`; tee assistant / thoughts / tool lines to stdout + `stdout.log` using the same coalescing and `t|` tool path as `src/cursor_sdk/log_adapter*.rs` (share code if practical)
- [x] Session drain: on `run_done` / `usage`, call `record_sdk_usage` (mirror `src/cursor_sdk/session_io.rs` + `timing.rs`) so TOKENS / run_timing fill; do not rely on the log adapter for that
- [x] Add `AgentBackend::PrimeSdk`; wire `set_prompts_log_run_dir` and all other match arms in `backend.rs` / `backend_ops.rs` (and any other exhaustive matches)
- [x] `keeps_coder_session_for_process_life` true; on bridge death, fresh `create` only (no resume id)
- [x] Factory: Mini → Prime → (ACP if env) → CursorSdk; ACP env must not steal `prime:`
- [x] Cost rates: `EstimateFromRates` for all `prime:` models
- [x] Node resolve per Q13 (dual floors; no sticky collision; prefer Prime bundled Node for Prime)
**Validation:** Bridge unit tests for protocol encode/decode; Rust mock-session tests assert `trace.jsonl` gets `assistant`/`thinking`/`tool_call` and stdout tee emits message / thought / tool lines under non-`no_tee` io; mock `run_done.usage` fills run_timing via `record_sdk_usage`; `build_agent_backend` returns `PrimeSdk` for `prime:openai/gpt-5.5` and `CursorSdk` for `cursor:auto`; with `MALVIN_AGENT_ACP_BIN` set, `prime:…` still selects PrimeSdk.
### Phase 3 — CLI listing, docs, and rename sweep
- [x] `malvin models`: print `cursor:…`, bounded `prime:…`, `mini:openrouter/…`, and (Metal) `mini:local/…`; download only `mini:local/<id>`
- [x] Add Prime models listing entry (bridge `models.js` or equivalent) with provider grouping / cap (see Q5); do not print the full ~300-model catalog
- [x] Update `default_prompts/docs/malvin.md`, `models.md`, `agent-interface.md`, `VISION.md`, `muse.md`, `assets/default_malvin_home_config.toml`, clap help, error strings
- [x] Update `prime.tex` (regen `prime.pdf`): Mini as `mini:…`; cost keys as Q2 quoted form; proposed factory diagram matches Target grammar
- [x] Grep-clean remaining primary docs/tests that teach bare `openrouter:` / `local:` as `--model` values (leave historical log artifacts alone)
- [x] Note in docs: Prime `openrouter/…` ≠ Mini `mini:openrouter/…`; Prime uses the same log/trace/TOKENS surface as Cursor SDK; bridge depends on R2 tarball
**Validation:** `malvin models` output contains bounded `prime:` lines and `mini:` prefixes; `rg 'model=openrouter:|--model openrouter:|--model local:|download local:' default_prompts docs assets` is clean for instructional examples; `cargo test` for models_cmd / config docs tests.
### Phase 4 — Live smoke (after unit/integration green)
- [x] Build bridges: `npm ci && npm run build` in `cursor-sdk-bridge/` and `prime-sdk-bridge/`
- [x] Smoke: `malvin --model=cursor:auto --do '…'` still works
- [x] Smoke: `malvin --model=mini:openrouter/<small-model> --do '…'` with `OPENROUTER_API_KEY`
- [x] Smoke: `malvin --model=prime:openai/gpt-5.5 --do '…'` (or cheaper Prime model) with provider key
- [x] On the Prime smoke: confirm `trace.jsonl` with assistant/tool events; stdout/`stdout.log` show normal output, thoughts (when enabled), and tool lines; TOKENS / run_timing non-null when the provider reports usage
**Validation:** Each smoke exits 0; Prime run dir has `trace.jsonl` + logger output as above; no Cursor auth errors on `prime:`; Mini OpenRouter run still records HTTP usage under the new model id.