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 +18 -0
- neuroncli/__main__.py +14 -0
- neuroncli/agent.py +544 -0
- neuroncli/auth.py +263 -0
- neuroncli/cli.py +409 -0
- neuroncli/config.py +112 -0
- neuroncli/git_integration.py +86 -0
- neuroncli/ollama_client.py +150 -0
- neuroncli/openrouter_client.py +220 -0
- neuroncli/prompts.py +109 -0
- neuroncli/provider.py +45 -0
- neuroncli/session.py +88 -0
- neuroncli/tools.py +413 -0
- neuroncli/ui.py +138 -0
- neuroncli-2.1.0.dist-info/METADATA +171 -0
- neuroncli-2.1.0.dist-info/RECORD +19 -0
- neuroncli-2.1.0.dist-info/WHEEL +5 -0
- neuroncli-2.1.0.dist-info/entry_points.txt +2 -0
- neuroncli-2.1.0.dist-info/top_level.txt +1 -0
neuroncli/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""NeuronCLI — Configuration module. v2.0 with modes, context management, and hybrid pricing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from .auth import ensure_api_key
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Operational Modes ─────────────────────────────────────────────
|
|
12
|
+
MODE_STANDARD = "standard" # Ask permission for writes/commands
|
|
13
|
+
MODE_PLAN = "plan" # Plan first, then execute after approval
|
|
14
|
+
MODE_YOLO = "yolo" # Skip all permission prompts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentConfig:
|
|
19
|
+
"""All configurable settings for the NeuronCLI agent."""
|
|
20
|
+
|
|
21
|
+
# ── Provider Selection ────────────────────────────────────────
|
|
22
|
+
provider: str = "openrouter" # "openrouter" or "ollama"
|
|
23
|
+
|
|
24
|
+
# ── OpenRouter Settings ───────────────────────────────────────
|
|
25
|
+
api_key: str = ""
|
|
26
|
+
model: str = "moonshotai/kimi-k2.5" # Free reasoning model
|
|
27
|
+
max_tokens: int = 4096
|
|
28
|
+
|
|
29
|
+
# ── Ollama Settings (fallback) ────────────────────────────────
|
|
30
|
+
ollama_model: str = "qwen2.5-coder:7b"
|
|
31
|
+
base_url: str = "http://localhost:11434"
|
|
32
|
+
num_ctx: int = 8192
|
|
33
|
+
|
|
34
|
+
# ── Shared LLM Settings ──────────────────────────────────────
|
|
35
|
+
temperature: float = 0.2
|
|
36
|
+
top_p: float = 0.9
|
|
37
|
+
|
|
38
|
+
# ── Agent Behavior ────────────────────────────────────────────
|
|
39
|
+
max_iterations: int = 15 # Safety cap for ReAct loop
|
|
40
|
+
streaming: bool = True # Stream tokens to terminal
|
|
41
|
+
confirm_dangerous: bool = True # Ask before destructive commands
|
|
42
|
+
show_thinking: bool = True # Show reasoning tokens (K2.5)
|
|
43
|
+
mode: str = MODE_STANDARD # standard / plan / yolo
|
|
44
|
+
|
|
45
|
+
# ── Context Management ────────────────────────────────────────
|
|
46
|
+
context_warn_percent: float = 0.85 # Warn at 85% context usage
|
|
47
|
+
context_compress_percent: float = 0.92 # Auto-compress at 92%
|
|
48
|
+
max_context_tokens: int = 128000 # K2.5 supports 128k
|
|
49
|
+
|
|
50
|
+
# ── Working Directory ─────────────────────────────────────────
|
|
51
|
+
working_dir: str = field(default_factory=lambda: os.getcwd())
|
|
52
|
+
|
|
53
|
+
# ── Dangerous command patterns (require confirmation) ─────────
|
|
54
|
+
dangerous_patterns: tuple[str, ...] = (
|
|
55
|
+
"rm ", "del ", "rmdir", "format ",
|
|
56
|
+
"shutdown", "restart",
|
|
57
|
+
"> /dev/null", "| rm",
|
|
58
|
+
"drop table", "drop database",
|
|
59
|
+
"git push --force", "git reset --hard",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_env(cls) -> "AgentConfig":
|
|
64
|
+
"""Build config from environment variables with defaults."""
|
|
65
|
+
provider = os.environ.get("NEURON_PROVIDER", "openrouter")
|
|
66
|
+
api_key = ensure_api_key() or ""
|
|
67
|
+
|
|
68
|
+
if provider == "openrouter" and not api_key:
|
|
69
|
+
provider = "ollama"
|
|
70
|
+
|
|
71
|
+
model = os.environ.get("NEURON_MODEL", "moonshotai/kimi-k2.5")
|
|
72
|
+
ollama_model = os.environ.get("NEURON_OLLAMA_MODEL", "qwen2.5-coder:7b")
|
|
73
|
+
|
|
74
|
+
return cls(
|
|
75
|
+
provider=provider,
|
|
76
|
+
api_key=api_key,
|
|
77
|
+
model=model,
|
|
78
|
+
ollama_model=ollama_model,
|
|
79
|
+
base_url=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
|
|
80
|
+
temperature=float(os.environ.get("NEURON_TEMPERATURE", "0.2")),
|
|
81
|
+
max_iterations=int(os.environ.get("NEURON_MAX_ITER", "15")),
|
|
82
|
+
max_tokens=int(os.environ.get("NEURON_MAX_TOKENS", "4096")),
|
|
83
|
+
num_ctx=int(os.environ.get("NEURON_CTX", "8192")),
|
|
84
|
+
working_dir=os.environ.get("NEURON_WORK_DIR", os.getcwd()),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def resolve_path(self, path: str) -> Path:
|
|
88
|
+
p = Path(path)
|
|
89
|
+
if p.is_absolute():
|
|
90
|
+
return p
|
|
91
|
+
return Path(self.working_dir) / p
|
|
92
|
+
|
|
93
|
+
def is_dangerous_command(self, cmd: str) -> bool:
|
|
94
|
+
lower = cmd.lower().strip()
|
|
95
|
+
return any(pat in lower for pat in self.dangerous_patterns)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def active_model(self) -> str:
|
|
99
|
+
if self.provider == "ollama":
|
|
100
|
+
return self.ollama_model
|
|
101
|
+
return self.model
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def needs_confirmation(self) -> bool:
|
|
105
|
+
"""Whether current mode requires user confirmation for writes."""
|
|
106
|
+
return self.mode == MODE_STANDARD and self.confirm_dangerous
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── App-wide constants ────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
APP_NAME = "NeuronCLI"
|
|
112
|
+
VERSION = "2.1.0"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""NeuronCLI — Git integration. Auto-commit AI edits like Aider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_git_repo(working_dir: str) -> bool:
|
|
10
|
+
"""Check if the working directory is inside a git repository."""
|
|
11
|
+
try:
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
14
|
+
cwd=working_dir, capture_output=True, text=True, timeout=5,
|
|
15
|
+
)
|
|
16
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
17
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def git_auto_commit(working_dir: str, files: list[str], message: str) -> str | None:
|
|
22
|
+
"""Stage specific files and commit with a descriptive message.
|
|
23
|
+
Returns commit hash on success, None on failure.
|
|
24
|
+
"""
|
|
25
|
+
if not is_git_repo(working_dir):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
# Stage only the modified files
|
|
30
|
+
for f in files:
|
|
31
|
+
subprocess.run(
|
|
32
|
+
["git", "add", str(f)],
|
|
33
|
+
cwd=working_dir, capture_output=True, timeout=5,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Check if there are staged changes
|
|
37
|
+
status = subprocess.run(
|
|
38
|
+
["git", "diff", "--cached", "--quiet"],
|
|
39
|
+
cwd=working_dir, capture_output=True, timeout=5,
|
|
40
|
+
)
|
|
41
|
+
if status.returncode == 0:
|
|
42
|
+
return None # Nothing staged
|
|
43
|
+
|
|
44
|
+
# Commit
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git", "commit", "-m", f"neuron: {message}"],
|
|
47
|
+
cwd=working_dir, capture_output=True, text=True, timeout=10,
|
|
48
|
+
)
|
|
49
|
+
if result.returncode == 0:
|
|
50
|
+
# Extract short hash
|
|
51
|
+
hash_result = subprocess.run(
|
|
52
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
53
|
+
cwd=working_dir, capture_output=True, text=True, timeout=5,
|
|
54
|
+
)
|
|
55
|
+
return hash_result.stdout.strip() if hash_result.returncode == 0 else "ok"
|
|
56
|
+
return None
|
|
57
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def git_status(working_dir: str) -> str:
|
|
62
|
+
"""Get git status summary."""
|
|
63
|
+
if not is_git_repo(working_dir):
|
|
64
|
+
return "Not a git repository."
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["git", "status", "--short"],
|
|
68
|
+
cwd=working_dir, capture_output=True, text=True, timeout=5,
|
|
69
|
+
)
|
|
70
|
+
return result.stdout.strip() or "Working tree clean."
|
|
71
|
+
except Exception:
|
|
72
|
+
return "Error running git status."
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def git_diff(working_dir: str) -> str:
|
|
76
|
+
"""Get current unstaged diff."""
|
|
77
|
+
if not is_git_repo(working_dir):
|
|
78
|
+
return "Not a git repository."
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["git", "diff", "--stat"],
|
|
82
|
+
cwd=working_dir, capture_output=True, text=True, timeout=5,
|
|
83
|
+
)
|
|
84
|
+
return result.stdout.strip() or "No changes."
|
|
85
|
+
except Exception:
|
|
86
|
+
return "Error running git diff."
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""NeuronCLI — Ollama HTTP streaming client. Zero external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Generator
|
|
10
|
+
|
|
11
|
+
from .config import AgentConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OllamaConnectionError(Exception):
|
|
15
|
+
"""Raised when the Ollama server is unreachable."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ChatMessage:
|
|
21
|
+
"""A single message in the conversation."""
|
|
22
|
+
role: str # "system", "user", "assistant"
|
|
23
|
+
content: str
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, str]:
|
|
26
|
+
return {"role": self.role, "content": self.content}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OllamaClient:
|
|
30
|
+
"""
|
|
31
|
+
HTTP client for Ollama's /api/chat endpoint.
|
|
32
|
+
Uses only stdlib — no pip dependencies needed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
36
|
+
self.config = config or AgentConfig.from_env()
|
|
37
|
+
self._base = self.config.base_url.rstrip("/")
|
|
38
|
+
|
|
39
|
+
def health_check(self) -> bool:
|
|
40
|
+
"""Check if Ollama server is responding."""
|
|
41
|
+
try:
|
|
42
|
+
req = urllib.request.Request(f"{self._base}/api/tags")
|
|
43
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
44
|
+
return resp.status == 200
|
|
45
|
+
except (urllib.error.URLError, OSError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def list_models(self) -> list[str]:
|
|
49
|
+
"""List locally installed model names."""
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(f"{self._base}/api/tags")
|
|
52
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
53
|
+
data = json.loads(resp.read().decode())
|
|
54
|
+
return [m["name"] for m in data.get("models", [])]
|
|
55
|
+
except (urllib.error.URLError, OSError):
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
def chat(self, messages: list[ChatMessage], model: str | None = None) -> str:
|
|
59
|
+
"""Send a chat request and return the full response text."""
|
|
60
|
+
payload = self._build_payload(messages, model, stream=False)
|
|
61
|
+
data = self._post("/api/chat", payload)
|
|
62
|
+
return data.get("message", {}).get("content", "")
|
|
63
|
+
|
|
64
|
+
def chat_stream(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[ChatMessage],
|
|
67
|
+
model: str | None = None,
|
|
68
|
+
) -> Generator[str, None, None]:
|
|
69
|
+
"""
|
|
70
|
+
Stream a chat response token by token.
|
|
71
|
+
Yields individual tokens as strings.
|
|
72
|
+
"""
|
|
73
|
+
payload = self._build_payload(messages, model, stream=True)
|
|
74
|
+
body = json.dumps(payload).encode("utf-8")
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
f"{self._base}/api/chat",
|
|
77
|
+
data=body,
|
|
78
|
+
headers={"Content-Type": "application/json"},
|
|
79
|
+
method="POST",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
resp = urllib.request.urlopen(req, timeout=300)
|
|
84
|
+
except urllib.error.URLError as exc:
|
|
85
|
+
raise OllamaConnectionError(
|
|
86
|
+
f"\n [X] Cannot connect to Ollama at {self._base}\n"
|
|
87
|
+
f" Start it with: ollama serve\n"
|
|
88
|
+
f" Error: {exc}"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
buffer = b""
|
|
92
|
+
try:
|
|
93
|
+
while True:
|
|
94
|
+
byte = resp.read(1)
|
|
95
|
+
if not byte:
|
|
96
|
+
break
|
|
97
|
+
buffer += byte
|
|
98
|
+
if byte == b"\n":
|
|
99
|
+
line = buffer.decode("utf-8", errors="replace").strip()
|
|
100
|
+
buffer = b""
|
|
101
|
+
if not line:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
token = data.get("message", {}).get("content", "")
|
|
108
|
+
if token:
|
|
109
|
+
yield token
|
|
110
|
+
if data.get("done", False):
|
|
111
|
+
break
|
|
112
|
+
finally:
|
|
113
|
+
resp.close()
|
|
114
|
+
|
|
115
|
+
# ── Internal helpers ──────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _build_payload(
|
|
118
|
+
self,
|
|
119
|
+
messages: list[ChatMessage],
|
|
120
|
+
model: str | None,
|
|
121
|
+
stream: bool,
|
|
122
|
+
) -> dict:
|
|
123
|
+
return {
|
|
124
|
+
"model": model or self.config.model,
|
|
125
|
+
"messages": [m.to_dict() for m in messages],
|
|
126
|
+
"stream": stream,
|
|
127
|
+
"options": {
|
|
128
|
+
"temperature": self.config.temperature,
|
|
129
|
+
"top_p": self.config.top_p,
|
|
130
|
+
"num_ctx": self.config.num_ctx,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
135
|
+
body = json.dumps(payload).encode("utf-8")
|
|
136
|
+
req = urllib.request.Request(
|
|
137
|
+
f"{self._base}{path}",
|
|
138
|
+
data=body,
|
|
139
|
+
headers={"Content-Type": "application/json"},
|
|
140
|
+
method="POST",
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
144
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
145
|
+
except urllib.error.URLError as exc:
|
|
146
|
+
raise OllamaConnectionError(
|
|
147
|
+
f"\n [X] Cannot connect to Ollama at {self._base}\n"
|
|
148
|
+
f" Start it with: ollama serve\n"
|
|
149
|
+
f" Error: {exc}"
|
|
150
|
+
) from exc
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""NeuronCLI — OpenRouter HTTP streaming client. Zero external dependencies.
|
|
2
|
+
|
|
3
|
+
Supports reasoning models (Kimi K2.5) where the response has both
|
|
4
|
+
a `reasoning` field (chain-of-thought) and a `content` field (final answer).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Generator
|
|
14
|
+
|
|
15
|
+
from .config import AgentConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenRouterConnectionError(Exception):
|
|
19
|
+
"""Raised when the OpenRouter API is unreachable or returns an error."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ChatMessage:
|
|
25
|
+
"""A single message in the conversation."""
|
|
26
|
+
role: str # "system", "user", "assistant"
|
|
27
|
+
content: str
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, str]:
|
|
30
|
+
return {"role": self.role, "content": self.content}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenRouterClient:
|
|
34
|
+
"""
|
|
35
|
+
HTTP client for OpenRouter's OpenAI-compatible /api/v1/chat/completions.
|
|
36
|
+
Uses only stdlib — no pip dependencies needed.
|
|
37
|
+
|
|
38
|
+
Handles reasoning models (like Kimi K2.5) that emit:
|
|
39
|
+
- `delta.reasoning` tokens (thinking, shown dimmed)
|
|
40
|
+
- `delta.content` tokens (final answer)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
API_BASE = "https://openrouter.ai/api/v1"
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
46
|
+
self.config = config or AgentConfig.from_env()
|
|
47
|
+
|
|
48
|
+
def health_check(self) -> bool:
|
|
49
|
+
"""Check if OpenRouter API is reachable and the API key is valid."""
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(
|
|
52
|
+
f"{self.API_BASE}/models",
|
|
53
|
+
headers=self._headers(),
|
|
54
|
+
)
|
|
55
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
56
|
+
return resp.status == 200
|
|
57
|
+
except (urllib.error.URLError, OSError):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def list_models(self) -> list[str]:
|
|
61
|
+
"""List coding-relevant models from OpenRouter."""
|
|
62
|
+
try:
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
f"{self.API_BASE}/models",
|
|
65
|
+
headers=self._headers(),
|
|
66
|
+
)
|
|
67
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
68
|
+
data = json.loads(resp.read().decode())
|
|
69
|
+
models = data.get("data", [])
|
|
70
|
+
keywords = ("kimi", "deepseek", "qwen", "codestral", "claude", "gemini")
|
|
71
|
+
names = [m["id"] for m in models
|
|
72
|
+
if any(k in m["id"].lower() for k in keywords)]
|
|
73
|
+
return sorted(names)[:25]
|
|
74
|
+
except (urllib.error.URLError, OSError):
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
def chat(self, messages: list[ChatMessage], model: str | None = None) -> str:
|
|
78
|
+
"""Send a chat request and return the full response text."""
|
|
79
|
+
payload = self._build_payload(messages, model, stream=False)
|
|
80
|
+
data = self._post(payload)
|
|
81
|
+
choice = data.get("choices", [{}])[0]
|
|
82
|
+
msg = choice.get("message", {})
|
|
83
|
+
# Kimi K2.5 reasoning models: content may be in `content` or `reasoning`
|
|
84
|
+
content = msg.get("content") or ""
|
|
85
|
+
if not content:
|
|
86
|
+
content = msg.get("reasoning", "")
|
|
87
|
+
return content
|
|
88
|
+
|
|
89
|
+
def chat_stream(
|
|
90
|
+
self,
|
|
91
|
+
messages: list[ChatMessage],
|
|
92
|
+
model: str | None = None,
|
|
93
|
+
) -> Generator[str, None, None]:
|
|
94
|
+
"""
|
|
95
|
+
Stream a chat response token by token.
|
|
96
|
+
|
|
97
|
+
For reasoning models (Kimi K2.5), yields:
|
|
98
|
+
- reasoning tokens with a dim prefix (hidden thinking)
|
|
99
|
+
- content tokens normally (the actual response)
|
|
100
|
+
"""
|
|
101
|
+
payload = self._build_payload(messages, model, stream=True)
|
|
102
|
+
body = json.dumps(payload).encode("utf-8")
|
|
103
|
+
req = urllib.request.Request(
|
|
104
|
+
f"{self.API_BASE}/chat/completions",
|
|
105
|
+
data=body,
|
|
106
|
+
headers=self._headers(json_body=True),
|
|
107
|
+
method="POST",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
resp = urllib.request.urlopen(req, timeout=300)
|
|
112
|
+
except urllib.error.HTTPError as exc:
|
|
113
|
+
error_body = exc.read().decode("utf-8", errors="replace")
|
|
114
|
+
try:
|
|
115
|
+
err_data = json.loads(error_body)
|
|
116
|
+
msg = err_data.get("error", {}).get("message", error_body)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
msg = error_body
|
|
119
|
+
raise OpenRouterConnectionError(
|
|
120
|
+
f"\n [X] OpenRouter API error ({exc.code})\n"
|
|
121
|
+
f" {msg}"
|
|
122
|
+
) from exc
|
|
123
|
+
except urllib.error.URLError as exc:
|
|
124
|
+
raise OpenRouterConnectionError(
|
|
125
|
+
f"\n [X] Cannot reach OpenRouter API.\n"
|
|
126
|
+
f" Check your internet connection.\n"
|
|
127
|
+
f" Error: {exc}"
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
in_reasoning = False
|
|
131
|
+
try:
|
|
132
|
+
for raw_line in resp:
|
|
133
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
134
|
+
if not line or line == "data: [DONE]":
|
|
135
|
+
continue
|
|
136
|
+
if not line.startswith("data: "):
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
chunk = json.loads(line[6:])
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
continue
|
|
142
|
+
choices = chunk.get("choices", [])
|
|
143
|
+
if not choices:
|
|
144
|
+
continue
|
|
145
|
+
delta = choices[0].get("delta", {})
|
|
146
|
+
|
|
147
|
+
# Handle reasoning tokens (Kimi K2.5 chain-of-thought)
|
|
148
|
+
reasoning_token = delta.get("reasoning", "")
|
|
149
|
+
content_token = delta.get("content", "")
|
|
150
|
+
|
|
151
|
+
if reasoning_token:
|
|
152
|
+
if not in_reasoning:
|
|
153
|
+
yield "\033[2m\033[90m" # DIM + GRAY
|
|
154
|
+
in_reasoning = True
|
|
155
|
+
yield reasoning_token
|
|
156
|
+
|
|
157
|
+
if content_token:
|
|
158
|
+
if in_reasoning:
|
|
159
|
+
yield "\033[0m\n" # Reset after reasoning
|
|
160
|
+
in_reasoning = False
|
|
161
|
+
yield content_token
|
|
162
|
+
finally:
|
|
163
|
+
if in_reasoning:
|
|
164
|
+
yield "\033[0m\n"
|
|
165
|
+
resp.close()
|
|
166
|
+
|
|
167
|
+
# ── Internal helpers ──────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def _headers(self, json_body: bool = False) -> dict[str, str]:
|
|
170
|
+
h: dict[str, str] = {
|
|
171
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
172
|
+
"HTTP-Referer": "https://github.com/RAHUL-DevelopeRR/neuroncli",
|
|
173
|
+
"X-Title": "NeuronCLI",
|
|
174
|
+
}
|
|
175
|
+
if json_body:
|
|
176
|
+
h["Content-Type"] = "application/json"
|
|
177
|
+
return h
|
|
178
|
+
|
|
179
|
+
def _build_payload(
|
|
180
|
+
self,
|
|
181
|
+
messages: list[ChatMessage],
|
|
182
|
+
model: str | None,
|
|
183
|
+
stream: bool,
|
|
184
|
+
) -> dict:
|
|
185
|
+
return {
|
|
186
|
+
"model": model or self.config.model,
|
|
187
|
+
"messages": [m.to_dict() for m in messages],
|
|
188
|
+
"stream": stream,
|
|
189
|
+
"max_tokens": self.config.max_tokens,
|
|
190
|
+
"temperature": self.config.temperature,
|
|
191
|
+
"top_p": self.config.top_p,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _post(self, payload: dict) -> dict:
|
|
195
|
+
body = json.dumps(payload).encode("utf-8")
|
|
196
|
+
req = urllib.request.Request(
|
|
197
|
+
f"{self.API_BASE}/chat/completions",
|
|
198
|
+
data=body,
|
|
199
|
+
headers=self._headers(json_body=True),
|
|
200
|
+
method="POST",
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
204
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
205
|
+
except urllib.error.HTTPError as exc:
|
|
206
|
+
error_body = exc.read().decode("utf-8", errors="replace")
|
|
207
|
+
try:
|
|
208
|
+
err_data = json.loads(error_body)
|
|
209
|
+
msg = err_data.get("error", {}).get("message", error_body)
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
msg = error_body
|
|
212
|
+
raise OpenRouterConnectionError(
|
|
213
|
+
f"\n [X] OpenRouter API error ({exc.code})\n"
|
|
214
|
+
f" {msg}"
|
|
215
|
+
) from exc
|
|
216
|
+
except urllib.error.URLError as exc:
|
|
217
|
+
raise OpenRouterConnectionError(
|
|
218
|
+
f"\n [X] Cannot reach OpenRouter API.\n"
|
|
219
|
+
f" Error: {exc}"
|
|
220
|
+
) from exc
|
neuroncli/prompts.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""NeuronCLI — System prompt builder. v2.0 with plan mode, to-do prompting, and context injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .tools import ToolRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_system_prompt(
|
|
9
|
+
tool_registry: ToolRegistry,
|
|
10
|
+
working_dir: str,
|
|
11
|
+
mode: str = "standard",
|
|
12
|
+
neuron_md: str | None = None,
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Build the complete system prompt for the agent."""
|
|
15
|
+
tool_docs = tool_registry.generate_prompt_section()
|
|
16
|
+
tool_names = ", ".join(tool_registry.tool_names())
|
|
17
|
+
|
|
18
|
+
# Base prompt
|
|
19
|
+
prompt = f"""You are NeuronCLI, an expert AI coding agent running in the user's terminal.
|
|
20
|
+
You have direct filesystem and terminal access through tools.
|
|
21
|
+
|
|
22
|
+
## Working Directory
|
|
23
|
+
{working_dir}
|
|
24
|
+
|
|
25
|
+
## Available Tools
|
|
26
|
+
{tool_docs}
|
|
27
|
+
|
|
28
|
+
## How to Call Tools
|
|
29
|
+
|
|
30
|
+
Use this EXACT format:
|
|
31
|
+
|
|
32
|
+
<tool_call>
|
|
33
|
+
{{"tool": "TOOL_NAME", "args": {{"param1": "value1"}}}}
|
|
34
|
+
</tool_call>
|
|
35
|
+
|
|
36
|
+
You CAN call MULTIPLE tools in one response (they execute in parallel):
|
|
37
|
+
|
|
38
|
+
<tool_call>
|
|
39
|
+
{{"tool": "read_file", "args": {{"path": "README.md"}}}}
|
|
40
|
+
</tool_call>
|
|
41
|
+
<tool_call>
|
|
42
|
+
{{"tool": "read_file", "args": {{"path": "package.json"}}}}
|
|
43
|
+
</tool_call>
|
|
44
|
+
<tool_call>
|
|
45
|
+
{{"tool": "list_directory", "args": {{"path": "src"}}}}
|
|
46
|
+
</tool_call>
|
|
47
|
+
|
|
48
|
+
## How to Give Your Final Answer
|
|
49
|
+
|
|
50
|
+
<final_answer>
|
|
51
|
+
Your response here. Use markdown formatting.
|
|
52
|
+
</final_answer>
|
|
53
|
+
|
|
54
|
+
## Rules
|
|
55
|
+
1. Be concise. Do NOT narrate what you are about to do.
|
|
56
|
+
2. Call multiple tools at once when possible (parallel execution).
|
|
57
|
+
3. For simple greetings or questions, skip tools and answer with <final_answer> directly.
|
|
58
|
+
4. NEVER invent file contents — always use tools to verify.
|
|
59
|
+
5. Use edit_file for changes (NOT write_file for existing files).
|
|
60
|
+
6. Read a file BEFORE editing it.
|
|
61
|
+
7. Available tools: {tool_names}"""
|
|
62
|
+
|
|
63
|
+
# Mode-specific instructions
|
|
64
|
+
if mode == "plan":
|
|
65
|
+
prompt += """
|
|
66
|
+
|
|
67
|
+
## PLAN MODE (Active)
|
|
68
|
+
You are in PLAN MODE. Do NOT write code yet.
|
|
69
|
+
1. Analyze the user's request carefully.
|
|
70
|
+
2. Ask numbered clarifying questions about requirements, tech stack, and constraints.
|
|
71
|
+
3. Once clarified, generate a structured implementation plan with:
|
|
72
|
+
- Architecture overview
|
|
73
|
+
- File structure
|
|
74
|
+
- Key components and their responsibilities
|
|
75
|
+
- Implementation steps (numbered)
|
|
76
|
+
4. Present the plan and wait for user approval before writing any code.
|
|
77
|
+
5. Do NOT use write_file or edit_file in Plan Mode."""
|
|
78
|
+
|
|
79
|
+
elif mode == "yolo":
|
|
80
|
+
prompt += """
|
|
81
|
+
|
|
82
|
+
## YOLO MODE (Active)
|
|
83
|
+
Execute ALL operations autonomously. Do NOT ask for permission.
|
|
84
|
+
Write files, edit code, run commands — go continuously until the task is fully complete.
|
|
85
|
+
Be aggressive and thorough. Do not stop to ask the user questions."""
|
|
86
|
+
|
|
87
|
+
# Multi-step task tracking
|
|
88
|
+
prompt += """
|
|
89
|
+
|
|
90
|
+
## Multi-Step Tasks
|
|
91
|
+
For complex tasks, create a mental to-do list:
|
|
92
|
+
```
|
|
93
|
+
TODO:
|
|
94
|
+
- [ ] Step 1: Understand requirements
|
|
95
|
+
- [ ] Step 2: Read relevant files
|
|
96
|
+
- [ ] Step 3: Make changes
|
|
97
|
+
- [ ] Step 4: Verify changes work
|
|
98
|
+
```
|
|
99
|
+
Track progress through each step. Do NOT skip verification."""
|
|
100
|
+
|
|
101
|
+
# Context from NEURON.md
|
|
102
|
+
if neuron_md:
|
|
103
|
+
prompt += f"""
|
|
104
|
+
|
|
105
|
+
## Project Context (from NEURON.md)
|
|
106
|
+
{neuron_md}
|
|
107
|
+
Follow all rules and conventions specified above."""
|
|
108
|
+
|
|
109
|
+
return prompt
|