neuroncli 2.1.0__py3-none-any.whl

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.
neuroncli/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """NeuronCLI — AI Coding Agent powered by Kimi K2.5 + Ollama."""
2
+
3
+ from .config import AgentConfig, APP_NAME, VERSION
4
+ from .agent import Agent, AgentResult
5
+ from .provider import ChatMessage, create_provider
6
+ from .auth import ensure_api_key
7
+ from .tools import registry as tool_registry
8
+
9
+ __version__ = VERSION
10
+ __all__ = [
11
+ "Agent",
12
+ "AgentConfig",
13
+ "AgentResult",
14
+ "ChatMessage",
15
+ "create_provider",
16
+ "ensure_api_key",
17
+ "tool_registry",
18
+ ]
neuroncli/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Allow running as: python -m neuroncli"""
2
+
3
+ import sys
4
+ import os
5
+
6
+ # Fix Windows terminal encoding — MUST be before any print()
7
+ if sys.platform == "win32":
8
+ os.system("") # Enable ANSI escape codes on Windows
9
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
10
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
11
+
12
+ from neuroncli.cli import main
13
+
14
+ raise SystemExit(main())
neuroncli/agent.py ADDED
@@ -0,0 +1,544 @@
1
+ """NeuronCLI — ReAct agent engine v2.1. Parallel tools, git integration, token tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from dataclasses import dataclass, field
11
+
12
+ from .config import AgentConfig, MODE_YOLO, MODE_PLAN
13
+ from .provider import ChatMessage, create_provider, ProviderConnectionError
14
+ from .prompts import build_system_prompt
15
+ from .tools import registry as tool_registry
16
+ from .git_integration import git_auto_commit, is_git_repo
17
+ from .ui import (
18
+ _ORANGE, _GREEN, _RED, _GRAY, _BLUE, _YELLOW,
19
+ RST, BOLD, DIM,
20
+ SYM_BULLET, SYM_THINK, SYM_OK, SYM_ERR, SYM_ARROW,
21
+ )
22
+
23
+
24
+ # ── ANSI Colors ───────────────────────────────────────────────────
25
+
26
+ class C:
27
+ RESET = "\033[0m"
28
+ BOLD = "\033[1m"
29
+ DIM = "\033[2m"
30
+ ITALIC = "\033[3m"
31
+ CYAN = "\033[96m"
32
+ GREEN = "\033[92m"
33
+ YELLOW = "\033[93m"
34
+ RED = "\033[91m"
35
+ MAGENTA = "\033[95m"
36
+ BLUE = "\033[94m"
37
+ GRAY = "\033[90m"
38
+ WHITE = "\033[97m"
39
+ DIFF_ADD = "\033[92m"
40
+ DIFF_DEL = "\033[91m"
41
+ DIFF_HDR = "\033[96m"
42
+
43
+
44
+ # ── Tool Call Parsing ─────────────────────────────────────────────
45
+
46
+ TOOL_CALL_PATTERN = re.compile(
47
+ r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
48
+ CODE_BLOCK_TOOL_PATTERN = re.compile(
49
+ r"```(?:json)?\s*(\{[^`]*?\"tool\"\s*:.*?\})\s*```", re.DOTALL)
50
+ BARE_JSON_TOOL_PATTERN = re.compile(
51
+ r'(\{\s*"tool"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{.*?\}\s*\})', re.DOTALL)
52
+ FINAL_ANSWER_PATTERN = re.compile(
53
+ r"<final_answer>(.*?)</final_answer>", re.DOTALL)
54
+
55
+
56
+ @dataclass
57
+ class ParsedToolCall:
58
+ tool: str
59
+ args: dict
60
+
61
+
62
+ def _try_parse_json(raw: str) -> dict | None:
63
+ raw = raw.strip().replace("'", '"')
64
+ raw = re.sub(r',\s*}', '}', raw)
65
+ raw = re.sub(r',\s*]', ']', raw)
66
+ try:
67
+ data = json.loads(raw)
68
+ if isinstance(data, dict) and "tool" in data:
69
+ return data
70
+ except json.JSONDecodeError:
71
+ pass
72
+ return None
73
+
74
+
75
+ def parse_all_tool_calls(text: str) -> list[ParsedToolCall]:
76
+ """Parse ALL tool calls — enables parallel execution."""
77
+ calls = []
78
+ seen = set()
79
+ for pattern in [TOOL_CALL_PATTERN, CODE_BLOCK_TOOL_PATTERN, BARE_JSON_TOOL_PATTERN]:
80
+ for match in pattern.finditer(text):
81
+ data = _try_parse_json(match.group(1))
82
+ if data:
83
+ key = (data["tool"], json.dumps(data.get("args", {}), sort_keys=True))
84
+ if key not in seen:
85
+ seen.add(key)
86
+ calls.append(ParsedToolCall(tool=data["tool"], args=data.get("args", {})))
87
+ return calls
88
+
89
+
90
+ def parse_final_answer(text: str) -> str | None:
91
+ """Extract final answer. Uses LAST match to skip cases where
92
+ the LLM mentions <final_answer> in its reasoning text."""
93
+ matches = list(FINAL_ANSWER_PATTERN.finditer(text))
94
+ if matches:
95
+ return matches[-1].group(1).strip()
96
+ return None
97
+
98
+
99
+ def _strip_ansi(text: str) -> str:
100
+ return re.sub(r'\033\[[0-9;]*m', '', text)
101
+
102
+
103
+ def _render_table(table_text: str) -> str:
104
+ """Convert markdown table to clean aligned terminal text with brand colors.
105
+ - Headers: Neuron orange + bold
106
+ - Separator: thin orange dashes (capped width)
107
+ - Data rows: clean default color
108
+ - Max column width: 40 chars (truncate with ...)
109
+ """
110
+ MAX_COL_WIDTH = 40
111
+
112
+ lines = table_text.strip().split('\n')
113
+ rows = []
114
+ for line in lines:
115
+ line = line.strip()
116
+ if not line or not line.startswith('|'):
117
+ continue
118
+ # Skip separator rows (|---|---|)
119
+ if re.match(r'^\|[\s:|\-]+\|$', line):
120
+ continue
121
+ cells = [c.strip() for c in line.strip('|').split('|')]
122
+ rows.append(cells)
123
+ if not rows:
124
+ return table_text
125
+
126
+ # Column count and capped widths
127
+ cols = max(len(r) for r in rows)
128
+ widths = [0] * cols
129
+ for row in rows:
130
+ for i, cell in enumerate(row):
131
+ if i < cols:
132
+ widths[i] = max(widths[i], min(len(cell), MAX_COL_WIDTH))
133
+
134
+ # Truncate helper
135
+ def fit(cell: str, w: int) -> str:
136
+ if len(cell) > w:
137
+ return cell[:w - 3] + "..."
138
+ return f"{cell:<{w}}"
139
+
140
+ # Render with brand colors
141
+ output = []
142
+ for idx, row in enumerate(rows):
143
+ parts = []
144
+ for i in range(cols):
145
+ cell = row[i] if i < len(row) else ''
146
+ w = widths[i]
147
+ if idx == 0: # Header row — brand orange + bold
148
+ parts.append(f"{_ORANGE}{BOLD}{fit(cell, w)}{RST}")
149
+ else: # Data rows
150
+ parts.append(fit(cell, w))
151
+ output.append(' ' + ' '.join(parts))
152
+ if idx == 0:
153
+ # Separator — thin orange line, per-column width
154
+ sep_parts = [f"{_GRAY}{'-' * w}{RST}" for w in widths]
155
+ output.append(' ' + ' '.join(sep_parts))
156
+ return '\n'.join(output)
157
+
158
+
159
+ def _clean_for_display(text: str) -> str:
160
+ """Strip XML tags, LLM reasoning, render markdown as ANSI.
161
+ Uses brand colors: orange headers, blue code, bold text."""
162
+ # Strip XML tags
163
+ text = re.sub(r'</?final_answer>', '', text)
164
+ text = re.sub(r'</?tool_call>', '', text)
165
+ text = re.sub(r'<[a-z_/][^>]*>', '', text)
166
+ text = re.sub(r'(?i)\b(tags? as instructed|as instructed)\.?\s*', '', text)
167
+
168
+ # Strip LLM reasoning preamble (lines starting with thinking patterns)
169
+ reasoning_patterns = [
170
+ r'^.*(?:So I should|I will|I need to|Let me|The user|I\'ll).*$',
171
+ r'^.*(?:block and no tool calls|respond directly).*$',
172
+ r'^.*(?:directly\."|just greet).*$',
173
+ ]
174
+ for pat in reasoning_patterns:
175
+ text = re.sub(pat, '', text, flags=re.MULTILINE)
176
+
177
+ # Render markdown tables with brand colors
178
+ table_pattern = re.compile(r'((?:^\|.+\|$\n?){2,})', re.MULTILINE)
179
+ text = table_pattern.sub(lambda m: _render_table(m.group(1)), text)
180
+
181
+ # Headers — brand orange
182
+ text = re.sub(r'^#{1,3}\s+(.+)$',
183
+ lambda m: f"{_ORANGE}{BOLD}{m.group(1)}{RST}",
184
+ text, flags=re.MULTILINE)
185
+ # Bold — white bold
186
+ text = re.sub(r'\*\*(.+?)\*\*', f'{BOLD}\\1{RST}', text)
187
+ # Italic — dim
188
+ text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', f'{DIM}\\1{RST}', text)
189
+ # Inline code — brand cyan
190
+ text = re.sub(r'`([^`]+)`',
191
+ lambda m: f"{_BLUE}{m.group(1)}{RST}",
192
+ text)
193
+ # Bullets — brand orange dot
194
+ text = re.sub(r'^(\s*)- ',
195
+ lambda m: f"{m.group(1)} {_ORANGE}.{RST} ",
196
+ text, flags=re.MULTILINE)
197
+ # Numbered lists — orange number
198
+ text = re.sub(r'^(\s*)(\d+)\.\s',
199
+ lambda m: f"{m.group(1)} {_ORANGE}{m.group(2)}.{RST} ",
200
+ text, flags=re.MULTILINE)
201
+ # Clean extra blank lines
202
+ text = re.sub(r'\n{3,}', '\n\n', text)
203
+ return text.strip()
204
+
205
+
206
+ # ── Tool Summaries ────────────────────────────────────────────────
207
+
208
+ def _tool_summary(name: str, args: dict, result: str) -> str:
209
+ if name == "read_file":
210
+ return f" Read {args.get('path', '?')} ({result.count(chr(10))} lines)"
211
+ elif name == "write_file":
212
+ lines = args.get('content', '').count('\n') + 1
213
+ return f" Wrote {args.get('path', '?')} ({lines} lines)"
214
+ elif name == "edit_file":
215
+ old_n = args.get('old_text', '').count('\n') + 1 if args.get('old_text') else 0
216
+ new_n = args.get('new_text', '').count('\n') + 1 if args.get('new_text') else 0
217
+ return f" Edited {args.get('path', '?')} (+{new_n} -{old_n})"
218
+ elif name == "run_command":
219
+ cmd = args.get('command', '?')
220
+ return f" Ran: {cmd[:57] + '...' if len(cmd) > 60 else cmd}"
221
+ elif name in ("list_directory", "get_project_structure"):
222
+ return f" Listed {args.get('path', '.')} ({result.count(chr(10))} items)"
223
+ elif name == "search_in_files":
224
+ return f" Searched \"{args.get('pattern', '?')}\" ({result.count(chr(10))} matches)"
225
+ return f" {name}()"
226
+
227
+
228
+ def _truncate_result(result: str, max_chars: int = 4000) -> str:
229
+ """Truncate tool results to keep context window lean."""
230
+ if len(result) <= max_chars:
231
+ return result
232
+ half = max_chars // 2
233
+ return result[:half] + f"\n\n... [{len(result) - max_chars} chars truncated] ...\n\n" + result[-half:]
234
+
235
+
236
+ def _format_diff(path: str, old_text: str, new_text: str) -> str:
237
+ lines = [f"\n {C.DIFF_HDR}{C.BOLD} {path}{C.RESET}",
238
+ f" {C.DIM} {'─' * min(len(path) + 4, 50)}{C.RESET}"]
239
+ for line in (old_text.splitlines() if old_text else []):
240
+ lines.append(f" {C.DIFF_DEL} - {line}{C.RESET}")
241
+ for line in (new_text.splitlines() if new_text else []):
242
+ lines.append(f" {C.DIFF_ADD} + {line}{C.RESET}")
243
+ return "\n".join(lines)
244
+
245
+
246
+ def _confirm_action(tool_name: str, tool_args: dict) -> bool:
247
+ """Permission prompt for destructive operations."""
248
+ if tool_name == "edit_file":
249
+ old, new = tool_args.get("old_text", ""), tool_args.get("new_text", "")
250
+ print(_format_diff(tool_args.get("path", "?"), old, new))
251
+ prompt = f"\n {C.YELLOW}Apply this edit?{C.RESET} [Y/n] "
252
+ elif tool_name == "write_file":
253
+ prompt = f"\n {C.YELLOW}Write to {C.BOLD}{tool_args.get('path', '?')}{C.RESET}{C.YELLOW}?{C.RESET} [Y/n] "
254
+ elif tool_name == "run_command":
255
+ prompt = f"\n {C.YELLOW}Run: {C.BOLD}{tool_args.get('command', '?')}{C.RESET}{C.YELLOW}?{C.RESET} [Y/n] "
256
+ else:
257
+ return True
258
+ try:
259
+ r = input(prompt).strip().lower()
260
+ return r in ("", "y", "yes")
261
+ except (EOFError, KeyboardInterrupt):
262
+ return False
263
+
264
+
265
+ # ── Context Compression (H2A-style) ──────────────────────────────
266
+
267
+ def _estimate_tokens(messages: list[ChatMessage]) -> int:
268
+ """Rough token estimate: ~4 chars per token."""
269
+ return sum(len(m.content) for m in messages) // 4
270
+
271
+
272
+ def _compress_context(messages: list[ChatMessage], keep_system: bool = True) -> list[ChatMessage]:
273
+ """H2A-style context compression:
274
+ Keep system prompt (head) + last 4 messages (tail).
275
+ Summarize everything in the middle.
276
+ """
277
+ if len(messages) <= 6:
278
+ return messages
279
+
280
+ head = [messages[0]] if keep_system else [] # System prompt
281
+ tail = messages[-4:] # Last 4 messages (recent context)
282
+ middle = messages[1:-4] if keep_system else messages[:-4]
283
+
284
+ # Summarize middle section
285
+ tool_calls = sum(1 for m in middle if "<tool_call>" in m.content or "[Tool:" in m.content)
286
+ summary = (
287
+ f"[Context compressed: {len(middle)} messages summarized. "
288
+ f"{tool_calls} tool operations were performed. "
289
+ f"Key context is preserved in recent messages below.]"
290
+ )
291
+ compressed = head + [ChatMessage(role="user", content=summary)] + tail
292
+ return compressed
293
+
294
+
295
+ # ── Agent Data Types ──────────────────────────────────────────────
296
+
297
+ @dataclass
298
+ class AgentStep:
299
+ step_number: int
300
+ thinking: str
301
+ tool_call: ParsedToolCall | None
302
+ tool_result: str
303
+ is_final: bool
304
+ final_answer: str = ""
305
+ elapsed: float = 0.0
306
+
307
+
308
+ @dataclass
309
+ class AgentResult:
310
+ task: str
311
+ steps: list[AgentStep] = field(default_factory=list)
312
+ final_answer: str = ""
313
+ total_elapsed: float = 0.0
314
+ iterations_used: int = 0
315
+ aborted: bool = False
316
+
317
+
318
+ # ── Agent Engine ──────────────────────────────────────────────────
319
+
320
+ class Agent:
321
+ """ReAct agent with parallel tools, context compression, and mode support."""
322
+
323
+ def __init__(self, config: AgentConfig | None = None):
324
+ self.config = config or AgentConfig.from_env()
325
+ self.client = create_provider(self.config)
326
+ self.messages: list[ChatMessage] = []
327
+ self._slow_response_count = 0 # Track slow responses for upgrade nudge
328
+ self._total_input_tokens = 0
329
+ self._total_output_tokens = 0
330
+ self._git_enabled = is_git_repo(self.config.working_dir)
331
+
332
+ def run(self, task: str, neuron_md: str | None = None) -> AgentResult:
333
+ start_time = time.time()
334
+ result = AgentResult(task=task)
335
+
336
+ system_prompt = build_system_prompt(
337
+ tool_registry, self.config.working_dir,
338
+ mode=self.config.mode, neuron_md=neuron_md,
339
+ )
340
+
341
+ # Preserve conversation history for follow-ups, but reset system prompt
342
+ if not self.messages:
343
+ self.messages = [ChatMessage(role="system", content=system_prompt)]
344
+ else:
345
+ self.messages[0] = ChatMessage(role="system", content=system_prompt)
346
+
347
+ self.messages.append(ChatMessage(role="user", content=task))
348
+
349
+ for iteration in range(1, self.config.max_iterations + 1):
350
+ step_start = time.time()
351
+ result.iterations_used = iteration
352
+
353
+ # ── Context compression check ─────────────────────
354
+ tokens_est = _estimate_tokens(self.messages)
355
+ if tokens_est > self.config.max_context_tokens * self.config.context_compress_percent:
356
+ self.messages = _compress_context(self.messages)
357
+ print(f" {C.DIM}/compact: context compressed{C.RESET}")
358
+
359
+ # ── Show thinking indicator (brand symbol) ───────
360
+ print(f"\r {SYM_THINK} {DIM}Thinking...{RST}", end="", flush=True)
361
+
362
+ # ── Get LLM response ──────────────────────────────
363
+ full_response = ""
364
+ try:
365
+ if self.config.streaming:
366
+ for token in self.client.chat_stream(self.messages):
367
+ full_response += token
368
+ # Dynamic status hint
369
+ clean = _strip_ansi(full_response).strip()
370
+ words = clean.split()[:6]
371
+ if words:
372
+ hint = " ".join(words)
373
+ if len(hint) > 50:
374
+ hint = hint[:47] + "..."
375
+ secs = time.time() - step_start
376
+ print(f"\r {SYM_THINK} {DIM}{hint}... ({secs:.0f}s){RST} ", end="", flush=True)
377
+ else:
378
+ full_response = self.client.chat(self.messages)
379
+ except Exception as exc:
380
+ print(f"\r {SYM_ERR} {exc}")
381
+ result.aborted = True
382
+ break
383
+
384
+ step_elapsed = time.time() - step_start
385
+ clean_response = _strip_ansi(full_response)
386
+ self.messages.append(ChatMessage(role="assistant", content=clean_response))
387
+
388
+ # ── Token tracking ────────────────────────────────
389
+ self._total_input_tokens += sum(len(m.content) for m in self.messages) // 4
390
+ self._total_output_tokens += len(clean_response) // 4
391
+
392
+ # ── Track slow responses for upgrade nudge ────────
393
+ if step_elapsed > 30:
394
+ self._slow_response_count += 1
395
+
396
+ # ── Check for final answer ────────────────────────
397
+ final = parse_final_answer(clean_response)
398
+ if final:
399
+ print(f"\r{' ' * 80}\r", end="")
400
+ display_text = _clean_for_display(final)
401
+ step = AgentStep(iteration, clean_response, None, "", True, final, step_elapsed)
402
+ result.steps.append(step)
403
+ result.final_answer = final
404
+ print(f"\n{display_text}\n")
405
+ break
406
+
407
+ # ── Check for tool calls (PARALLEL) ───────────────
408
+ all_tool_calls = parse_all_tool_calls(clean_response)
409
+ if all_tool_calls:
410
+ safe_calls = []
411
+ dangerous_calls = []
412
+ for tc in all_tool_calls:
413
+ if tc.tool in ("write_file", "edit_file", "run_command"):
414
+ dangerous_calls.append(tc)
415
+ else:
416
+ safe_calls.append(tc)
417
+
418
+ all_results = []
419
+
420
+ # Execute safe calls in PARALLEL
421
+ if safe_calls:
422
+ with ThreadPoolExecutor(max_workers=min(len(safe_calls), 4)) as pool:
423
+ futures = {
424
+ pool.submit(tool_registry.execute, tc.tool, tc.args, self.config): tc
425
+ for tc in safe_calls
426
+ }
427
+ for future in as_completed(futures):
428
+ tc = futures[future]
429
+ try:
430
+ tool_result = future.result()
431
+ except Exception as e:
432
+ tool_result = f"Error: {e}"
433
+ summary = _tool_summary(tc.tool, tc.args, tool_result)
434
+ print(f"\r{' ' * 80}\r{C.GREEN}{summary}{C.RESET}")
435
+ all_results.append((tc, tool_result))
436
+
437
+ # Execute dangerous calls sequentially
438
+ for tc in dangerous_calls:
439
+ yolo = self.config.mode == MODE_YOLO
440
+
441
+ if not yolo and self.config.needs_confirmation:
442
+ print(f"\r{' ' * 80}\r", end="")
443
+ if not _confirm_action(tc.tool, tc.args):
444
+ all_results.append((tc, "[User denied]"))
445
+ continue
446
+
447
+ tool_result = tool_registry.execute(tc.tool, tc.args, self.config)
448
+ summary = _tool_summary(tc.tool, tc.args, tool_result)
449
+ print(f"\r{' ' * 80}\r{C.GREEN}{summary}{C.RESET}")
450
+
451
+ # Show diff for edits (in YOLO mode, show after applying)
452
+ if tc.tool == "edit_file" and "done" in tool_result.lower():
453
+ old_text = tc.args.get("old_text", "")
454
+ new_text = tc.args.get("new_text", "")
455
+ if old_text and new_text and yolo:
456
+ print(_format_diff(tc.args.get("path", "?"), old_text, new_text))
457
+
458
+ all_results.append((tc, tool_result))
459
+
460
+ # Git auto-commit for write/edit operations
461
+ if self._git_enabled and tc.tool in ("write_file", "edit_file") and "[OK]" in tool_result:
462
+ file_path = tc.args.get("path", "")
463
+ commit_msg = f"{tc.tool.replace('_', ' ')}: {file_path}"
464
+ commit_hash = git_auto_commit(self.config.working_dir, [file_path], commit_msg)
465
+ if commit_hash:
466
+ print(f" {DIM}git: committed {commit_hash}{RST}")
467
+
468
+ # Build result message
469
+ result_parts = [f"[Tool: {tc.tool}] {_truncate_result(tr)}" for tc, tr in all_results]
470
+ combined = "\n---\n".join(result_parts)
471
+ self.messages.append(ChatMessage(role="user", content=(
472
+ f"{combined}\n\nAnalyze results. If done, give <final_answer>. "
473
+ f"Otherwise, call more tools (multiple allowed for parallel execution)."
474
+ )))
475
+
476
+ step = AgentStep(iteration, clean_response, all_tool_calls[0], combined, False, "", step_elapsed)
477
+ result.steps.append(step)
478
+ continue
479
+
480
+ # ── No tool call, no final answer ─────────────────
481
+ stripped = clean_response.strip()
482
+ if len(stripped) > 20 and not stripped.startswith('{') and 'tool' not in stripped[:30].lower():
483
+ display_text = _clean_for_display(stripped)
484
+ print(f"\r{' ' * 80}\r\n{display_text}\n")
485
+ step = AgentStep(iteration, clean_response, None, "", True, stripped, step_elapsed)
486
+ result.steps.append(step)
487
+ result.final_answer = stripped
488
+ break
489
+
490
+ # Nudge
491
+ self.messages.append(ChatMessage(role="user", content=(
492
+ "Provide a <final_answer> or use <tool_call> tags.")))
493
+ result.steps.append(AgentStep(iteration, clean_response, None, "", False, "", step_elapsed))
494
+
495
+ else:
496
+ result.aborted = True
497
+ print(f"\r{' ' * 80}\r {C.RED}X Max iterations reached.{C.RESET}")
498
+ if not result.final_answer:
499
+ result.final_answer = "Task was not completed within the iteration limit."
500
+
501
+ result.total_elapsed = time.time() - start_time
502
+ self._print_status(result)
503
+
504
+ # Upgrade nudge for slow responses
505
+ if self._slow_response_count >= 2 and self.config.provider == "openrouter":
506
+ print(f" {C.YELLOW}Tip:{C.RESET} {C.DIM}Responses are slow on the free tier. "
507
+ f"Run /upgrade for faster speed.{C.RESET}\n")
508
+
509
+ return result
510
+
511
+ def compact(self) -> int:
512
+ """Manually compress context. Returns messages removed."""
513
+ before = len(self.messages)
514
+ self.messages = _compress_context(self.messages)
515
+ removed = before - len(self.messages)
516
+ return removed
517
+
518
+ def clear_history(self):
519
+ self.messages.clear()
520
+ self._slow_response_count = 0
521
+
522
+ def _print_status(self, result: AgentResult):
523
+ tools_used = [s.tool_call.tool for s in result.steps if s.tool_call]
524
+ parts = []
525
+ for tool_name, label in [("read_file", "read"), ("edit_file", "edited"),
526
+ ("write_file", "wrote"), ("run_command", "ran"),
527
+ ("search_in_files", "searched")]:
528
+ count = sum(1 for t in tools_used if t == tool_name)
529
+ if count:
530
+ noun = "files" if "file" in tool_name else ("commands" if "command" in tool_name else "patterns")
531
+ parts.append(f"{label} {count} {noun}")
532
+
533
+ summary = ", ".join(parts) if parts else "no tools used"
534
+ model = self.config.active_model.split("/")[-1]
535
+ mode_tag = f" [{self.config.mode}]" if self.config.mode != "standard" else ""
536
+
537
+ # Token count display
538
+ total_tok = self._total_output_tokens
539
+ if total_tok > 1000:
540
+ tok_str = f"~{total_tok / 1000:.1f}k tokens"
541
+ else:
542
+ tok_str = f"~{total_tok} tokens"
543
+
544
+ print(f" {_GRAY}{model}{mode_tag} {RST}{DIM}. {summary} . {result.total_elapsed:.1f}s . {tok_str} . $0.00{RST}\n")