miii-cli 1.2.4 → 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.
@@ -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({ model, messages, stream: !!onChunk }),
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
- await onDone(obj?.choices?.[0]?.message?.content ?? '');
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 chunk = obj?.choices?.[0]?.delta?.content ?? '';
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
- const obj = await res.json();
205
- const text = (obj.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('');
206
- onUsage?.(obj.usage?.input_tokens ?? 0, obj.usage?.output_tokens ?? 0);
207
- await onDone(text);
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')
@@ -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())
@@ -73,6 +75,11 @@ export class MCPClient {
73
75
  resolve: (v) => { clearTimeout(timer); resolve(v); },
74
76
  reject: (e) => { clearTimeout(timer); reject(e); },
75
77
  });
78
+ if (!this.proc?.stdin?.writable) {
79
+ this.pending.delete(id);
80
+ reject(new Error('MCP process stdin not writable'));
81
+ return;
82
+ }
76
83
  this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
77
84
  timer = setTimeout(() => {
78
85
  if (this.pending.has(id)) {
@@ -26,12 +26,43 @@ export function extractFacts(messages, config, model) {
26
26
  ],
27
27
  onDone(text) {
28
28
  try {
29
- const m = text.match(/\[[\s\S]*?\]/);
30
- if (!m) {
29
+ const start = text.indexOf('[');
30
+ if (start === -1) {
31
31
  resolve([]);
32
32
  return;
33
33
  }
34
- const arr = JSON.parse(m[0]);
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 {
@@ -167,8 +167,10 @@ export class SkillLoader {
167
167
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
168
168
  ? nameOrPkg
169
169
  : `miii-skill-${nameOrPkg}`;
170
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
171
+ throw new Error(`invalid package name: ${pkg}`);
170
172
  createDir(NPM_SKILLS_DIR);
171
- const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
173
+ const { stdout, stderr } = await run(`npm install --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
172
174
  const out = (stdout + stderr).trim();
173
175
  // Reload newly installed skill
174
176
  await this.loadAll();
@@ -178,7 +180,9 @@ export class SkillLoader {
178
180
  const pkg = nameOrPkg.includes('/') || nameOrPkg.startsWith('miii-skill-')
179
181
  ? nameOrPkg
180
182
  : `miii-skill-${nameOrPkg}`;
181
- const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${pkg}`);
183
+ if (!/^[a-zA-Z0-9@/._-]+$/.test(pkg))
184
+ throw new Error(`invalid package name: ${pkg}`);
185
+ const { stdout, stderr } = await run(`npm uninstall --prefix ${JSON.stringify(NPM_SKILLS_DIR)} ${JSON.stringify(pkg)}`);
182
186
  const out = (stdout + stderr).trim();
183
187
  // Remove from map
184
188
  const shortName = pkg.replace(/^miii-skill-/, '');
@@ -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);
@@ -20,7 +20,10 @@ export const tools = [
20
20
  params: '{"path": "string"}',
21
21
  execute: async ({ path }) => {
22
22
  try {
23
- return readFile(guardPath(requireArg(path, 'path', 'read_file')));
23
+ const safe = guardPath(requireArg(path, 'path', 'read_file'));
24
+ if (!existsSync(safe))
25
+ throw new Error(`file not found: ${path}`);
26
+ return readFile(safe);
24
27
  }
25
28
  catch (e) {
26
29
  throw new Error(`read_file: ${e}`);
@@ -73,15 +76,18 @@ export const tools = [
73
76
  params: '{"path": "string", "old": "string", "new": "string"}',
74
77
  execute: async ({ path, old: oldStr, new: newStr }) => {
75
78
  const safe = guardPath(requireArg(path, 'path', 'update_file'));
76
- const current = readFile(safe);
77
- if (current === null)
79
+ if (!existsSync(safe))
78
80
  throw new Error(`file not found: ${path}`);
81
+ const current = readFile(safe);
79
82
  if (current === '')
80
83
  throw new Error(`file empty: ${path}`);
81
84
  const old = requireArg(oldStr, 'old', 'update_file');
82
85
  if (newStr === undefined || newStr === null)
83
86
  throw new Error('update_file: "new" argument is required');
84
- const count = current.split(old).length - 1;
87
+ const norm = (s) => s.replace(/\r\n/g, '\n');
88
+ const currentNorm = norm(current);
89
+ const oldNorm = norm(old);
90
+ const count = currentNorm.split(oldNorm).length - 1;
85
91
  if (count === 0) {
86
92
  throw new Error(`old text not found in ${path} — file may have changed since last read.\n` +
87
93
  `Call read_file again to get current content, then retry with exact matching text.`);
@@ -89,11 +95,11 @@ export const tools = [
89
95
  if (count > 1) {
90
96
  throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
91
97
  }
92
- const updated = current.replace(old, String(newStr));
98
+ const updated = currentNorm.replace(oldNorm, norm(String(newStr)));
93
99
  writeFile(safe, updated);
94
100
  // Compute affected line range for the snippet
95
- const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
96
- const oldLines = old.split('\n').length;
101
+ const startLine = currentNorm.slice(0, currentNorm.indexOf(oldNorm)).split('\n').length;
102
+ const oldLines = oldNorm.split('\n').length;
97
103
  const newLines = newStr.split('\n').length;
98
104
  const updatedArr = updated.split('\n');
99
105
  const snippetStart = Math.max(0, startLine - 3);
@@ -287,14 +293,16 @@ export function getSystemPrompt(extra = '', extraTools = []) {
287
293
  const toolDocs = allTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
288
294
  const deepThinkDoc = `- deep_think({"query": "string", "needs_web": "boolean (optional)"}): Research tool — gathers information from files, git, and optionally the web before answering. Returns a compiled research summary. Guardrails: read-only tools only, max 6 tool calls, max 4 web calls inside. Use when a question requires reading multiple files or searching the web first.
289
295
  - search_codebase({"query": "string", "k": "number (optional)"}): Semantic vector search over the indexed codebase. Returns top-k relevant code snippets by meaning. Requires the user to have run /index build. Use this when you need to find code by concept rather than exact string — e.g. "authentication logic", "error handling patterns", "database queries".`;
290
- return `You are Miii — AI coding assistant.
296
+ return `You are Miii — a precise, disciplined AI coding assistant. You implement exactly what is asked. Nothing more.
297
+
298
+ ## Tool format
291
299
 
292
- Tools via:
293
300
  <tool_call>
294
301
  {"name": "tool_name", "args": {...}}
295
302
  </tool_call>
296
303
 
297
- File content in named blocks (not inside JSON):
304
+ File content goes in named blocks outside the JSON — never inside it:
305
+
298
306
  <tool_call>
299
307
  {"name": "edit_file", "args": {"path": "src/foo.ts"}}
300
308
  <content>
@@ -305,23 +313,68 @@ full file content here
305
313
  <tool_call>
306
314
  {"name": "update_file", "args": {"path": "src/foo.ts"}}
307
315
  <old>
308
- exact text to replace
316
+ exact text to replace (copy verbatim from read_file output)
309
317
  </old>
310
318
  <new>
311
319
  replacement text
312
320
  </new>
313
321
  </tool_call>
314
322
 
315
- Tools:
323
+ ## Tools
316
324
  ${toolDocs}
317
325
  ${deepThinkDoc}
318
326
 
319
- Rules:
320
- - edit_file: new files only (errors if exists). For existing files: read_file then update_file with exact <old> text
321
- - Never guess old text — always re-read immediately before patching. If "old text not found": read_file again and retry
322
- - Plain text responses only. No markdown (#/*/\`), no code blocks write code with tools, not in responses
323
- - git_status/git_diff before refactors. git_status before git_commit
324
- - run_tests after edits. Fix failures, retry up to 3 times
325
- - web_search requires "query" key exactly. Never say you can't search — always call web_search
326
- - deep_think: read-only research only, cannot edit files${extra}`;
327
+ ## Execution protocol
328
+
329
+ For every task, follow this sequence:
330
+ 1. Read relevant files first — never assume file contents. When reading multiple independent files, emit all read_file calls in a single batch — do not wait for one before requesting the next.
331
+ 2. Make the minimal targeted change that satisfies the request
332
+ 3. Run run_tests after any edit. If tests fail, fix and retry up to 3 times before reporting
333
+ 4. For refactors or commits: git_status git_diff first, always
334
+
335
+ Parallel tool calls: when multiple tool calls have no dependency between them, issue them together in one batch. Sequential only when a later call depends on an earlier result.
336
+
337
+ For exploratory questions ("how should we approach X?", "what could we do about Y?"):
338
+ - Respond in 2-3 sentences: recommendation + main tradeoff
339
+ - Do not implement until the user agrees
340
+
341
+ For UI or frontend changes: verify the change works in a browser before reporting done. If browser testing is not possible, say so explicitly rather than claiming success.
342
+
343
+ ## Code discipline
344
+
345
+ - Implement exactly what is asked. A bug fix is not a refactor opportunity. A one-shot task does not need a helper abstraction.
346
+ - Three similar lines of code is better than a premature abstraction.
347
+ - Write no comments by default. Add one only when the WHY is non-obvious: a hidden constraint, a subtle invariant, a specific bug workaround. Never explain what the code does — names do that.
348
+ - Add no error handling for scenarios that cannot occur. Trust framework and internal code guarantees. Validate only at system boundaries: user input, external APIs, file I/O.
349
+ - Add no backwards-compatibility shims, feature flags, or dead code for hypothetical future requirements.
350
+
351
+ ## File editing rules
352
+
353
+ - edit_file: new files only — throws if file exists. For existing files: read_file → update_file.
354
+ - update_file: copy the <old> text verbatim from read_file output. Never guess or paraphrase it.
355
+ - If "old text not found": read_file again immediately and retry with exact current text.
356
+ - Prefer update_file (surgical patch) over edit_file (full rewrite) for existing files.
357
+ - Read a file immediately before patching it — not from earlier in the conversation.
358
+
359
+ ## Safety and reversibility
360
+
361
+ - Before any destructive action (delete_file, overwriting content, git_commit with -A), verify the blast radius.
362
+ - Never introduce security vulnerabilities: no command injection, no path traversal, no hardcoded secrets, no XSS, no SQL injection. If you wrote insecure code, fix it immediately.
363
+ - run_command executes in a shell — validate any user-supplied values before interpolating into commands.
364
+
365
+ ## Git discipline
366
+
367
+ - git_status before every commit. Never commit if working tree is unexpected.
368
+ - Stage specific files. Use -A only when all changes are intentional and reviewed.
369
+ - Never amend a commit unless explicitly asked.
370
+ - Never force-push unless explicitly asked and confirmed.
371
+ - Never skip hooks (--no-verify) unless explicitly asked. If a hook fails, diagnose and fix the root cause.
372
+ - Never use interactive git flags (-i) — they require terminal input that is not available.
373
+
374
+ ## Communication
375
+
376
+ - Plain text only. No markdown (no #, *, \`, ---). No code blocks in responses — write code with tools.
377
+ - No filler: no "sure", "certainly", "happy to", "great question". State results and next steps directly.
378
+ - web_search requires "query" key exactly. Never say you can't search — always call web_search.
379
+ - deep_think: read-only research only. Cannot edit files.${extra}`;
327
380
  }
@@ -101,7 +101,8 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
101
101
  const abortRef = useRef(null);
102
102
  const [designTeachState, setDesignTeachState] = useState(null);
103
103
  const [designReadyPrompt, setDesignReadyPrompt] = useState(null);
104
- const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
104
+ const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
105
+ const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, setHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools, currentModelRef);
105
106
  const startDesignTeach = useCallback(() => {
106
107
  setDesignTeachState({ answers: [], idx: 0 });
107
108
  }, []);
@@ -119,7 +120,6 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
119
120
  return { answers, idx: nextIdx };
120
121
  });
121
122
  }, []);
122
- const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
123
123
  const deepThinkTool = useMemo(() => ({
124
124
  name: 'deep_think',
125
125
  description: 'Research tool: gather info from files and web before answering.',
@@ -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
- openScreen(MENU_ITEMS[menuIdx].key);
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
- return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶ ' : ' ', item.label.padEnd(12)] }), _jsx(Text, { color: active ? 'white' : 'gray', children: val })] }, item.key));
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
- clearInput();
180
- onSubmit(name);
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;
@@ -428,10 +431,14 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
428
431
  appendChar(input);
429
432
  if (prospective.startsWith('/')) {
430
433
  if (prospective.slice(1).includes(' ')) {
431
- if (input === '@' || overlay === 'at') {
434
+ if (input === '@') {
435
+ filesLoadedRef.current = false;
432
436
  setOverlay('at');
433
437
  setOverlayIdx(0);
434
438
  }
439
+ else if (overlay === 'at') {
440
+ setOverlay('at');
441
+ }
435
442
  else {
436
443
  setOverlay('none');
437
444
  }
@@ -441,10 +448,14 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
441
448
  setOverlayIdx(0);
442
449
  }
443
450
  }
444
- else if (input === '@' || (overlay === 'at' && atQuery !== '')) {
451
+ else if (input === '@') {
452
+ filesLoadedRef.current = false;
445
453
  setOverlay('at');
446
454
  setOverlayIdx(0);
447
455
  }
456
+ else if (overlay === 'at' && atQuery !== '') {
457
+ setOverlay('at');
458
+ }
448
459
  else if (overlay === 'command') {
449
460
  setOverlay('none');
450
461
  }
@@ -42,7 +42,6 @@ Guardrails:
42
42
  apiKey: config.apiKey,
43
43
  messages: msgs,
44
44
  signal,
45
- onChunk() { },
46
45
  async onDone(text) { fullText = text; },
47
46
  onError(err) { if (err.name !== 'AbortError')
48
47
  chatError = err; },