miii-cli 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -127
- package/dist/config.js +1 -1
- package/dist/llm/stream.js +181 -18
- package/dist/mcp/client.js +3 -1
- package/dist/memory/extractor.js +34 -3
- package/dist/tasks/compactor.js +4 -1
- package/dist/tui/components/ConfigPicker.js +12 -2
- package/dist/tui/components/InputArea.js +5 -2
- package/dist/tui/hooks/useRunLoop.js +156 -80
- package/dist/tui/hooks/useSubmit.js +12 -0
- package/dist/tui/printer.js +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# miii — Ollama Coding CLI. 176 KB. No API Key.
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **Claude Code UX. Ollama models. No invoice.**
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/miii-cli)
|
|
8
|
-
[](https://www.npmjs.com/package/miii-cli)
|
|
9
8
|
[](LICENSE)
|
|
10
9
|
[](https://nodejs.org)
|
|
11
10
|
|
|
11
|
+
**176 KB · no API key · works offline**
|
|
12
|
+
|
|
12
13
|
---
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
Buy hardware once. Pay for AI never.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
Your code never leaves your machine. Nothing sent to Anthropic, OpenAI, or anyone. If you're already running Ollama, miii adds $0 to your stack.
|
|
17
18
|
|
|
18
19
|
```bash
|
|
19
20
|
npm install -g miii-cli && miii
|
|
@@ -23,13 +24,59 @@ npm install -g miii-cli && miii
|
|
|
23
24
|
|
|
24
25
|
## Why Miii Exists
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
**You're probably paying for something miii does for free.**
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Claude Code bills against your Anthropic API key. miii runs open models on Ollama — Llama, Mistral, Qwen, Phi. Fully local. $0. Claude Code has no built-in undo for file changes. A bad edit is a bad edit. Miii checkpoints every file before touching it.
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
The gap is what miii adds on top: file checkpoints before every edit, npm skills, live model switching, and full air-gap support.
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
- **16 GB RAM, a GPU** — if you're already running Ollama, miii adds $0 to your stack
|
|
34
|
+
- **Try Llama 3, Mistral, Qwen, Phi** side by side without switching tools
|
|
35
|
+
- **Literally cannot use cloud AI** — miii with Ollama is purpose-built for zero-internet environments
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## What Miii Actually Does
|
|
40
|
+
|
|
41
|
+
Not a chatbot with a file-write button. Miii is a **full autonomous agent loop** — reasons, plans, acts, self-corrects until the task is done.
|
|
42
|
+
|
|
43
|
+
1. Describe a goal in plain English
|
|
44
|
+
2. Miii reads your codebase, maps the changes, shows the plan
|
|
45
|
+
3. Asks permission before touching anything — every edit, command, delete
|
|
46
|
+
4. Shows exact diff of what changes *before* you approve
|
|
47
|
+
5. Runs tests. If they fail, reads the error, fixes autonomously
|
|
48
|
+
6. Every file checkpointed — hit Esc and everything rolls back
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What a Real Session Looks Like
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
> refactor the auth module to use JWT instead of sessions
|
|
56
|
+
|
|
57
|
+
● thinking…
|
|
58
|
+
● read_file src/auth/session.ts (42 lines)
|
|
59
|
+
● read_file src/middleware/auth.ts (28 lines)
|
|
60
|
+
|
|
61
|
+
─ plan (2 actions)
|
|
62
|
+
◦ edit_file src/auth/session.ts
|
|
63
|
+
◦ edit_file src/middleware/auth.ts
|
|
64
|
+
|
|
65
|
+
⚠ edit_file src/auth/session.ts
|
|
66
|
+
┌─ diff preview ──────────────────────┐
|
|
67
|
+
│ - const session = req.session.user │
|
|
68
|
+
│ + const token = verifyJWT(req) │
|
|
69
|
+
└─────────────────────────────────────┘
|
|
70
|
+
y approve s approve all n deny
|
|
71
|
+
> s
|
|
72
|
+
|
|
73
|
+
● edit_file src/auth/session.ts done
|
|
74
|
+
● edit_file src/middleware/auth.ts done
|
|
75
|
+
● run_tests ✅ passed
|
|
76
|
+
─ done in 14.2s · branch: miii/task-2025-05-17-14-32
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Parallel file reads. Diff preview before approval. Auto-branched off `main`. Tests ran. Session over.
|
|
33
80
|
|
|
34
81
|
---
|
|
35
82
|
|
|
@@ -39,169 +86,153 @@ Your compute. Your data. Your rules.
|
|
|
39
86
|
|---|:---:|:---:|:---:|:---:|:---:|
|
|
40
87
|
| Monthly cost | **$0** | $20–200 | API cost | API cost | $0 |
|
|
41
88
|
| Bundle size | **176 KB** | ~50 MB | ~30 MB | ~20 MB | ~200 MB |
|
|
89
|
+
| Startup time | **<100ms** | ~2s | ~1s | ~1s | ~4s |
|
|
42
90
|
| Local / offline (Ollama) | **✅** | ❌ | partial | ❌ | ⚠️ |
|
|
43
91
|
| Air-gapped | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
44
|
-
|
|
|
92
|
+
| Any model | **✅** | ❌ | partial | ❌ | ✅ |
|
|
45
93
|
| File checkpoints (undo) | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
46
|
-
|
|
|
47
|
-
|
|
|
94
|
+
| Diff preview before approve | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
95
|
+
| Git auto-branch on edit | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
96
|
+
| Switch provider live | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
97
|
+
| Native tool_calls (Anthropic + OpenAI) | **✅** | ✅ | ✅ | ✅ | ❌ |
|
|
98
|
+
| Parallel read-only tools | **✅** | partial | ❌ | ❌ | ❌ |
|
|
99
|
+
| Two-phase plan→execute | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
100
|
+
| Live streaming toggle | **✅** | always on | always on | always on | ❌ |
|
|
48
101
|
| Semantic codebase index | **✅** | ❌ | ❌ | ❌ | ❌ |
|
|
49
|
-
|
|
|
50
|
-
|
|
|
102
|
+
| npm skills | **✅** | plugins | ❌ | ❌ | ❌ |
|
|
103
|
+
| MCP client | **✅** | ✅ | ✅ | ❌ | ❌ |
|
|
51
104
|
| License | **MIT** | Proprietary | MIT | MIT | Apache 2.0 |
|
|
52
105
|
|
|
53
106
|
---
|
|
54
107
|
|
|
55
|
-
##
|
|
108
|
+
## Eight Core Capabilities
|
|
56
109
|
|
|
57
|
-
|
|
110
|
+
**Local / Offline** — Ollama runs on your machine. No internet required after model pull.
|
|
58
111
|
|
|
59
|
-
|
|
60
|
-
2. Miii reads your codebase, plans the changes, edits the files
|
|
61
|
-
3. It asks your permission before touching anything (edit, delete, run commands)
|
|
62
|
-
4. It runs your test suite automatically after every change
|
|
63
|
-
5. If tests fail, it reads the error, fixes the code, re-runs
|
|
64
|
-
6. It repeats until the work is done — and checkpoints every file so you can abort safely
|
|
112
|
+
**Air-Gapped Ready** — regulated industries, defense, offline infrastructure. miii with Ollama works where cloud literally cannot.
|
|
65
113
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
## What a Session Looks Like
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
> refactor the auth module to use JWT instead of sessions
|
|
114
|
+
**Any Model** — Llama 3, Mistral, Qwen, Phi, or switch to Anthropic/OpenAI live. One tool, every model.
|
|
72
115
|
|
|
73
|
-
|
|
74
|
-
● Reading src/auth/session.ts
|
|
75
|
-
Read 42 lines
|
|
76
|
-
● Reading src/middleware/auth.ts
|
|
77
|
-
Read 28 lines
|
|
116
|
+
**File Checkpoints** — every file snapshotted before edit. Abort = full rollback. No bad edits stick.
|
|
78
117
|
|
|
79
|
-
|
|
80
|
-
◦ edit_file src/auth/session.ts
|
|
81
|
-
◦ edit_file src/middleware/auth.ts
|
|
118
|
+
**Permission Gates + Diff Preview** — approve every write, delete, or command. See the exact diff before you say yes.
|
|
82
119
|
|
|
83
|
-
|
|
84
|
-
> y
|
|
120
|
+
**MCP Client** — plug in any MCP-compatible tool server. Tools discovered automatically.
|
|
85
121
|
|
|
86
|
-
|
|
87
|
-
Wrote 12 lines
|
|
88
|
-
● edit_file src/middleware/auth.ts
|
|
89
|
-
Wrote 8 lines
|
|
90
|
-
● run_tests
|
|
91
|
-
✅ Tests passed
|
|
122
|
+
**npm Skills** — extend miii with plain Markdown files or npm packages. Ship reusable agent behaviors to your whole team.
|
|
92
123
|
|
|
93
|
-
|
|
94
|
-
```
|
|
124
|
+
**$0 / Month** — no subscription, no invoice, no API key required for local use.
|
|
95
125
|
|
|
96
126
|
---
|
|
97
127
|
|
|
98
|
-
##
|
|
99
|
-
|
|
100
|
-
**🔒 Privacy-First, Local by Default**
|
|
101
|
-
Run on Ollama and your code never leaves your machine. No account. No API key. No monthly bill. Switch to Anthropic or OpenAI when you need it — one command, live, mid-session.
|
|
102
|
-
|
|
103
|
-
**🔄 Live Provider Switching**
|
|
104
|
-
Type `/config` to open an interactive picker. Arrow-navigate between Ollama, Anthropic, and OpenAI-compatible endpoints. Change model, API key, base URL, or Tavily key without restarting. Config saves automatically.
|
|
128
|
+
## Features Worth Knowing
|
|
105
129
|
|
|
106
|
-
|
|
107
|
-
Miii asks before every edit, delete, or shell command — just like Claude Code. Every file is checkpointed before it's touched. Hit Esc to abort and all changes roll back automatically.
|
|
130
|
+
**Git Auto-Branch** — first approved edit auto-creates `miii/task-YYYY-MM-DD-HH-MM`. Your `main` is never touched until you decide.
|
|
108
131
|
|
|
109
|
-
|
|
110
|
-
Build a vector index of your entire codebase using local embeddings. Ask "where is the auth logic?" and Miii finds it by meaning, not keyword. No data leaves your machine.
|
|
132
|
+
**Parallel Read-Only Tools** — reading five files + git status + web search? All fire at once. Write ops stay sequential. Speed where safe, safety where it matters.
|
|
111
133
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
**🛠 Surgical File Editing**
|
|
119
|
-
`patch_file` replaces exact strings in your files. No full rewrites. No formatting destruction. Exactly the change, nothing more.
|
|
120
|
-
|
|
121
|
-
**🔁 Self-Healing Test Loop**
|
|
122
|
-
Runs `npm test` after every file change. If something breaks, reads the failure trace and fixes it autonomously — up to 3 retries before surfacing the issue.
|
|
134
|
+
**Two-Phase Plan → Execute**
|
|
135
|
+
```
|
|
136
|
+
/plan exec refactor the payment module
|
|
137
|
+
```
|
|
138
|
+
First turn: numbered plan, tools disabled — you read it, decide. Second turn: execution with plan as context. No surprises.
|
|
123
139
|
|
|
124
|
-
|
|
125
|
-
Pick up exactly where you left off. Named sessions mean your context, history, and goal survive terminal restarts.
|
|
140
|
+
**Native Tool Calls** — Anthropic uses `tool_use` blocks, OpenAI uses `tool_calls` arrays, exactly as the API intended. Faster, more reliable, less hallucination. Ollama uses compact XML fallback.
|
|
126
141
|
|
|
127
|
-
|
|
128
|
-
Extend Miii with plain Markdown files or npm packages. Ship reusable agent behaviors as versioned packages your whole team can pull.
|
|
142
|
+
**Live Streaming Toggle** — turn on in `/config` to watch tokens appear in real time. Turn off for clean batch output. Toggle mid-session, no restart.
|
|
129
143
|
|
|
130
|
-
|
|
131
|
-
Connect any MCP-compatible tool server. Miii discovers tools automatically and makes them available to the agent.
|
|
144
|
+
**Semantic Codebase Search** — local vector index, no embeddings sent anywhere. `/index build` once. Ask "where is the payment logic?" by meaning, not grep.
|
|
132
145
|
|
|
133
146
|
---
|
|
134
147
|
|
|
135
|
-
##
|
|
148
|
+
## Quick Start
|
|
136
149
|
|
|
137
150
|
```bash
|
|
138
|
-
#
|
|
151
|
+
# Local — free, offline (recommended)
|
|
139
152
|
ollama pull qwen2.5-coder:7b
|
|
153
|
+
npm install -g miii-cli
|
|
154
|
+
cd your-project && miii
|
|
140
155
|
|
|
141
|
-
#
|
|
156
|
+
# Anthropic Claude
|
|
142
157
|
npm install -g miii-cli
|
|
158
|
+
ANTHROPIC_API_KEY=sk-... miii
|
|
143
159
|
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
miii
|
|
160
|
+
# OpenAI or compatible endpoint
|
|
161
|
+
npm install -g miii-cli
|
|
162
|
+
miii # set key + base URL in /config
|
|
147
163
|
```
|
|
148
164
|
|
|
149
|
-
|
|
165
|
+
Hardware requirements are real — this runs on your machine, not a server farm.
|
|
166
|
+
|
|
167
|
+
| | Minimum | Recommended |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| RAM | 16 GB | 32 GB+ |
|
|
170
|
+
| GPU | integrated | dedicated |
|
|
171
|
+
| Storage | 10 GB | 20 GB+ |
|
|
150
172
|
|
|
151
173
|
---
|
|
152
174
|
|
|
153
|
-
##
|
|
175
|
+
## Commands
|
|
154
176
|
|
|
155
177
|
| Command | What it does |
|
|
156
178
|
|---|---|
|
|
157
|
-
| `/config` |
|
|
158
|
-
| `/
|
|
159
|
-
| `/
|
|
160
|
-
| `/index build` | Build semantic vector index
|
|
161
|
-
| `/index search <
|
|
162
|
-
| `/git review` | AI reviews
|
|
163
|
-
| `/git commit <msg>` | Stage everything
|
|
164
|
-
| `/
|
|
165
|
-
| `/
|
|
166
|
-
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## Semantic Codebase Indexing
|
|
173
|
-
|
|
174
|
-
For large codebases, Miii builds and queries a local vector index — no third-party APIs, no embeddings sent anywhere.
|
|
175
|
-
|
|
176
|
-
```bash
|
|
177
|
-
# Pull an embedding model (one time)
|
|
178
|
-
ollama pull nomic-embed-text
|
|
179
|
-
|
|
180
|
-
# Index your project
|
|
181
|
-
/index build
|
|
182
|
-
|
|
183
|
-
# The agent calls search_codebase automatically when it needs to find code by concept
|
|
184
|
-
```
|
|
179
|
+
| `/config` | Interactive picker — provider, model, API key, base URL, Tavily, streaming |
|
|
180
|
+
| `/plan exec <task>` | Two-phase: plan turn (no tools) → execute with plan as context |
|
|
181
|
+
| `/think <question>` | Deep research: reads files + web, synthesizes answer |
|
|
182
|
+
| `/index build` | Build local semantic vector index |
|
|
183
|
+
| `/index search <q>` | Find code by concept, not string match |
|
|
184
|
+
| `/git review` | AI reviews current diff — bugs, risks, style |
|
|
185
|
+
| `/git commit <msg>` | Stage everything, commit in one shot |
|
|
186
|
+
| `/model <name>` | Hot-swap LLM mid-conversation |
|
|
187
|
+
| `/session <name>` | Named sessions — resume exactly where you left off |
|
|
188
|
+
| `@filename` | Inject any file into context |
|
|
189
|
+
|
|
190
|
+
Commands open in a picker — select to insert into input, Enter to run.
|
|
185
191
|
|
|
186
192
|
---
|
|
187
193
|
|
|
188
194
|
## Configuration
|
|
189
195
|
|
|
190
|
-
**Interactive
|
|
196
|
+
**Interactive:** type `/config` inside miii.
|
|
191
197
|
|
|
192
|
-
**File-based:**
|
|
198
|
+
**File-based:** `.miii.json` in project root or `~/.config/miii/config.json` globally:
|
|
193
199
|
|
|
194
200
|
```json
|
|
195
201
|
{
|
|
196
|
-
"model": "qwen2.5-coder:7b",
|
|
197
202
|
"provider": "ollama",
|
|
198
203
|
"baseUrl": "http://localhost:11434",
|
|
199
204
|
"gitContext": true,
|
|
205
|
+
"streaming": false,
|
|
200
206
|
"embedModel": "nomic-embed-text"
|
|
201
207
|
}
|
|
202
208
|
```
|
|
203
209
|
|
|
204
|
-
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## MCP — Connect Any Tool Server
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"mcpServers": {
|
|
217
|
+
"postgres": {
|
|
218
|
+
"command": "npx",
|
|
219
|
+
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Drop into global config. Tools discovered automatically.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Semantic Index Setup
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
ollama pull nomic-embed-text # one time
|
|
233
|
+
/index build # inside your project
|
|
234
|
+
# agent calls search_codebase automatically from here
|
|
235
|
+
```
|
|
205
236
|
|
|
206
237
|
---
|
|
207
238
|
|
|
@@ -214,27 +245,28 @@ cd miii-cli && npm install && npm run build && npm link
|
|
|
214
245
|
|
|
215
246
|
---
|
|
216
247
|
|
|
217
|
-
## Who
|
|
248
|
+
## Who This Is For
|
|
218
249
|
|
|
219
|
-
|
|
220
|
-
- **Cost-sensitive teams** — API bills compound; Ollama is $0
|
|
221
|
-
- **Air-gapped environments** — regulated industries, defense, offline infra
|
|
222
|
-
- **Model experimenters** — want to try llama3, mistral, qwen, Claude side-by-side without switching tools
|
|
250
|
+
**Privacy-conscious developers** — proprietary code stays on your machine, always.
|
|
223
251
|
|
|
224
|
-
|
|
252
|
+
**Cost-sensitive teams** — API bills compound for every developer on the team, every month.
|
|
225
253
|
|
|
226
|
-
|
|
254
|
+
**Air-gapped environments** — regulated industries, defense, offline infrastructure where cloud is not an option.
|
|
227
255
|
|
|
228
|
-
|
|
256
|
+
**Model experimenters** — benchmark Llama 3 vs Qwen vs Claude vs GPT-4o in the same workflow.
|
|
229
257
|
|
|
230
|
-
|
|
258
|
+
**Anyone who's had an AI silently rewrite something they didn't want rewritten.**
|
|
231
259
|
|
|
232
|
-
|
|
260
|
+
---
|
|
233
261
|
|
|
234
|
-
|
|
262
|
+
The AI coding tools you're paying for will raise prices, change terms, and keep reading your code. Miii won't. MIT licensed, runs locally, gets better every time Ollama ships a new model.
|
|
235
263
|
|
|
236
|
-
|
|
264
|
+
**If this is the tool you've been waiting for — [⭐ star it](https://github.com/maruakshay/miii-cli) and tell someone.**
|
|
237
265
|
|
|
238
|
-
|
|
266
|
+
> Built by [@maruakshay](https://github.com/maruakshay) — PRs, issues, and model recommendations welcome.
|
|
267
|
+
>
|
|
268
|
+
> [miii.in](https://www.miii.in)
|
|
269
|
+
|
|
270
|
+
---
|
|
239
271
|
|
|
240
272
|
MIT — do whatever you want with it.
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,7 @@ const defaults = {
|
|
|
6
6
|
provider: 'ollama',
|
|
7
7
|
baseUrl: 'http://localhost:11434',
|
|
8
8
|
};
|
|
9
|
-
const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'tavilyApiKey', 'embedModel']);
|
|
9
|
+
const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'streaming', 'tavilyApiKey', 'embedModel']);
|
|
10
10
|
const PROJECT_CONFIG = join(process.cwd(), '.miii.json');
|
|
11
11
|
const GLOBAL_CONFIG = join(homedir(), '.config', 'miii', 'config.json');
|
|
12
12
|
export function saveConfig(config) {
|
package/dist/llm/stream.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Transient errors worth retrying: rate limits + server-side faults
|
|
2
1
|
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529]);
|
|
3
2
|
const MAX_RETRIES = 4;
|
|
4
3
|
const MAX_DELAY_MS = 30_000;
|
|
5
4
|
function retryDelay(attempt) {
|
|
6
|
-
// Exponential backoff: 1s → 2s → 4s → 8s, capped at 30s, ±20% jitter
|
|
7
5
|
const base = 1_000 * Math.pow(2, attempt);
|
|
8
6
|
const capped = Math.min(base, MAX_DELAY_MS);
|
|
9
7
|
return Math.round(capped * (0.8 + Math.random() * 0.4));
|
|
@@ -43,6 +41,37 @@ async function fetchWithRetry(url, init, signal, onRetry) {
|
|
|
43
41
|
}
|
|
44
42
|
throw new Error('fetchWithRetry: exhausted retries without returning');
|
|
45
43
|
}
|
|
44
|
+
// Convert Tool params string to JSON Schema for native tool_calls APIs
|
|
45
|
+
function paramsToSchema(paramsStr) {
|
|
46
|
+
try {
|
|
47
|
+
const obj = JSON.parse(paramsStr);
|
|
48
|
+
const properties = {};
|
|
49
|
+
const required = [];
|
|
50
|
+
for (const [key, typeStr] of Object.entries(obj)) {
|
|
51
|
+
const isOptional = typeStr.toLowerCase().includes('optional');
|
|
52
|
+
const isArray = typeStr.toLowerCase().includes('[]') || typeStr.toLowerCase().startsWith('array');
|
|
53
|
+
const base = typeStr.split(' ')[0].toLowerCase().replace('[]', '');
|
|
54
|
+
if (isArray) {
|
|
55
|
+
properties[key] = { type: 'array', items: { type: 'string' } };
|
|
56
|
+
}
|
|
57
|
+
else if (base === 'boolean') {
|
|
58
|
+
properties[key] = { type: 'boolean' };
|
|
59
|
+
}
|
|
60
|
+
else if (base === 'number') {
|
|
61
|
+
properties[key] = { type: 'number' };
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
properties[key] = { type: 'string' };
|
|
65
|
+
}
|
|
66
|
+
if (!isOptional)
|
|
67
|
+
required.push(key);
|
|
68
|
+
}
|
|
69
|
+
return { type: 'object', properties, required };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { type: 'object', properties: {}, required: [] };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
46
75
|
export async function warmup(provider, baseUrl, model) {
|
|
47
76
|
if (provider !== 'ollama')
|
|
48
77
|
return;
|
|
@@ -121,12 +150,21 @@ async function chatOllama(cfg) {
|
|
|
121
150
|
}
|
|
122
151
|
}
|
|
123
152
|
async function chatOpenAI(cfg) {
|
|
124
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
|
|
153
|
+
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry, tools, toolChoice } = cfg;
|
|
154
|
+
const body = { model, messages, stream: !!onChunk };
|
|
155
|
+
if (tools?.length) {
|
|
156
|
+
body.tools = tools.map(t => ({
|
|
157
|
+
type: 'function',
|
|
158
|
+
function: { name: t.name, description: t.description, parameters: paramsToSchema(t.params) },
|
|
159
|
+
}));
|
|
160
|
+
if (toolChoice === 'none')
|
|
161
|
+
body.tool_choice = 'none';
|
|
162
|
+
}
|
|
125
163
|
try {
|
|
126
164
|
const res = await fetchWithRetry(`${baseUrl}/v1/chat/completions`, {
|
|
127
165
|
method: 'POST',
|
|
128
166
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
|
|
129
|
-
body: JSON.stringify(
|
|
167
|
+
body: JSON.stringify(body),
|
|
130
168
|
}, signal, onRetry);
|
|
131
169
|
if (!res.ok) {
|
|
132
170
|
onError(new Error(`LLM ${res.status}: ${await res.text()}`));
|
|
@@ -135,13 +173,26 @@ async function chatOpenAI(cfg) {
|
|
|
135
173
|
if (!onChunk) {
|
|
136
174
|
const obj = await res.json();
|
|
137
175
|
onUsage?.(obj?.usage?.prompt_tokens ?? 0, obj?.usage?.completion_tokens ?? 0);
|
|
138
|
-
|
|
176
|
+
const message = obj?.choices?.[0]?.message;
|
|
177
|
+
let text = message?.content ?? '';
|
|
178
|
+
if (message?.tool_calls?.length) {
|
|
179
|
+
for (const tc of message.tool_calls) {
|
|
180
|
+
let args = {};
|
|
181
|
+
try {
|
|
182
|
+
args = JSON.parse(tc.function?.arguments ?? '{}');
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
text += `\n<tool_call>\n{"name": ${JSON.stringify(tc.function?.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
await onDone(text);
|
|
139
189
|
return;
|
|
140
190
|
}
|
|
141
191
|
const reader = res.body.getReader();
|
|
142
192
|
const decoder = new TextDecoder();
|
|
143
193
|
let full = '';
|
|
144
194
|
let buf = '';
|
|
195
|
+
const tcAccum = {};
|
|
145
196
|
while (true) {
|
|
146
197
|
const { done, value } = await reader.read();
|
|
147
198
|
if (done)
|
|
@@ -157,15 +208,41 @@ async function chatOpenAI(cfg) {
|
|
|
157
208
|
continue;
|
|
158
209
|
try {
|
|
159
210
|
const obj = JSON.parse(data);
|
|
160
|
-
const
|
|
211
|
+
const delta = obj?.choices?.[0]?.delta;
|
|
212
|
+
if (!delta)
|
|
213
|
+
continue;
|
|
214
|
+
const chunk = delta.content ?? '';
|
|
161
215
|
if (chunk) {
|
|
162
216
|
full += chunk;
|
|
163
217
|
onChunk(chunk);
|
|
164
218
|
}
|
|
219
|
+
if (delta.tool_calls) {
|
|
220
|
+
for (const tc of delta.tool_calls) {
|
|
221
|
+
const idx = tc.index ?? 0;
|
|
222
|
+
if (!tcAccum[idx])
|
|
223
|
+
tcAccum[idx] = { id: '', name: '', args: '' };
|
|
224
|
+
if (tc.id)
|
|
225
|
+
tcAccum[idx].id = tc.id;
|
|
226
|
+
if (tc.function?.name)
|
|
227
|
+
tcAccum[idx].name += tc.function.name;
|
|
228
|
+
if (tc.function?.arguments)
|
|
229
|
+
tcAccum[idx].args += tc.function.arguments;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
165
232
|
}
|
|
166
233
|
catch { }
|
|
167
234
|
}
|
|
168
235
|
}
|
|
236
|
+
// Serialize accumulated tool_calls to XML for run loop compatibility
|
|
237
|
+
for (const idx of Object.keys(tcAccum).map(Number).sort((a, b) => a - b)) {
|
|
238
|
+
const tc = tcAccum[idx];
|
|
239
|
+
let args = {};
|
|
240
|
+
try {
|
|
241
|
+
args = JSON.parse(tc.args);
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
full += `\n<tool_call>\n{"name": ${JSON.stringify(tc.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
|
|
245
|
+
}
|
|
169
246
|
await onDone(full);
|
|
170
247
|
}
|
|
171
248
|
catch (err) {
|
|
@@ -174,20 +251,30 @@ async function chatOpenAI(cfg) {
|
|
|
174
251
|
}
|
|
175
252
|
}
|
|
176
253
|
async function chatAnthropic(cfg) {
|
|
177
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onRetry } = cfg;
|
|
254
|
+
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry, tools, toolChoice } = cfg;
|
|
178
255
|
const url = baseUrl && baseUrl !== 'http://localhost:11434'
|
|
179
256
|
? `${baseUrl}/v1/messages`
|
|
180
257
|
: 'https://api.anthropic.com/v1/messages';
|
|
181
258
|
const systemParts = messages.filter(m => m.role === 'system').map(m => m.content);
|
|
182
259
|
const filtered = messages.filter(m => m.role !== 'system');
|
|
260
|
+
const body = {
|
|
261
|
+
model,
|
|
262
|
+
max_tokens: 8192,
|
|
263
|
+
stream: !!onChunk,
|
|
264
|
+
messages: filtered,
|
|
265
|
+
};
|
|
266
|
+
if (systemParts.length)
|
|
267
|
+
body.system = systemParts.join('\n\n');
|
|
268
|
+
if (tools?.length) {
|
|
269
|
+
body.tools = tools.map(t => ({
|
|
270
|
+
name: t.name,
|
|
271
|
+
description: t.description,
|
|
272
|
+
input_schema: paramsToSchema(t.params),
|
|
273
|
+
}));
|
|
274
|
+
if (toolChoice === 'none')
|
|
275
|
+
body.tool_choice = { type: 'none' };
|
|
276
|
+
}
|
|
183
277
|
try {
|
|
184
|
-
const body = {
|
|
185
|
-
model,
|
|
186
|
-
max_tokens: 8192,
|
|
187
|
-
messages: filtered,
|
|
188
|
-
};
|
|
189
|
-
if (systemParts.length)
|
|
190
|
-
body.system = systemParts.join('\n\n');
|
|
191
278
|
const res = await fetchWithRetry(url, {
|
|
192
279
|
method: 'POST',
|
|
193
280
|
headers: {
|
|
@@ -201,10 +288,86 @@ async function chatAnthropic(cfg) {
|
|
|
201
288
|
onError(new Error(`Anthropic ${res.status}: ${await res.text()}`));
|
|
202
289
|
return;
|
|
203
290
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
291
|
+
if (!onChunk) {
|
|
292
|
+
const obj = await res.json();
|
|
293
|
+
let fullText = '';
|
|
294
|
+
for (const block of obj?.content ?? []) {
|
|
295
|
+
if (block.type === 'text')
|
|
296
|
+
fullText += block.text ?? '';
|
|
297
|
+
else if (block.type === 'tool_use') {
|
|
298
|
+
const args = block.input ?? {};
|
|
299
|
+
fullText += `\n<tool_call>\n{"name": ${JSON.stringify(block.name ?? '')}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
onUsage?.(obj?.usage?.input_tokens ?? 0, obj?.usage?.output_tokens ?? 0);
|
|
303
|
+
await onDone(fullText);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const reader = res.body.getReader();
|
|
307
|
+
const decoder = new TextDecoder();
|
|
308
|
+
let buf = '';
|
|
309
|
+
let fullText = '';
|
|
310
|
+
let promptTokens = 0;
|
|
311
|
+
let completionTokens = 0;
|
|
312
|
+
// Track native tool_use content blocks
|
|
313
|
+
const toolBlocks = [];
|
|
314
|
+
let activeToolIdx = -1;
|
|
315
|
+
while (true) {
|
|
316
|
+
const { done, value } = await reader.read();
|
|
317
|
+
if (done)
|
|
318
|
+
break;
|
|
319
|
+
buf += decoder.decode(value, { stream: true });
|
|
320
|
+
const lines = buf.split('\n');
|
|
321
|
+
buf = lines.pop() ?? '';
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
if (!line.startsWith('data: '))
|
|
324
|
+
continue;
|
|
325
|
+
const data = line.slice(6).trim();
|
|
326
|
+
if (!data || data === '[DONE]')
|
|
327
|
+
continue;
|
|
328
|
+
try {
|
|
329
|
+
const evt = JSON.parse(data);
|
|
330
|
+
if (evt.type === 'message_start') {
|
|
331
|
+
promptTokens = (evt.message?.usage?.input_tokens) ?? 0;
|
|
332
|
+
}
|
|
333
|
+
else if (evt.type === 'content_block_start') {
|
|
334
|
+
const block = evt.content_block;
|
|
335
|
+
if (block.type === 'tool_use') {
|
|
336
|
+
activeToolIdx = toolBlocks.length;
|
|
337
|
+
toolBlocks.push({ id: block.id ?? '', name: block.name ?? '', inputJson: '' });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else if (evt.type === 'content_block_delta') {
|
|
341
|
+
const delta = evt.delta;
|
|
342
|
+
if (delta.type === 'text_delta' && delta.text) {
|
|
343
|
+
fullText += delta.text;
|
|
344
|
+
onChunk?.(delta.text);
|
|
345
|
+
}
|
|
346
|
+
else if (delta.type === 'input_json_delta' && activeToolIdx >= 0) {
|
|
347
|
+
toolBlocks[activeToolIdx].inputJson += delta.partial_json ?? '';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (evt.type === 'content_block_stop') {
|
|
351
|
+
activeToolIdx = -1;
|
|
352
|
+
}
|
|
353
|
+
else if (evt.type === 'message_delta') {
|
|
354
|
+
completionTokens = (evt.usage?.output_tokens) ?? 0;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch { }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Serialize native tool_use blocks to XML for run loop compatibility
|
|
361
|
+
for (const block of toolBlocks) {
|
|
362
|
+
let args = {};
|
|
363
|
+
try {
|
|
364
|
+
args = JSON.parse(block.inputJson);
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
fullText += `\n<tool_call>\n{"name": ${JSON.stringify(block.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
|
|
368
|
+
}
|
|
369
|
+
onUsage?.(promptTokens, completionTokens);
|
|
370
|
+
await onDone(fullText);
|
|
208
371
|
}
|
|
209
372
|
catch (err) {
|
|
210
373
|
if (err?.name !== 'AbortError')
|
package/dist/mcp/client.js
CHANGED
|
@@ -22,7 +22,9 @@ export class MCPClient {
|
|
|
22
22
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
23
23
|
env: { ...process.env, ...cfg.env },
|
|
24
24
|
});
|
|
25
|
-
this.proc.stderr?.on('data', () => {
|
|
25
|
+
this.proc.stderr?.on('data', (d) => {
|
|
26
|
+
d.toString().split('\n').filter(Boolean).forEach(line => process.stderr.write(`[MCP:${this.name}] ${line}\n`));
|
|
27
|
+
});
|
|
26
28
|
const rl = createInterface({ input: this.proc.stdout });
|
|
27
29
|
rl.on('line', (line) => {
|
|
28
30
|
if (!line.trim())
|
package/dist/memory/extractor.js
CHANGED
|
@@ -26,12 +26,43 @@ export function extractFacts(messages, config, model) {
|
|
|
26
26
|
],
|
|
27
27
|
onDone(text) {
|
|
28
28
|
try {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
29
|
+
const start = text.indexOf('[');
|
|
30
|
+
if (start === -1) {
|
|
31
31
|
resolve([]);
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
let depth = 0, inStr = false, esc = false, end = -1;
|
|
35
|
+
for (let i = start; i < text.length; i++) {
|
|
36
|
+
const ch = text[i];
|
|
37
|
+
if (esc) {
|
|
38
|
+
esc = false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '\\' && inStr) {
|
|
42
|
+
esc = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === '"') {
|
|
46
|
+
inStr = !inStr;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inStr)
|
|
50
|
+
continue;
|
|
51
|
+
if (ch === '[')
|
|
52
|
+
depth++;
|
|
53
|
+
else if (ch === ']') {
|
|
54
|
+
depth--;
|
|
55
|
+
if (depth === 0) {
|
|
56
|
+
end = i;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (end === -1) {
|
|
62
|
+
resolve([]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const arr = JSON.parse(text.slice(start, end + 1));
|
|
35
66
|
resolve(Array.isArray(arr) ? arr.filter((f) => typeof f === 'string') : []);
|
|
36
67
|
}
|
|
37
68
|
catch {
|
package/dist/tasks/compactor.js
CHANGED
|
@@ -30,7 +30,7 @@ What still needs to be done, if anything.
|
|
|
30
30
|
Any constraints, errors encountered, important facts the agent must remember to continue correctly.
|
|
31
31
|
|
|
32
32
|
Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
|
|
33
|
-
export async function compactContext(messages, cfg, goal) {
|
|
33
|
+
export async function compactContext(messages, cfg, goal, signal) {
|
|
34
34
|
if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
|
|
35
35
|
return messages;
|
|
36
36
|
const system = messages[0]?.role === 'system' ? messages[0] : null;
|
|
@@ -50,6 +50,7 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
50
50
|
let compactErr = '';
|
|
51
51
|
await chat({
|
|
52
52
|
...cfg,
|
|
53
|
+
signal,
|
|
53
54
|
messages: [
|
|
54
55
|
{ role: 'system', content: COMPACT_SYSTEM },
|
|
55
56
|
{ role: 'user', content: userPrompt },
|
|
@@ -59,6 +60,8 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
59
60
|
});
|
|
60
61
|
if (compactErr)
|
|
61
62
|
console.error(`[compactor] LLM error: ${compactErr}`);
|
|
63
|
+
if (signal?.aborted)
|
|
64
|
+
return messages;
|
|
62
65
|
// Fallback to dumb compaction if LLM fails
|
|
63
66
|
if (!summary)
|
|
64
67
|
return dumbCompact(messages, goal);
|
|
@@ -12,6 +12,7 @@ const MENU_ITEMS = [
|
|
|
12
12
|
{ key: 'key', label: 'API Key' },
|
|
13
13
|
{ key: 'url', label: 'Base URL' },
|
|
14
14
|
{ key: 'tavily', label: 'Tavily Key' },
|
|
15
|
+
{ key: 'streaming', label: 'Streaming' },
|
|
15
16
|
];
|
|
16
17
|
function truncate(s, n) {
|
|
17
18
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
@@ -76,7 +77,12 @@ export function ConfigPicker({ config, currentModel, tavilyKey, onUpdate, onTavi
|
|
|
76
77
|
return;
|
|
77
78
|
}
|
|
78
79
|
if (key.return) {
|
|
79
|
-
|
|
80
|
+
const item = MENU_ITEMS[menuIdx];
|
|
81
|
+
if (item.key === 'streaming') {
|
|
82
|
+
onUpdate({ streaming: !config.streaming });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
openScreen(item.key);
|
|
80
86
|
return;
|
|
81
87
|
}
|
|
82
88
|
return;
|
|
@@ -161,7 +167,11 @@ export function ConfigPicker({ config, currentModel, tavilyKey, onUpdate, onTavi
|
|
|
161
167
|
val = truncate(config.baseUrl, 36);
|
|
162
168
|
if (item.key === 'tavily')
|
|
163
169
|
val = tavilyDisplay;
|
|
164
|
-
|
|
170
|
+
const isStreaming = item.key === 'streaming';
|
|
171
|
+
const streamingOn = config.streaming === true;
|
|
172
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶ ' : ' ', item.label.padEnd(12)] }), isStreaming
|
|
173
|
+
? _jsx(Text, { color: streamingOn ? 'green' : 'gray', children: streamingOn ? 'on' : 'off' })
|
|
174
|
+
: _jsx(Text, { color: active ? 'white' : 'gray', children: val })] }, item.key));
|
|
165
175
|
}), screen === 'provider' && PROVIDERS.map((p, i) => {
|
|
166
176
|
const active = i === provIdx;
|
|
167
177
|
const current = p.key === config.provider;
|
|
@@ -21,6 +21,7 @@ const BUILTIN_COMMANDS = [
|
|
|
21
21
|
{ ns: 'builtin', name: 'list', description: 'list all loaded skills and their descriptions' },
|
|
22
22
|
// ── AI modes ─────────────────────────────────────────────────────────────
|
|
23
23
|
{ ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
|
|
24
|
+
{ ns: 'builtin', name: 'plan exec', description: 'two-phase: AI outputs plan first (no tools), say "go" to execute — /plan exec <task>' },
|
|
24
25
|
{ ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
|
|
25
26
|
{ ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
|
|
26
27
|
{ ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
|
|
@@ -176,8 +177,10 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
|
|
|
176
177
|
: skill.ns === 'git'
|
|
177
178
|
? `/git ${skill.name}`
|
|
178
179
|
: `/${skill.ns}:${skill.name}`;
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
setLines([name]);
|
|
181
|
+
setCursor({ row: 0, col: name.length });
|
|
182
|
+
setOverlay('none');
|
|
183
|
+
setOverlayIdx(0);
|
|
181
184
|
}
|
|
182
185
|
function selectFile(file) {
|
|
183
186
|
const r = cursor.row;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
2
|
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const runCmd = promisify(exec);
|
|
3
6
|
import { chat } from '../../llm/stream.js';
|
|
4
7
|
import { tools as staticTools } from '../../tools/index.js';
|
|
5
8
|
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
@@ -10,6 +13,7 @@ const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'del
|
|
|
10
13
|
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
11
14
|
const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
|
|
12
15
|
const CHECKPOINT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'delete_file']);
|
|
16
|
+
const PARALLEL_SAFE = new Set(['read_file', 'list_files', 'git_status', 'git_log', 'git_diff', 'web_search', 'web_extract']);
|
|
13
17
|
// Tool result messages that are ephemeral — never worth storing in memory or compact summaries
|
|
14
18
|
const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted|^\[file updated:/;
|
|
15
19
|
export function stripEphemeral(messages) {
|
|
@@ -23,6 +27,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
23
27
|
const [permissionRequest, setPermissionRequest] = useState(null);
|
|
24
28
|
const permissionResolveRef = useRef(null);
|
|
25
29
|
const checkpointRef = useRef(new Map());
|
|
30
|
+
const autoBranchedRef = useRef(null);
|
|
26
31
|
const sessionApprovedRef = useRef(new Set());
|
|
27
32
|
const thinkingStartRef = useRef(0);
|
|
28
33
|
const extraToolsRef = useRef(extraTools);
|
|
@@ -42,7 +47,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
42
47
|
const t = setInterval(() => setTick(n => n + 1), 80);
|
|
43
48
|
return () => clearInterval(t);
|
|
44
49
|
}, [status]);
|
|
45
|
-
const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
|
|
50
|
+
const runLoop = useCallback(async (contextMsgs, depth = 0, goal, options) => {
|
|
46
51
|
if (depth >= MAX_TOOL_DEPTH) {
|
|
47
52
|
abortRef.current = null;
|
|
48
53
|
setStatus('idle');
|
|
@@ -52,7 +57,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
52
57
|
if (depth === 0) {
|
|
53
58
|
thinkingStartRef.current = Date.now();
|
|
54
59
|
checkpointRef.current.clear();
|
|
60
|
+
autoBranchedRef.current = null;
|
|
55
61
|
}
|
|
62
|
+
abortRef.current = new AbortController();
|
|
56
63
|
let msgs = contextMsgs;
|
|
57
64
|
if (shouldCompact(contextMsgs)) {
|
|
58
65
|
printer.systemMsg('compacting context…');
|
|
@@ -62,17 +69,31 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
62
69
|
model: currentModelRef.current,
|
|
63
70
|
baseUrl: config.baseUrl,
|
|
64
71
|
apiKey: config.apiKey,
|
|
65
|
-
}, goal);
|
|
72
|
+
}, goal, abortRef.current.signal);
|
|
73
|
+
if (abortRef.current.signal.aborted) {
|
|
74
|
+
setStatus('idle');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
66
77
|
printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
|
|
67
78
|
replaceHistoryRef.current?.(msgs.filter(m => m.role !== 'system'));
|
|
68
79
|
}
|
|
69
|
-
|
|
80
|
+
let didStream = false;
|
|
70
81
|
await chat({
|
|
71
82
|
provider: config.provider,
|
|
72
83
|
model: currentModelRef.current,
|
|
73
84
|
baseUrl: config.baseUrl,
|
|
85
|
+
apiKey: config.apiKey,
|
|
74
86
|
messages: msgs,
|
|
87
|
+
tools: config.provider !== 'ollama' && !(options?.noTools && depth === 0) ? [...staticTools, ...extraToolsRef.current] : undefined,
|
|
88
|
+
toolChoice: (options?.noTools && depth === 0) ? 'none' : undefined,
|
|
75
89
|
signal: abortRef.current.signal,
|
|
90
|
+
onChunk: config.streaming ? (chunk) => {
|
|
91
|
+
if (!didStream) {
|
|
92
|
+
printer.streamStart();
|
|
93
|
+
didStream = true;
|
|
94
|
+
}
|
|
95
|
+
printer.streamChunk(chunk);
|
|
96
|
+
} : undefined,
|
|
76
97
|
onRetry(attempt, max, delayMs) {
|
|
77
98
|
printer.systemMsg(`retry ${attempt}/${max} — waiting ${Math.round(delayMs / 1000)}s`);
|
|
78
99
|
},
|
|
@@ -91,8 +112,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
91
112
|
if (bare)
|
|
92
113
|
pendingTools.push({ name: bare.name, args: bare.args });
|
|
93
114
|
}
|
|
115
|
+
if (didStream)
|
|
116
|
+
printer.streamEnd();
|
|
94
117
|
const displayText = textParts.join('').trim();
|
|
95
|
-
if (displayText)
|
|
118
|
+
if (displayText && !didStream)
|
|
96
119
|
printer.assistantMsg(displayText);
|
|
97
120
|
pushHistoryRef.current({ role: 'assistant', content: fullText });
|
|
98
121
|
if (pendingTools.length)
|
|
@@ -107,105 +130,154 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
107
130
|
await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
|
|
108
131
|
return;
|
|
109
132
|
}
|
|
133
|
+
if (autoBranchedRef.current)
|
|
134
|
+
printer.systemMsg(`branch: ${autoBranchedRef.current} (git checkout main when done)`);
|
|
110
135
|
printer.systemMsg(`done in ${printer.formatElapsed(Date.now() - thinkingStartRef.current)}`);
|
|
111
136
|
setStatus('idle');
|
|
112
137
|
return;
|
|
113
138
|
}
|
|
114
139
|
setStatus('tool');
|
|
115
140
|
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
const allParallelSafe = pendingTools.every(tc => PARALLEL_SAFE.has(tc.name));
|
|
142
|
+
if (allParallelSafe && pendingTools.length > 1) {
|
|
143
|
+
try {
|
|
144
|
+
setCurrentTool(pendingTools[0].name);
|
|
118
145
|
const allTools = [...staticTools, ...extraToolsRef.current];
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
const settled = await Promise.allSettled(pendingTools.map(async (tc) => {
|
|
147
|
+
const tool = allTools.find(t => t.name === tc.name);
|
|
148
|
+
printer.toolCallStart(tc.name, tc.args);
|
|
149
|
+
if (!tool)
|
|
150
|
+
throw new Error(`unknown tool: ${tc.name}`);
|
|
151
|
+
const result = await tool.execute(tc.args);
|
|
152
|
+
printer.toolResultSummary(tc.name, tc.args, result);
|
|
153
|
+
if (SHOW_RESULT_TOOLS.has(tc.name))
|
|
154
|
+
printer.toolMsg(tc.name, result);
|
|
155
|
+
return { tc, result };
|
|
156
|
+
}));
|
|
157
|
+
for (const r of settled) {
|
|
158
|
+
if (r.status === 'fulfilled') {
|
|
159
|
+
next.push({ role: 'user', content: `Tool ${r.value.tc.name} result:\n${r.value.result}` });
|
|
126
160
|
}
|
|
127
161
|
else {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
if (decision === 'session')
|
|
134
|
-
sessionApprovedRef.current.add(sessionKey);
|
|
135
|
-
if (decision === 'no') {
|
|
136
|
-
printer.systemMsg(`denied: ${tc.name}`);
|
|
137
|
-
const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
|
|
138
|
-
const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
|
|
139
|
-
next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
|
|
140
|
-
break;
|
|
162
|
+
const err = `Tool error: ${r.reason}`;
|
|
163
|
+
printer.errorMsg(err);
|
|
164
|
+
next.push({ role: 'user', content: err });
|
|
141
165
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
setCurrentTool(undefined);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
try {
|
|
174
|
+
for (const tc of pendingTools) {
|
|
175
|
+
const allTools = [...staticTools, ...extraToolsRef.current];
|
|
176
|
+
const tool = allTools.find(t => t.name === tc.name);
|
|
177
|
+
setCurrentTool(tc.name);
|
|
178
|
+
if (PERMISSION_TOOLS.has(tc.name)) {
|
|
179
|
+
const sessionKey = tc.name;
|
|
180
|
+
let decision;
|
|
181
|
+
if (sessionApprovedRef.current.has(sessionKey)) {
|
|
182
|
+
decision = 'yes';
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
decision = await new Promise(resolve => {
|
|
186
|
+
permissionResolveRef.current = resolve;
|
|
187
|
+
setPermissionRequest({ toolName: tc.name, args: tc.args });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (decision === 'session')
|
|
191
|
+
sessionApprovedRef.current.add(sessionKey);
|
|
192
|
+
if (decision === 'no') {
|
|
193
|
+
printer.systemMsg(`denied: ${tc.name}`);
|
|
194
|
+
const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
|
|
195
|
+
const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
|
|
196
|
+
next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
// Checkpoint: store pre-execution file state + auto-branch on first edit
|
|
200
|
+
if (CHECKPOINT_TOOLS.has(tc.name)) {
|
|
201
|
+
const path = tc.args.path;
|
|
202
|
+
if (path && !checkpointRef.current.has(path)) {
|
|
203
|
+
try {
|
|
204
|
+
checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
checkpointRef.current.set(path, null);
|
|
208
|
+
}
|
|
148
209
|
}
|
|
149
|
-
|
|
150
|
-
|
|
210
|
+
if (!autoBranchedRef.current) {
|
|
211
|
+
try {
|
|
212
|
+
const { stdout } = await runCmd('git rev-parse --abbrev-ref HEAD', { timeout: 3000 });
|
|
213
|
+
const branch = stdout.trim();
|
|
214
|
+
if (branch === 'main' || branch === 'master') {
|
|
215
|
+
const ts = new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-');
|
|
216
|
+
const newBranch = `miii/task-${ts}`;
|
|
217
|
+
await runCmd(`git checkout -b ${newBranch}`, { timeout: 5000 });
|
|
218
|
+
autoBranchedRef.current = newBranch;
|
|
219
|
+
printer.systemMsg(`auto-branched: ${newBranch}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
151
223
|
}
|
|
152
224
|
}
|
|
153
225
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
226
|
+
if (tool) {
|
|
227
|
+
try {
|
|
228
|
+
// Guard: for update_file, verify old text still matches before executing.
|
|
229
|
+
// If stale, inject fresh file content and skip — model will retry.
|
|
230
|
+
if (tc.name === 'update_file') {
|
|
231
|
+
const filePath = tc.args.path;
|
|
232
|
+
const oldText = tc.args.old;
|
|
233
|
+
if (filePath && oldText && existsSync(filePath)) {
|
|
234
|
+
const norm = (s) => s.replace(/\r\n/g, '\n');
|
|
235
|
+
const current = readFileSync(filePath, 'utf-8');
|
|
236
|
+
const occurrences = norm(current).split(norm(oldText)).length - 1;
|
|
237
|
+
if (occurrences === 0) {
|
|
238
|
+
printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
|
|
239
|
+
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
240
|
+
next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (occurrences > 1) {
|
|
244
|
+
printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
|
|
245
|
+
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
246
|
+
next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
171
249
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
250
|
+
}
|
|
251
|
+
printer.toolCallStart(tc.name, tc.args);
|
|
252
|
+
const result = await tool.execute(tc.args);
|
|
253
|
+
printer.toolResultSummary(tc.name, tc.args, result);
|
|
254
|
+
if (SHOW_RESULT_TOOLS.has(tc.name))
|
|
255
|
+
printer.toolMsg(tc.name, result);
|
|
256
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
257
|
+
if (FILE_EDIT_TOOLS.has(tc.name)) {
|
|
258
|
+
const filePath = tc.args.path;
|
|
259
|
+
if (filePath && existsSync(filePath)) {
|
|
260
|
+
const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
|
|
261
|
+
next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
|
|
177
262
|
}
|
|
178
263
|
}
|
|
179
264
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
printer.toolMsg(tc.name, result);
|
|
185
|
-
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
186
|
-
if (FILE_EDIT_TOOLS.has(tc.name)) {
|
|
187
|
-
const filePath = tc.args.path;
|
|
188
|
-
if (filePath && existsSync(filePath)) {
|
|
189
|
-
const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
|
|
190
|
-
next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
|
|
191
|
-
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
const err = `Tool ${tc.name} error: ${e}`;
|
|
267
|
+
printer.errorMsg(err);
|
|
268
|
+
next.push({ role: 'user', content: err });
|
|
192
269
|
}
|
|
193
270
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
next.push({ role: 'user', content: err });
|
|
271
|
+
else {
|
|
272
|
+
printer.errorMsg(`unknown tool: ${tc.name}`);
|
|
273
|
+
next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
|
|
198
274
|
}
|
|
199
275
|
}
|
|
200
|
-
else {
|
|
201
|
-
printer.errorMsg(`unknown tool: ${tc.name}`);
|
|
202
|
-
next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
|
|
203
|
-
}
|
|
204
276
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
277
|
+
finally {
|
|
278
|
+
setCurrentTool(undefined);
|
|
279
|
+
}
|
|
280
|
+
} // end sequential else
|
|
209
281
|
// For file-edit turns: slim context (system + goal + fresh file states + recent results)
|
|
210
282
|
// For non-edit turns: full next (model needs full conversational context)
|
|
211
283
|
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
@@ -261,6 +333,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
261
333
|
if (restored > 0)
|
|
262
334
|
printer.systemMsg(`restored ${restored} file(s) to pre-session state`);
|
|
263
335
|
}
|
|
336
|
+
if (autoBranchedRef.current) {
|
|
337
|
+
printer.systemMsg(`task branch preserved: ${autoBranchedRef.current}`);
|
|
338
|
+
autoBranchedRef.current = null;
|
|
339
|
+
}
|
|
264
340
|
setStatus('idle');
|
|
265
341
|
}, []);
|
|
266
342
|
return {
|
|
@@ -326,6 +326,18 @@ Analyze what exists, then implement the design. Use the design system above if a
|
|
|
326
326
|
}
|
|
327
327
|
return;
|
|
328
328
|
}
|
|
329
|
+
if (cmd.startsWith('/plan exec ')) {
|
|
330
|
+
const task = cmd.slice(11).trim();
|
|
331
|
+
if (!task) {
|
|
332
|
+
printer.systemMsg('usage: /plan exec <task>');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const planPrompt = `PLANNING TURN — output a numbered plan of exactly what you will do to accomplish this task. List which files to read, which to edit, and what changes to make. Do NOT call any tools in this response. After I review the plan and respond "go", you will execute.\n\nTask: ${task}`;
|
|
336
|
+
printer.userMsg(`/plan exec ${task}`);
|
|
337
|
+
pushHistory({ role: 'user', content: planPrompt });
|
|
338
|
+
await runLoop(buildContext(), 0, task, { noTools: true });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
329
341
|
if (cmd === '/plan' || cmd.startsWith('/plan ')) {
|
|
330
342
|
const topic = cmd.slice(5).trim();
|
|
331
343
|
setPlanningMode(true);
|
package/dist/tui/printer.js
CHANGED
|
@@ -161,8 +161,11 @@ export function assistantMsg(text) {
|
|
|
161
161
|
const tail = lines.slice(idx + 1).join('\n');
|
|
162
162
|
write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
|
|
163
163
|
}
|
|
164
|
-
export
|
|
165
|
-
export
|
|
164
|
+
export function streamStart() { write(`\n${blue('●')} `); }
|
|
165
|
+
export function streamChunk(s) { write(s); }
|
|
166
|
+
export function streamEnd() { write('\n'); }
|
|
167
|
+
export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file']);
|
|
168
|
+
export const DELETE_TOOLS = new Set(['delete_file']);
|
|
166
169
|
const PERM_DESC = {
|
|
167
170
|
delete_file: 'delete this file',
|
|
168
171
|
update_file: 'edit this file',
|
|
@@ -291,8 +294,7 @@ export function toolResultSummary(name, args, result) {
|
|
|
291
294
|
const lines = result.trim().split('\n').filter(Boolean);
|
|
292
295
|
let summary = '';
|
|
293
296
|
switch (name) {
|
|
294
|
-
case 'edit_file':
|
|
295
|
-
case 'write_file': {
|
|
297
|
+
case 'edit_file': {
|
|
296
298
|
const n = (a.content ?? '').split('\n').length;
|
|
297
299
|
summary = `Wrote ${n} line${n === 1 ? '' : 's'}`;
|
|
298
300
|
break;
|