lmcoding-local 3.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.
lmcoding.py
ADDED
|
@@ -0,0 +1,2565 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LMCoding Pro: agente de programación local para LM Studio.
|
|
4
|
+
|
|
5
|
+
Ejemplos:
|
|
6
|
+
python lmcoding.py
|
|
7
|
+
python lmcoding.py -C C:\\proyectos\\mi-app
|
|
8
|
+
python lmcoding.py exec "corrige las pruebas que fallan" -C .
|
|
9
|
+
python lmcoding.py resume --last
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import ast
|
|
16
|
+
import dataclasses
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import difflib
|
|
19
|
+
import fnmatch
|
|
20
|
+
import importlib.util
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Iterable, Literal
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import tomllib
|
|
36
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10
|
|
37
|
+
import tomli as tomllib # type: ignore
|
|
38
|
+
|
|
39
|
+
from openai import OpenAI
|
|
40
|
+
from prompt_toolkit import PromptSession
|
|
41
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
42
|
+
from prompt_toolkit.formatted_text import HTML
|
|
43
|
+
from prompt_toolkit.history import FileHistory
|
|
44
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
45
|
+
from rich.console import Console
|
|
46
|
+
from rich.markdown import Markdown
|
|
47
|
+
from rich.panel import Panel
|
|
48
|
+
from rich.prompt import Confirm, Prompt
|
|
49
|
+
from rich.table import Table
|
|
50
|
+
from rich.text import Text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
APP_NAME = "LMCoding"
|
|
54
|
+
COMMAND_BRAND = "llmCodex"
|
|
55
|
+
APP_VERSION = "3.1.0"
|
|
56
|
+
DEFAULT_BASE_URL = "http://localhost:1234/v1"
|
|
57
|
+
DEFAULT_MAX_STEPS = 32
|
|
58
|
+
DEFAULT_CONTEXT_CHARS = 120_000
|
|
59
|
+
MAX_TOOL_OUTPUT = 30_000
|
|
60
|
+
MAX_FILE_READ_LINES = 1_500
|
|
61
|
+
MAX_LISTED_FILES = 1_000
|
|
62
|
+
MAX_SEARCH_MATCHES = 500
|
|
63
|
+
|
|
64
|
+
PermissionMode = Literal["read-only", "workspace-write", "full-access"]
|
|
65
|
+
ApprovalPolicy = Literal["untrusted", "on-request", "never"]
|
|
66
|
+
VerificationMode = Literal["off", "quick", "full"]
|
|
67
|
+
|
|
68
|
+
console = Console()
|
|
69
|
+
|
|
70
|
+
HOME_DIR = Path(os.getenv("LMCODING_HOME", Path.home() / ".lmcoding")).expanduser()
|
|
71
|
+
SESSIONS_DIR = HOME_DIR / "sessions"
|
|
72
|
+
HISTORY_FILE = HOME_DIR / "history"
|
|
73
|
+
GLOBAL_CONFIG_FILE = HOME_DIR / "config.toml"
|
|
74
|
+
|
|
75
|
+
DEFAULT_IGNORES = {
|
|
76
|
+
".git",
|
|
77
|
+
".idea",
|
|
78
|
+
".vscode",
|
|
79
|
+
".lmcoding",
|
|
80
|
+
"__pycache__",
|
|
81
|
+
".pytest_cache",
|
|
82
|
+
".mypy_cache",
|
|
83
|
+
".ruff_cache",
|
|
84
|
+
"node_modules",
|
|
85
|
+
".venv",
|
|
86
|
+
"venv",
|
|
87
|
+
"dist",
|
|
88
|
+
"build",
|
|
89
|
+
"target",
|
|
90
|
+
".next",
|
|
91
|
+
".nuxt",
|
|
92
|
+
"coverage",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
PROJECT_HINT_FILES = [
|
|
96
|
+
"AGENTS.md",
|
|
97
|
+
"CLAUDE.md",
|
|
98
|
+
"README.md",
|
|
99
|
+
"README.rst",
|
|
100
|
+
"README.txt",
|
|
101
|
+
"pyproject.toml",
|
|
102
|
+
"package.json",
|
|
103
|
+
"Cargo.toml",
|
|
104
|
+
"go.mod",
|
|
105
|
+
"pom.xml",
|
|
106
|
+
"build.gradle",
|
|
107
|
+
"build.gradle.kts",
|
|
108
|
+
"requirements.txt",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
SAFE_COMMAND_PREFIXES = (
|
|
112
|
+
"git status",
|
|
113
|
+
"git diff",
|
|
114
|
+
"git log",
|
|
115
|
+
"git show",
|
|
116
|
+
"git branch",
|
|
117
|
+
"git rev-parse",
|
|
118
|
+
"git ls-files",
|
|
119
|
+
"pwd",
|
|
120
|
+
"ls",
|
|
121
|
+
"dir",
|
|
122
|
+
"tree",
|
|
123
|
+
"rg",
|
|
124
|
+
"grep",
|
|
125
|
+
"find",
|
|
126
|
+
"where",
|
|
127
|
+
"which",
|
|
128
|
+
"python --version",
|
|
129
|
+
"python -V",
|
|
130
|
+
"node --version",
|
|
131
|
+
"npm --version",
|
|
132
|
+
"java -version",
|
|
133
|
+
"gradle --version",
|
|
134
|
+
"cargo --version",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
NETWORK_COMMAND_PATTERNS = (
|
|
138
|
+
r"\bcurl\b",
|
|
139
|
+
r"\bwget\b",
|
|
140
|
+
r"\binvoke-webrequest\b",
|
|
141
|
+
r"\bgit\s+(clone|pull|fetch|push)\b",
|
|
142
|
+
r"\b(pip|pip3|python\s+-m\s+pip)\s+install\b",
|
|
143
|
+
r"\b(npm|pnpm|yarn|bun)\s+(install|add|update|upgrade)\b",
|
|
144
|
+
r"\b(cargo|go)\s+(install|get)\b",
|
|
145
|
+
r"\bapt(-get)?\b",
|
|
146
|
+
r"\bbrew\s+install\b",
|
|
147
|
+
r"\bchoco\s+install\b",
|
|
148
|
+
r"\bwinget\s+install\b",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
DANGEROUS_COMMAND_PATTERNS = (
|
|
152
|
+
r"(^|[;&|]\s*)rm\s+-rf\b",
|
|
153
|
+
r"(^|[;&|]\s*)del\s+(/s|/q|/f)",
|
|
154
|
+
r"(^|[;&|]\s*)rmdir\s+(/s|/q)",
|
|
155
|
+
r"\bformat\s+[a-z]:",
|
|
156
|
+
r"\bmkfs\b",
|
|
157
|
+
r"\bdd\s+if=",
|
|
158
|
+
r"\bshutdown\b",
|
|
159
|
+
r"\breboot\b",
|
|
160
|
+
r"\breg\s+delete\b",
|
|
161
|
+
r"\bgit\s+reset\s+--hard\b",
|
|
162
|
+
r"\bgit\s+clean\s+-[a-z]*f",
|
|
163
|
+
r"\bgit\s+push\s+.*--force\b",
|
|
164
|
+
r"\b(drop|truncate)\s+(database|table)\b",
|
|
165
|
+
r"\bchmod\s+-R\s+777\b",
|
|
166
|
+
r"\b(chown|takeown)\b",
|
|
167
|
+
r"\b(powershell|pwsh)\b.*-enc(odedcommand)?\b",
|
|
168
|
+
r"\bcurl\b.*\|\s*(sh|bash|powershell|pwsh)\b",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
SYSTEM_PROMPT = """Eres LMCoding Ultra, un agente de programación local avanzado y autocorrectivo.
|
|
172
|
+
Tu trabajo es completar tareas de ingeniería de software de principio a fin dentro del proyecto.
|
|
173
|
+
|
|
174
|
+
Principios obligatorios:
|
|
175
|
+
1. Inspecciona el proyecto antes de modificarlo. Lee AGENTS.md y reglas locales si existen.
|
|
176
|
+
2. Usa herramientas para comprobar hechos. No inventes archivos, salidas, pruebas ni resultados.
|
|
177
|
+
3. Prefiere cambios pequeños y precisos. Para editar, usa apply_patch cuando sea posible.
|
|
178
|
+
4. Conserva el estilo, arquitectura y convenciones existentes del repositorio.
|
|
179
|
+
5. Después de cualquier modificación, verifica con diagnose_project, pruebas, lint o build apropiado.
|
|
180
|
+
6. Si una verificación falla, analiza la causa raíz, corrige y vuelve a verificar; no ocultes el fallo.
|
|
181
|
+
7. No destruyas trabajo del usuario. No reviertas cambios ajenos ni uses comandos destructivos sin necesidad.
|
|
182
|
+
8. Mantén un plan breve para tareas complejas y actualízalo mientras avanzas.
|
|
183
|
+
9. Si una acción está bloqueada por permisos, explica la razón o solicita aprobación mediante la herramienta.
|
|
184
|
+
10. No repitas indefinidamente la misma herramienta o el mismo cambio; cambia de estrategia.
|
|
185
|
+
11. Al terminar, informa: resultado, archivos cambiados, verificaciones ejecutadas y riesgos pendientes.
|
|
186
|
+
12. No reveles razonamiento privado paso a paso. Comunica decisiones, hallazgos y resultados útiles.
|
|
187
|
+
13. Responde en el idioma del usuario.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
AUTO_REPAIR_PROMPT = """VERIFICACIÓN AUTOMÁTICA FALLIDA.
|
|
191
|
+
No des por terminada la tarea. Examina la salida real incluida abajo, identifica la causa raíz,
|
|
192
|
+
inspecciona los archivos relevantes, aplica una corrección mínima y vuelve a verificar.
|
|
193
|
+
No elimines pruebas ni desactives validaciones para hacerlas pasar. Si el fallo parece anterior
|
|
194
|
+
a tus cambios, demuéstralo con evidencia y evita empeorar el proyecto.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
PLAN_MODE_PROMPT = """
|
|
198
|
+
MODO PLAN ACTIVO:
|
|
199
|
+
- Solo inspecciona y analiza.
|
|
200
|
+
- No modifiques archivos, no elimines rutas y no ejecutes comandos que alteren el proyecto.
|
|
201
|
+
- Entrega un plan concreto con archivos, pasos, pruebas y riesgos.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
REVIEW_PROMPT = """Revisa el árbol de trabajo actual como revisor de código senior.
|
|
205
|
+
Busca errores funcionales, regresiones, riesgos de seguridad, problemas de compatibilidad,
|
|
206
|
+
falta de pruebas y cambios accidentales. Prioriza hallazgos por severidad y cita archivo y línea.
|
|
207
|
+
Si no encuentras problemas, dilo claramente y menciona riesgos de prueba restantes.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def ensure_home() -> None:
|
|
212
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def now_iso() -> str:
|
|
217
|
+
return dt.datetime.now(dt.timezone.utc).isoformat()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def truncate(text: str, limit: int = MAX_TOOL_OUTPUT) -> str:
|
|
221
|
+
if len(text) <= limit:
|
|
222
|
+
return text
|
|
223
|
+
omitted = len(text) - limit
|
|
224
|
+
return text[:limit] + f"\n\n...[salida recortada: {omitted} caracteres]"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def sha256_text(text: str) -> str:
|
|
228
|
+
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
232
|
+
result = dict(base)
|
|
233
|
+
for key, value in override.items():
|
|
234
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
235
|
+
result[key] = deep_merge(result[key], value)
|
|
236
|
+
else:
|
|
237
|
+
result[key] = value
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_toml(path: Path) -> dict[str, Any]:
|
|
242
|
+
if not path.exists():
|
|
243
|
+
return {}
|
|
244
|
+
try:
|
|
245
|
+
with path.open("rb") as handle:
|
|
246
|
+
data = tomllib.load(handle)
|
|
247
|
+
return data if isinstance(data, dict) else {}
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
console.print(f"[yellow]No se pudo leer {path}: {exc}[/yellow]")
|
|
250
|
+
return {}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclasses.dataclass
|
|
254
|
+
class AppConfig:
|
|
255
|
+
base_url: str = DEFAULT_BASE_URL
|
|
256
|
+
api_key: str = "lm-studio"
|
|
257
|
+
model: str | None = None
|
|
258
|
+
permission_mode: PermissionMode = "workspace-write"
|
|
259
|
+
approval_policy: ApprovalPolicy = "on-request"
|
|
260
|
+
max_steps: int = DEFAULT_MAX_STEPS
|
|
261
|
+
max_context_chars: int = DEFAULT_CONTEXT_CHARS
|
|
262
|
+
temperature: float = 0.15
|
|
263
|
+
command_timeout: int = 180
|
|
264
|
+
auto_compact: bool = True
|
|
265
|
+
save_sessions: bool = True
|
|
266
|
+
show_tool_output: bool = True
|
|
267
|
+
self_heal: bool = True
|
|
268
|
+
verification_mode: VerificationMode = "quick"
|
|
269
|
+
max_fix_attempts: int = 3
|
|
270
|
+
rollback_on_failure: bool = True
|
|
271
|
+
api_retries: int = 4
|
|
272
|
+
retry_backoff: float = 1.0
|
|
273
|
+
max_repeat_tool_calls: int = 3
|
|
274
|
+
extra_writable_roots: list[str] = dataclasses.field(default_factory=list)
|
|
275
|
+
trusted_commands: list[str] = dataclasses.field(default_factory=list)
|
|
276
|
+
denied_commands: list[str] = dataclasses.field(default_factory=list)
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def from_layers(
|
|
280
|
+
cls,
|
|
281
|
+
workspace: Path,
|
|
282
|
+
cli_values: dict[str, Any] | None = None,
|
|
283
|
+
) -> "AppConfig":
|
|
284
|
+
defaults = dataclasses.asdict(cls())
|
|
285
|
+
global_data = load_toml(GLOBAL_CONFIG_FILE)
|
|
286
|
+
project_data = load_toml(workspace / ".lmcoding" / "config.toml")
|
|
287
|
+
merged = deep_merge(defaults, global_data)
|
|
288
|
+
merged = deep_merge(merged, project_data)
|
|
289
|
+
if cli_values:
|
|
290
|
+
merged = deep_merge(merged, {k: v for k, v in cli_values.items() if v is not None})
|
|
291
|
+
|
|
292
|
+
valid_fields = {field.name for field in dataclasses.fields(cls)}
|
|
293
|
+
filtered = {key: value for key, value in merged.items() if key in valid_fields}
|
|
294
|
+
config = cls(**filtered)
|
|
295
|
+
config.max_steps = max(1, min(int(config.max_steps), 128))
|
|
296
|
+
config.max_context_chars = max(20_000, int(config.max_context_chars))
|
|
297
|
+
config.command_timeout = max(1, min(int(config.command_timeout), 3_600))
|
|
298
|
+
config.max_fix_attempts = max(0, min(int(config.max_fix_attempts), 10))
|
|
299
|
+
config.api_retries = max(1, min(int(config.api_retries), 10))
|
|
300
|
+
config.retry_backoff = max(0.1, min(float(config.retry_backoff), 10.0))
|
|
301
|
+
config.max_repeat_tool_calls = max(1, min(int(config.max_repeat_tool_calls), 10))
|
|
302
|
+
if config.verification_mode not in {"off", "quick", "full"}:
|
|
303
|
+
config.verification_mode = "quick"
|
|
304
|
+
if config.permission_mode not in {"read-only", "workspace-write", "full-access"}:
|
|
305
|
+
config.permission_mode = "workspace-write"
|
|
306
|
+
if config.approval_policy not in {"untrusted", "on-request", "never"}:
|
|
307
|
+
config.approval_policy = "on-request"
|
|
308
|
+
return config
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclasses.dataclass
|
|
312
|
+
class PlanItem:
|
|
313
|
+
step: str
|
|
314
|
+
status: Literal["pending", "in_progress", "completed"] = "pending"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclasses.dataclass
|
|
318
|
+
class SessionState:
|
|
319
|
+
id: str
|
|
320
|
+
name: str
|
|
321
|
+
workspace: str
|
|
322
|
+
model: str | None
|
|
323
|
+
created_at: str
|
|
324
|
+
updated_at: str
|
|
325
|
+
messages: list[dict[str, Any]]
|
|
326
|
+
plan: list[PlanItem] = dataclasses.field(default_factory=list)
|
|
327
|
+
plan_mode: bool = False
|
|
328
|
+
total_prompt_tokens: int = 0
|
|
329
|
+
total_completion_tokens: int = 0
|
|
330
|
+
last_checkpoint: str | None = None
|
|
331
|
+
last_verification: str | None = None
|
|
332
|
+
repair_cycles: int = 0
|
|
333
|
+
|
|
334
|
+
@classmethod
|
|
335
|
+
def create(cls, workspace: Path, model: str | None = None) -> "SessionState":
|
|
336
|
+
session_id = str(uuid.uuid4())
|
|
337
|
+
return cls(
|
|
338
|
+
id=session_id,
|
|
339
|
+
name=f"session-{session_id[:8]}",
|
|
340
|
+
workspace=str(workspace),
|
|
341
|
+
model=model,
|
|
342
|
+
created_at=now_iso(),
|
|
343
|
+
updated_at=now_iso(),
|
|
344
|
+
messages=[],
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def to_dict(self) -> dict[str, Any]:
|
|
348
|
+
data = dataclasses.asdict(self)
|
|
349
|
+
return data
|
|
350
|
+
|
|
351
|
+
@classmethod
|
|
352
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionState":
|
|
353
|
+
plan = [PlanItem(**item) for item in data.get("plan", [])]
|
|
354
|
+
return cls(
|
|
355
|
+
id=data["id"],
|
|
356
|
+
name=data.get("name", f"session-{data['id'][:8]}"),
|
|
357
|
+
workspace=data["workspace"],
|
|
358
|
+
model=data.get("model"),
|
|
359
|
+
created_at=data.get("created_at", now_iso()),
|
|
360
|
+
updated_at=data.get("updated_at", now_iso()),
|
|
361
|
+
messages=data.get("messages", []),
|
|
362
|
+
plan=plan,
|
|
363
|
+
plan_mode=bool(data.get("plan_mode", False)),
|
|
364
|
+
total_prompt_tokens=int(data.get("total_prompt_tokens", 0)),
|
|
365
|
+
total_completion_tokens=int(data.get("total_completion_tokens", 0)),
|
|
366
|
+
last_checkpoint=data.get("last_checkpoint"),
|
|
367
|
+
last_verification=data.get("last_verification"),
|
|
368
|
+
repair_cycles=int(data.get("repair_cycles", 0)),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class SessionStore:
|
|
373
|
+
def __init__(self) -> None:
|
|
374
|
+
ensure_home()
|
|
375
|
+
|
|
376
|
+
def path_for(self, session_id: str) -> Path:
|
|
377
|
+
return SESSIONS_DIR / f"{session_id}.json"
|
|
378
|
+
|
|
379
|
+
def save(self, state: SessionState) -> None:
|
|
380
|
+
state.updated_at = now_iso()
|
|
381
|
+
path = self.path_for(state.id)
|
|
382
|
+
temp = path.with_suffix(".tmp")
|
|
383
|
+
temp.write_text(json.dumps(state.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
384
|
+
temp.replace(path)
|
|
385
|
+
|
|
386
|
+
def load(self, session_id: str) -> SessionState:
|
|
387
|
+
exact = self.path_for(session_id)
|
|
388
|
+
if exact.exists():
|
|
389
|
+
return SessionState.from_dict(json.loads(exact.read_text(encoding="utf-8")))
|
|
390
|
+
|
|
391
|
+
matches = list(SESSIONS_DIR.glob(f"{session_id}*.json"))
|
|
392
|
+
if len(matches) == 1:
|
|
393
|
+
return SessionState.from_dict(json.loads(matches[0].read_text(encoding="utf-8")))
|
|
394
|
+
if not matches:
|
|
395
|
+
for item in self.list_states():
|
|
396
|
+
if item.name == session_id:
|
|
397
|
+
return item
|
|
398
|
+
raise FileNotFoundError(f"Sesión no encontrada: {session_id}")
|
|
399
|
+
raise ValueError(f"ID de sesión ambiguo: {session_id}")
|
|
400
|
+
|
|
401
|
+
def list_states(self, workspace: Path | None = None) -> list[SessionState]:
|
|
402
|
+
states: list[SessionState] = []
|
|
403
|
+
for path in SESSIONS_DIR.glob("*.json"):
|
|
404
|
+
try:
|
|
405
|
+
state = SessionState.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
406
|
+
except Exception:
|
|
407
|
+
continue
|
|
408
|
+
if workspace is None or Path(state.workspace).resolve() == workspace.resolve():
|
|
409
|
+
states.append(state)
|
|
410
|
+
states.sort(key=lambda item: item.updated_at, reverse=True)
|
|
411
|
+
return states
|
|
412
|
+
|
|
413
|
+
def latest(self, workspace: Path | None = None) -> SessionState:
|
|
414
|
+
states = self.list_states(workspace)
|
|
415
|
+
if not states:
|
|
416
|
+
raise FileNotFoundError("No hay sesiones guardadas")
|
|
417
|
+
return states[0]
|
|
418
|
+
|
|
419
|
+
def delete(self, session_id: str) -> None:
|
|
420
|
+
state = self.load(session_id)
|
|
421
|
+
self.path_for(state.id).unlink(missing_ok=True)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class ApprovalManager:
|
|
425
|
+
def __init__(self, config: AppConfig) -> None:
|
|
426
|
+
self.config = config
|
|
427
|
+
|
|
428
|
+
def classify_command(self, command: str) -> tuple[str, list[str]]:
|
|
429
|
+
normalized = " ".join(command.strip().split())
|
|
430
|
+
reasons: list[str] = []
|
|
431
|
+
|
|
432
|
+
for pattern in self.config.denied_commands:
|
|
433
|
+
if re.search(pattern, normalized, flags=re.IGNORECASE):
|
|
434
|
+
return "deny", [f"coincide con regla denegada: {pattern}"]
|
|
435
|
+
|
|
436
|
+
if any(re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in DANGEROUS_COMMAND_PATTERNS):
|
|
437
|
+
reasons.append("comando potencialmente destructivo")
|
|
438
|
+
return "dangerous", reasons
|
|
439
|
+
|
|
440
|
+
if any(re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in NETWORK_COMMAND_PATTERNS):
|
|
441
|
+
reasons.append("puede usar la red o instalar dependencias")
|
|
442
|
+
return "network", reasons
|
|
443
|
+
|
|
444
|
+
prefixes = tuple(SAFE_COMMAND_PREFIXES) + tuple(self.config.trusted_commands)
|
|
445
|
+
if normalized.lower().startswith(tuple(prefix.lower() for prefix in prefixes)):
|
|
446
|
+
return "trusted", ["comando de inspección reconocido"]
|
|
447
|
+
|
|
448
|
+
if ".." in normalized or re.search(r"(^|\s)(/|[A-Za-z]:\\)", normalized):
|
|
449
|
+
reasons.append("puede acceder fuera del directorio de trabajo")
|
|
450
|
+
return "outside", reasons
|
|
451
|
+
|
|
452
|
+
return "untrusted", ["comando no clasificado como seguro"]
|
|
453
|
+
|
|
454
|
+
def approve_command(self, command: str) -> tuple[bool, str]:
|
|
455
|
+
classification, reasons = self.classify_command(command)
|
|
456
|
+
if classification == "deny":
|
|
457
|
+
return False, "; ".join(reasons)
|
|
458
|
+
|
|
459
|
+
mode = self.config.permission_mode
|
|
460
|
+
policy = self.config.approval_policy
|
|
461
|
+
|
|
462
|
+
if mode == "read-only":
|
|
463
|
+
if classification == "trusted":
|
|
464
|
+
return True, "permitido en lectura"
|
|
465
|
+
return self._prompt(command, classification, reasons)
|
|
466
|
+
|
|
467
|
+
if mode == "full-access" and policy == "never":
|
|
468
|
+
return True, "full-access"
|
|
469
|
+
|
|
470
|
+
if policy == "never":
|
|
471
|
+
return True, "sin confirmaciones"
|
|
472
|
+
|
|
473
|
+
if classification == "trusted":
|
|
474
|
+
return True, "comando confiable"
|
|
475
|
+
|
|
476
|
+
if policy == "untrusted":
|
|
477
|
+
return self._prompt(command, classification, reasons)
|
|
478
|
+
|
|
479
|
+
if classification in {"dangerous", "network", "outside"}:
|
|
480
|
+
return self._prompt(command, classification, reasons)
|
|
481
|
+
|
|
482
|
+
return True, "dentro del workspace"
|
|
483
|
+
|
|
484
|
+
def approve_mutation(self, description: str, outside_workspace: bool = False) -> bool:
|
|
485
|
+
if self.config.permission_mode == "full-access" and self.config.approval_policy == "never":
|
|
486
|
+
return True
|
|
487
|
+
if self.config.permission_mode == "read-only":
|
|
488
|
+
return Confirm.ask(
|
|
489
|
+
f"[yellow]Modo read-only. ¿Permitir una vez?[/yellow]\n{description}",
|
|
490
|
+
default=False,
|
|
491
|
+
)
|
|
492
|
+
if outside_workspace:
|
|
493
|
+
if self.config.permission_mode != "full-access":
|
|
494
|
+
return False
|
|
495
|
+
if self.config.approval_policy != "never":
|
|
496
|
+
return Confirm.ask(f"[red]Acción fuera del workspace[/red]\n{description}", default=False)
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _prompt(command: str, classification: str, reasons: list[str]) -> tuple[bool, str]:
|
|
501
|
+
details = ", ".join(reasons)
|
|
502
|
+
approved = Confirm.ask(
|
|
503
|
+
f"[yellow]Autorizar comando ({classification})?[/yellow]\n"
|
|
504
|
+
f"[bold]{command}[/bold]\n[dim]{details}[/dim]",
|
|
505
|
+
default=False,
|
|
506
|
+
)
|
|
507
|
+
return approved, "aprobado por usuario" if approved else "rechazado por usuario"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class Workspace:
|
|
511
|
+
def __init__(self, root: Path, config: AppConfig) -> None:
|
|
512
|
+
self.root = root.expanduser().resolve()
|
|
513
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
514
|
+
self.config = config
|
|
515
|
+
self.approvals = ApprovalManager(config)
|
|
516
|
+
workspace_key = hashlib.sha256(str(self.root).encode("utf-8")).hexdigest()[:20]
|
|
517
|
+
self.metadata_dir = HOME_DIR / "workspaces" / workspace_key
|
|
518
|
+
self.checkpoints_dir = self.metadata_dir / "checkpoints"
|
|
519
|
+
self.checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
self.extra_writable_roots = [Path(item).expanduser().resolve() for item in config.extra_writable_roots]
|
|
521
|
+
|
|
522
|
+
def resolve(self, user_path: str | None = None, *, for_write: bool = False) -> Path:
|
|
523
|
+
raw = user_path or "."
|
|
524
|
+
candidate = Path(raw).expanduser()
|
|
525
|
+
if not candidate.is_absolute():
|
|
526
|
+
candidate = self.root / candidate
|
|
527
|
+
candidate = candidate.resolve()
|
|
528
|
+
|
|
529
|
+
inside_root = self._is_within(candidate, self.root)
|
|
530
|
+
inside_extra = any(self._is_within(candidate, extra) for extra in self.extra_writable_roots)
|
|
531
|
+
if inside_root:
|
|
532
|
+
return candidate
|
|
533
|
+
if for_write and inside_extra:
|
|
534
|
+
return candidate
|
|
535
|
+
if self.config.permission_mode == "full-access":
|
|
536
|
+
return candidate
|
|
537
|
+
raise ValueError(f"Ruta fuera del workspace no permitida: {user_path}")
|
|
538
|
+
|
|
539
|
+
@staticmethod
|
|
540
|
+
def _is_within(candidate: Path, root: Path) -> bool:
|
|
541
|
+
try:
|
|
542
|
+
candidate.relative_to(root)
|
|
543
|
+
return True
|
|
544
|
+
except ValueError:
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
def relative(self, path: Path) -> str:
|
|
548
|
+
try:
|
|
549
|
+
return str(path.relative_to(self.root)) or "."
|
|
550
|
+
except ValueError:
|
|
551
|
+
return str(path)
|
|
552
|
+
|
|
553
|
+
def is_outside(self, path: Path) -> bool:
|
|
554
|
+
return not self._is_within(path, self.root)
|
|
555
|
+
|
|
556
|
+
def checkpoint(self, paths: Iterable[Path], description: str) -> str:
|
|
557
|
+
checkpoint_id = f"{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
|
558
|
+
target_dir = self.checkpoints_dir / checkpoint_id
|
|
559
|
+
files_dir = target_dir / "files"
|
|
560
|
+
files_dir.mkdir(parents=True, exist_ok=True)
|
|
561
|
+
manifest: dict[str, Any] = {
|
|
562
|
+
"id": checkpoint_id,
|
|
563
|
+
"created_at": now_iso(),
|
|
564
|
+
"description": description,
|
|
565
|
+
"entries": [],
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for path in paths:
|
|
569
|
+
path = path.resolve()
|
|
570
|
+
rel = self.relative(path)
|
|
571
|
+
entry: dict[str, Any] = {"path": rel, "absolute": str(path), "existed": path.exists()}
|
|
572
|
+
if path.exists():
|
|
573
|
+
backup_name = hashlib.sha256(str(path).encode()).hexdigest()[:16]
|
|
574
|
+
backup_path = files_dir / backup_name
|
|
575
|
+
if path.is_dir():
|
|
576
|
+
shutil.copytree(path, backup_path)
|
|
577
|
+
entry["kind"] = "dir"
|
|
578
|
+
else:
|
|
579
|
+
shutil.copy2(path, backup_path)
|
|
580
|
+
entry["kind"] = "file"
|
|
581
|
+
entry["backup"] = str(backup_path.relative_to(target_dir))
|
|
582
|
+
manifest["entries"].append(entry)
|
|
583
|
+
|
|
584
|
+
(target_dir / "manifest.json").write_text(
|
|
585
|
+
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
586
|
+
)
|
|
587
|
+
return checkpoint_id
|
|
588
|
+
|
|
589
|
+
def undo(self, checkpoint_id: str | None = None) -> str:
|
|
590
|
+
if checkpoint_id is None:
|
|
591
|
+
candidates = sorted(
|
|
592
|
+
[item for item in self.checkpoints_dir.iterdir() if item.is_dir()],
|
|
593
|
+
key=lambda item: item.name,
|
|
594
|
+
reverse=True,
|
|
595
|
+
)
|
|
596
|
+
if not candidates:
|
|
597
|
+
return "ERROR: no hay checkpoints para deshacer"
|
|
598
|
+
checkpoint_dir = candidates[0]
|
|
599
|
+
else:
|
|
600
|
+
checkpoint_dir = self.checkpoints_dir / checkpoint_id
|
|
601
|
+
manifest_path = checkpoint_dir / "manifest.json"
|
|
602
|
+
if not manifest_path.exists():
|
|
603
|
+
return f"ERROR: checkpoint no encontrado: {checkpoint_id}"
|
|
604
|
+
|
|
605
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
606
|
+
restored: list[str] = []
|
|
607
|
+
for entry in reversed(manifest.get("entries", [])):
|
|
608
|
+
target = Path(entry["absolute"])
|
|
609
|
+
if target.exists():
|
|
610
|
+
if target.is_dir():
|
|
611
|
+
shutil.rmtree(target)
|
|
612
|
+
else:
|
|
613
|
+
target.unlink()
|
|
614
|
+
if entry.get("existed"):
|
|
615
|
+
backup = checkpoint_dir / entry["backup"]
|
|
616
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
if entry.get("kind") == "dir":
|
|
618
|
+
shutil.copytree(backup, target)
|
|
619
|
+
else:
|
|
620
|
+
shutil.copy2(backup, target)
|
|
621
|
+
restored.append(entry["path"])
|
|
622
|
+
checkpoint_name = manifest["id"]
|
|
623
|
+
shutil.rmtree(checkpoint_dir, ignore_errors=True)
|
|
624
|
+
return f"Checkpoint restaurado: {checkpoint_name}\nRutas: " + ", ".join(restored)
|
|
625
|
+
|
|
626
|
+
def git_root(self) -> Path | None:
|
|
627
|
+
try:
|
|
628
|
+
result = subprocess.run(
|
|
629
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
630
|
+
cwd=self.root,
|
|
631
|
+
text=True,
|
|
632
|
+
stdout=subprocess.PIPE,
|
|
633
|
+
stderr=subprocess.DEVNULL,
|
|
634
|
+
timeout=5,
|
|
635
|
+
)
|
|
636
|
+
except (OSError, subprocess.SubprocessError):
|
|
637
|
+
return None
|
|
638
|
+
if result.returncode != 0:
|
|
639
|
+
return None
|
|
640
|
+
return Path(result.stdout.strip()).resolve()
|
|
641
|
+
|
|
642
|
+
def git_branch(self) -> str:
|
|
643
|
+
try:
|
|
644
|
+
result = subprocess.run(
|
|
645
|
+
["git", "branch", "--show-current"],
|
|
646
|
+
cwd=self.root,
|
|
647
|
+
text=True,
|
|
648
|
+
stdout=subprocess.PIPE,
|
|
649
|
+
stderr=subprocess.DEVNULL,
|
|
650
|
+
timeout=3,
|
|
651
|
+
)
|
|
652
|
+
return result.stdout.strip() or "detached"
|
|
653
|
+
except Exception:
|
|
654
|
+
return "no-git"
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class PatchError(RuntimeError):
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class PatchEngine:
|
|
662
|
+
"""Aplica el formato *** Begin Patch usado por agentes de código."""
|
|
663
|
+
|
|
664
|
+
def __init__(self, workspace: Workspace) -> None:
|
|
665
|
+
self.workspace = workspace
|
|
666
|
+
|
|
667
|
+
def apply(self, patch: str) -> tuple[list[str], str]:
|
|
668
|
+
lines = patch.splitlines()
|
|
669
|
+
if not lines or lines[0].strip() != "*** Begin Patch":
|
|
670
|
+
raise PatchError("el parche debe comenzar con '*** Begin Patch'")
|
|
671
|
+
if lines[-1].strip() != "*** End Patch":
|
|
672
|
+
raise PatchError("el parche debe terminar con '*** End Patch'")
|
|
673
|
+
|
|
674
|
+
index = 1
|
|
675
|
+
operations: list[tuple[str, str, list[str]]] = []
|
|
676
|
+
while index < len(lines) - 1:
|
|
677
|
+
header = lines[index]
|
|
678
|
+
match = re.match(r"\*\*\* (Add|Update|Delete) File: (.+)$", header)
|
|
679
|
+
if not match:
|
|
680
|
+
if not header.strip():
|
|
681
|
+
index += 1
|
|
682
|
+
continue
|
|
683
|
+
raise PatchError(f"encabezado de operación inválido: {header}")
|
|
684
|
+
operation = match.group(1).lower()
|
|
685
|
+
path = match.group(2).strip()
|
|
686
|
+
index += 1
|
|
687
|
+
body: list[str] = []
|
|
688
|
+
while index < len(lines) - 1 and not lines[index].startswith("*** "):
|
|
689
|
+
body.append(lines[index])
|
|
690
|
+
index += 1
|
|
691
|
+
operations.append((operation, path, body))
|
|
692
|
+
|
|
693
|
+
targets = [self.workspace.resolve(path, for_write=True) for _, path, _ in operations]
|
|
694
|
+
checkpoint = self.workspace.checkpoint(targets, "apply_patch")
|
|
695
|
+
changed: list[str] = []
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
for operation, path, body in operations:
|
|
699
|
+
target = self.workspace.resolve(path, for_write=True)
|
|
700
|
+
if operation == "add":
|
|
701
|
+
if target.exists():
|
|
702
|
+
raise PatchError(f"el archivo ya existe: {path}")
|
|
703
|
+
content_lines = [line[1:] if line.startswith("+") else line for line in body]
|
|
704
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
705
|
+
target.write_text("\n".join(content_lines) + ("\n" if body else ""), encoding="utf-8")
|
|
706
|
+
elif operation == "delete":
|
|
707
|
+
if not target.exists():
|
|
708
|
+
raise PatchError(f"no existe: {path}")
|
|
709
|
+
if target.is_dir():
|
|
710
|
+
shutil.rmtree(target)
|
|
711
|
+
else:
|
|
712
|
+
target.unlink()
|
|
713
|
+
else:
|
|
714
|
+
self._update_file(target, path, body)
|
|
715
|
+
changed.append(path)
|
|
716
|
+
except Exception:
|
|
717
|
+
self.workspace.undo(checkpoint)
|
|
718
|
+
raise
|
|
719
|
+
|
|
720
|
+
return changed, checkpoint
|
|
721
|
+
|
|
722
|
+
def _update_file(self, target: Path, path: str, body: list[str]) -> None:
|
|
723
|
+
if not target.exists() or not target.is_file():
|
|
724
|
+
raise PatchError(f"archivo no encontrado: {path}")
|
|
725
|
+
original = target.read_text(encoding="utf-8")
|
|
726
|
+
original_lines = original.splitlines()
|
|
727
|
+
had_final_newline = original.endswith("\n")
|
|
728
|
+
|
|
729
|
+
hunks: list[list[str]] = []
|
|
730
|
+
current: list[str] = []
|
|
731
|
+
for line in body:
|
|
732
|
+
if line.startswith("@@"):
|
|
733
|
+
if current:
|
|
734
|
+
hunks.append(current)
|
|
735
|
+
current = []
|
|
736
|
+
else:
|
|
737
|
+
current.append(line)
|
|
738
|
+
if current:
|
|
739
|
+
hunks.append(current)
|
|
740
|
+
if not hunks:
|
|
741
|
+
raise PatchError(f"sin bloques de cambio para {path}")
|
|
742
|
+
|
|
743
|
+
working = original_lines[:]
|
|
744
|
+
search_start = 0
|
|
745
|
+
for hunk in hunks:
|
|
746
|
+
old: list[str] = []
|
|
747
|
+
new: list[str] = []
|
|
748
|
+
for line in hunk:
|
|
749
|
+
if line.startswith("-"):
|
|
750
|
+
old.append(line[1:])
|
|
751
|
+
elif line.startswith("+"):
|
|
752
|
+
new.append(line[1:])
|
|
753
|
+
elif line.startswith(" "):
|
|
754
|
+
value = line[1:]
|
|
755
|
+
old.append(value)
|
|
756
|
+
new.append(value)
|
|
757
|
+
elif line == "\":
|
|
758
|
+
continue
|
|
759
|
+
else:
|
|
760
|
+
old.append(line)
|
|
761
|
+
new.append(line)
|
|
762
|
+
|
|
763
|
+
position = self._find_sequence(working, old, search_start)
|
|
764
|
+
if position is None:
|
|
765
|
+
position = self._find_sequence(working, old, 0)
|
|
766
|
+
if position is None:
|
|
767
|
+
preview = "\n".join(old[:8])
|
|
768
|
+
raise PatchError(f"no se encontró el contexto en {path}:\n{preview}")
|
|
769
|
+
working[position : position + len(old)] = new
|
|
770
|
+
search_start = position + len(new)
|
|
771
|
+
|
|
772
|
+
new_content = "\n".join(working)
|
|
773
|
+
if had_final_newline:
|
|
774
|
+
new_content += "\n"
|
|
775
|
+
target.write_text(new_content, encoding="utf-8")
|
|
776
|
+
|
|
777
|
+
@staticmethod
|
|
778
|
+
def _find_sequence(haystack: list[str], needle: list[str], start: int) -> int | None:
|
|
779
|
+
if not needle:
|
|
780
|
+
return start
|
|
781
|
+
limit = len(haystack) - len(needle) + 1
|
|
782
|
+
for index in range(max(0, start), max(0, limit)):
|
|
783
|
+
if haystack[index : index + len(needle)] == needle:
|
|
784
|
+
return index
|
|
785
|
+
return None
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@dataclasses.dataclass
|
|
789
|
+
class CommandResult:
|
|
790
|
+
command: str
|
|
791
|
+
cwd: str
|
|
792
|
+
returncode: int
|
|
793
|
+
duration: float
|
|
794
|
+
output: str
|
|
795
|
+
timed_out: bool = False
|
|
796
|
+
blocked: bool = False
|
|
797
|
+
policy: str = ""
|
|
798
|
+
|
|
799
|
+
@property
|
|
800
|
+
def success(self) -> bool:
|
|
801
|
+
return self.returncode == 0 and not self.timed_out and not self.blocked
|
|
802
|
+
|
|
803
|
+
def to_text(self) -> str:
|
|
804
|
+
state = "OK" if self.success else "FALLO"
|
|
805
|
+
extras: list[str] = []
|
|
806
|
+
if self.timed_out:
|
|
807
|
+
extras.append("timeout")
|
|
808
|
+
if self.blocked:
|
|
809
|
+
extras.append("bloqueado")
|
|
810
|
+
suffix = f" ({', '.join(extras)})" if extras else ""
|
|
811
|
+
return (
|
|
812
|
+
f"[{state}{suffix}] {self.command}\n"
|
|
813
|
+
f"Política: {self.policy or '-'}\n"
|
|
814
|
+
f"Código de salida: {self.returncode}\n"
|
|
815
|
+
f"Duración: {self.duration:.2f}s\n"
|
|
816
|
+
f"CWD: {self.cwd}\nSalida:\n{self.output or '(sin salida)'}"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@dataclasses.dataclass(frozen=True)
|
|
821
|
+
class DiagnosticCommand:
|
|
822
|
+
name: str
|
|
823
|
+
command: str
|
|
824
|
+
timeout: int = 180
|
|
825
|
+
cwd: str = "."
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
@dataclasses.dataclass
|
|
829
|
+
class DiagnosticReport:
|
|
830
|
+
mode: VerificationMode
|
|
831
|
+
detected: list[str]
|
|
832
|
+
results: list[CommandResult]
|
|
833
|
+
skipped_reason: str | None = None
|
|
834
|
+
|
|
835
|
+
@property
|
|
836
|
+
def success(self) -> bool:
|
|
837
|
+
return bool(self.results) and all(item.success for item in self.results)
|
|
838
|
+
|
|
839
|
+
@property
|
|
840
|
+
def blocked(self) -> bool:
|
|
841
|
+
return any(item.blocked for item in self.results)
|
|
842
|
+
|
|
843
|
+
@property
|
|
844
|
+
def failed(self) -> list[CommandResult]:
|
|
845
|
+
return [item for item in self.results if not item.success]
|
|
846
|
+
|
|
847
|
+
def to_text(self, limit: int = 24_000) -> str:
|
|
848
|
+
if not self.results:
|
|
849
|
+
return f"VERIFICACIÓN OMITIDA: {self.skipped_reason or 'no se detectaron comprobaciones'}"
|
|
850
|
+
heading = (
|
|
851
|
+
f"VERIFICACIÓN {'CORRECTA' if self.success else 'FALLIDA'} · "
|
|
852
|
+
f"modo={self.mode} · tipos={', '.join(self.detected) or 'genérico'}"
|
|
853
|
+
)
|
|
854
|
+
body = "\n\n".join(item.to_text() for item in self.results)
|
|
855
|
+
return truncate(heading + "\n\n" + body, limit)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class ProjectDoctor:
|
|
859
|
+
"""Detecta el tipo de proyecto y propone verificaciones locales sin instalar nada."""
|
|
860
|
+
|
|
861
|
+
def __init__(self, workspace: Workspace, config: AppConfig) -> None:
|
|
862
|
+
self.workspace = workspace
|
|
863
|
+
self.config = config
|
|
864
|
+
|
|
865
|
+
def detect_commands(self, mode: VerificationMode = "quick") -> tuple[list[str], list[DiagnosticCommand]]:
|
|
866
|
+
if mode == "off":
|
|
867
|
+
return [], []
|
|
868
|
+
root = self.workspace.root
|
|
869
|
+
detected: list[str] = []
|
|
870
|
+
commands: list[DiagnosticCommand] = []
|
|
871
|
+
|
|
872
|
+
def add(kind: str, name: str, command: str, timeout: int = 180, cwd: str = ".") -> None:
|
|
873
|
+
if kind not in detected:
|
|
874
|
+
detected.append(kind)
|
|
875
|
+
if command and all(existing.command != command for existing in commands):
|
|
876
|
+
commands.append(DiagnosticCommand(name=name, command=command, timeout=timeout, cwd=cwd))
|
|
877
|
+
|
|
878
|
+
def has_source(suffix: str, limit: int = 3000) -> bool:
|
|
879
|
+
seen = 0
|
|
880
|
+
for current_root, dirs, files in os.walk(root):
|
|
881
|
+
dirs[:] = [d for d in dirs if d not in DEFAULT_IGNORES]
|
|
882
|
+
for filename in files:
|
|
883
|
+
seen += 1
|
|
884
|
+
if filename.endswith(suffix):
|
|
885
|
+
return True
|
|
886
|
+
if seen >= limit:
|
|
887
|
+
return False
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
python_cmd = f'"{sys.executable}"' if os.name == "nt" else shlex.quote(sys.executable)
|
|
891
|
+
is_python = any((root / name).exists() for name in ("pyproject.toml", "setup.py", "requirements.txt")) or has_source(".py")
|
|
892
|
+
if is_python:
|
|
893
|
+
add("python", "compilación de sintaxis", f"{python_cmd} -m compileall -q .", 180)
|
|
894
|
+
ruff_configured = any((root / name).exists() for name in ("ruff.toml", ".ruff.toml"))
|
|
895
|
+
if not ruff_configured and (root / "pyproject.toml").exists():
|
|
896
|
+
try:
|
|
897
|
+
ruff_configured = "[tool.ruff" in (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
898
|
+
except Exception:
|
|
899
|
+
ruff_configured = False
|
|
900
|
+
if ruff_configured and shutil.which("ruff"):
|
|
901
|
+
add("python", "ruff", "ruff check .", 180)
|
|
902
|
+
has_tests = any((root / name).exists() for name in ("tests", "test", "pytest.ini", "tox.ini"))
|
|
903
|
+
pytest_available = importlib.util.find_spec("pytest") is not None or shutil.which("pytest") is not None
|
|
904
|
+
if mode == "full" and has_tests and pytest_available:
|
|
905
|
+
add("python", "pytest", f"{python_cmd} -m pytest -q", 600)
|
|
906
|
+
|
|
907
|
+
package_json = root / "package.json"
|
|
908
|
+
if package_json.exists():
|
|
909
|
+
scripts: dict[str, Any] = {}
|
|
910
|
+
try:
|
|
911
|
+
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
912
|
+
scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
|
|
913
|
+
except Exception:
|
|
914
|
+
scripts = {}
|
|
915
|
+
manager = "npm"
|
|
916
|
+
if (root / "pnpm-lock.yaml").exists() and shutil.which("pnpm"):
|
|
917
|
+
manager = "pnpm"
|
|
918
|
+
elif (root / "yarn.lock").exists() and shutil.which("yarn"):
|
|
919
|
+
manager = "yarn"
|
|
920
|
+
elif (root / "bun.lockb").exists() and shutil.which("bun"):
|
|
921
|
+
manager = "bun"
|
|
922
|
+
run = f"{manager} run"
|
|
923
|
+
if "lint" in scripts:
|
|
924
|
+
add("javascript", "lint", f"{run} lint", 300)
|
|
925
|
+
local_tsc = root / "node_modules" / ".bin" / ("tsc.cmd" if os.name == "nt" else "tsc")
|
|
926
|
+
if (root / "tsconfig.json").exists() and local_tsc.exists():
|
|
927
|
+
tsc = f'"{local_tsc}"' if os.name == "nt" else shlex.quote(str(local_tsc))
|
|
928
|
+
add("typescript", "typecheck", f"{tsc} --noEmit", 300)
|
|
929
|
+
if mode == "full":
|
|
930
|
+
if "test" in scripts:
|
|
931
|
+
add("javascript", "tests", f"{run} test", 600)
|
|
932
|
+
if "build" in scripts:
|
|
933
|
+
add("javascript", "build", f"{run} build", 600)
|
|
934
|
+
elif "check" in scripts:
|
|
935
|
+
add("javascript", "check", f"{run} check", 300)
|
|
936
|
+
|
|
937
|
+
if (root / "Cargo.toml").exists() and shutil.which("cargo"):
|
|
938
|
+
add("rust", "cargo check", "cargo check", 600)
|
|
939
|
+
if mode == "full":
|
|
940
|
+
add("rust", "cargo test", "cargo test", 900)
|
|
941
|
+
|
|
942
|
+
if (root / "go.mod").exists() and shutil.which("go"):
|
|
943
|
+
add("go", "go test", "go test ./...", 600)
|
|
944
|
+
|
|
945
|
+
gradlew = root / ("gradlew.bat" if os.name == "nt" else "gradlew")
|
|
946
|
+
if gradlew.exists():
|
|
947
|
+
wrapper = "gradlew.bat" if os.name == "nt" else "./gradlew"
|
|
948
|
+
add("gradle", "gradle check", f"{wrapper} check", 900)
|
|
949
|
+
if mode == "full":
|
|
950
|
+
add("gradle", "gradle test", f"{wrapper} test", 900)
|
|
951
|
+
elif any((root / name).exists() for name in ("build.gradle", "build.gradle.kts")) and shutil.which("gradle"):
|
|
952
|
+
add("gradle", "gradle check", "gradle check", 900)
|
|
953
|
+
|
|
954
|
+
mvnw = root / ("mvnw.cmd" if os.name == "nt" else "mvnw")
|
|
955
|
+
if mvnw.exists():
|
|
956
|
+
wrapper = "mvnw.cmd" if os.name == "nt" else "./mvnw"
|
|
957
|
+
goal = "test" if mode == "full" else "-DskipTests compile"
|
|
958
|
+
add("maven", "maven", f"{wrapper} {goal}", 900)
|
|
959
|
+
elif (root / "pom.xml").exists() and shutil.which("mvn"):
|
|
960
|
+
goal = "test" if mode == "full" else "-DskipTests compile"
|
|
961
|
+
add("maven", "maven", f"mvn {goal}", 900)
|
|
962
|
+
|
|
963
|
+
if any(root.glob("*.sln")) or any(root.glob("*.csproj")):
|
|
964
|
+
if shutil.which("dotnet"):
|
|
965
|
+
add("dotnet", "dotnet build", "dotnet build --nologo", 900)
|
|
966
|
+
if mode == "full":
|
|
967
|
+
add("dotnet", "dotnet test", "dotnet test --nologo --no-build", 900)
|
|
968
|
+
|
|
969
|
+
if (root / "composer.json").exists() and shutil.which("php"):
|
|
970
|
+
add("php", "php syntax", "php -l index.php" if (root / "index.php").exists() else "php --version", 120)
|
|
971
|
+
|
|
972
|
+
return detected, commands
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
class ToolBox:
|
|
976
|
+
def __init__(self, workspace: Workspace, state: SessionState, config: AppConfig) -> None:
|
|
977
|
+
self.workspace = workspace
|
|
978
|
+
self.state = state
|
|
979
|
+
self.config = config
|
|
980
|
+
self.patch_engine = PatchEngine(workspace)
|
|
981
|
+
self.doctor = ProjectDoctor(workspace, config)
|
|
982
|
+
self.mutation_checkpoints: list[str] = []
|
|
983
|
+
self.changed_paths: list[str] = []
|
|
984
|
+
self.last_diagnostic_report: DiagnosticReport | None = None
|
|
985
|
+
self.functions: dict[str, Callable[..., str]] = {
|
|
986
|
+
"list_files": self.list_files,
|
|
987
|
+
"read_file": self.read_file,
|
|
988
|
+
"read_files": self.read_files,
|
|
989
|
+
"search_text": self.search_text,
|
|
990
|
+
"find_files": self.find_files,
|
|
991
|
+
"apply_patch": self.apply_patch,
|
|
992
|
+
"write_file": self.write_file,
|
|
993
|
+
"delete_path": self.delete_path,
|
|
994
|
+
"run_command": self.run_command,
|
|
995
|
+
"git_status": self.git_status,
|
|
996
|
+
"git_diff": self.git_diff,
|
|
997
|
+
"update_plan": self.update_plan,
|
|
998
|
+
"project_context": self.project_context,
|
|
999
|
+
"diagnose_project": self.diagnose_project,
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
@property
|
|
1003
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
1004
|
+
schemas = [
|
|
1005
|
+
self._schema(
|
|
1006
|
+
"list_files",
|
|
1007
|
+
"Lista el árbol de archivos y carpetas del proyecto.",
|
|
1008
|
+
{
|
|
1009
|
+
"path": {"type": "string", "default": "."},
|
|
1010
|
+
"max_depth": {"type": "integer", "default": 4},
|
|
1011
|
+
},
|
|
1012
|
+
),
|
|
1013
|
+
self._schema(
|
|
1014
|
+
"read_file",
|
|
1015
|
+
"Lee un archivo de texto con números de línea.",
|
|
1016
|
+
{
|
|
1017
|
+
"path": {"type": "string"},
|
|
1018
|
+
"start_line": {"type": "integer", "default": 1},
|
|
1019
|
+
"end_line": {"type": "integer", "default": 500},
|
|
1020
|
+
},
|
|
1021
|
+
["path"],
|
|
1022
|
+
),
|
|
1023
|
+
self._schema(
|
|
1024
|
+
"read_files",
|
|
1025
|
+
"Lee varios archivos de texto en una sola llamada.",
|
|
1026
|
+
{"paths": {"type": "array", "items": {"type": "string"}, "maxItems": 20}},
|
|
1027
|
+
["paths"],
|
|
1028
|
+
),
|
|
1029
|
+
self._schema(
|
|
1030
|
+
"search_text",
|
|
1031
|
+
"Busca una expresión regular o texto en los archivos del proyecto.",
|
|
1032
|
+
{
|
|
1033
|
+
"query": {"type": "string"},
|
|
1034
|
+
"path": {"type": "string", "default": "."},
|
|
1035
|
+
"case_sensitive": {"type": "boolean", "default": False},
|
|
1036
|
+
"regex": {"type": "boolean", "default": False},
|
|
1037
|
+
"glob": {"type": "string", "description": "Filtro opcional, por ejemplo *.py"},
|
|
1038
|
+
},
|
|
1039
|
+
["query"],
|
|
1040
|
+
),
|
|
1041
|
+
self._schema(
|
|
1042
|
+
"find_files",
|
|
1043
|
+
"Encuentra archivos por patrón glob, por ejemplo **/*.py.",
|
|
1044
|
+
{
|
|
1045
|
+
"pattern": {"type": "string"},
|
|
1046
|
+
"path": {"type": "string", "default": "."},
|
|
1047
|
+
},
|
|
1048
|
+
["pattern"],
|
|
1049
|
+
),
|
|
1050
|
+
self._schema(
|
|
1051
|
+
"apply_patch",
|
|
1052
|
+
"Aplica cambios atómicos con formato *** Begin Patch / Add|Update|Delete File / *** End Patch.",
|
|
1053
|
+
{"patch": {"type": "string"}},
|
|
1054
|
+
["patch"],
|
|
1055
|
+
),
|
|
1056
|
+
self._schema(
|
|
1057
|
+
"write_file",
|
|
1058
|
+
"Crea o reemplaza por completo un archivo. Prefiere apply_patch para archivos existentes.",
|
|
1059
|
+
{"path": {"type": "string"}, "content": {"type": "string"}},
|
|
1060
|
+
["path", "content"],
|
|
1061
|
+
),
|
|
1062
|
+
self._schema(
|
|
1063
|
+
"delete_path",
|
|
1064
|
+
"Elimina un archivo o carpeta después de aplicar la política de permisos.",
|
|
1065
|
+
{"path": {"type": "string"}},
|
|
1066
|
+
["path"],
|
|
1067
|
+
),
|
|
1068
|
+
self._schema(
|
|
1069
|
+
"run_command",
|
|
1070
|
+
"Ejecuta un comando de terminal. Indica cwd relativo y timeout cuando sea necesario.",
|
|
1071
|
+
{
|
|
1072
|
+
"command": {"type": "string"},
|
|
1073
|
+
"cwd": {"type": "string", "default": "."},
|
|
1074
|
+
"timeout": {"type": "integer", "default": self.config.command_timeout},
|
|
1075
|
+
},
|
|
1076
|
+
["command"],
|
|
1077
|
+
),
|
|
1078
|
+
self._schema("git_status", "Muestra el estado Git corto del proyecto.", {}, []),
|
|
1079
|
+
self._schema(
|
|
1080
|
+
"git_diff",
|
|
1081
|
+
"Muestra el diff Git. Puede incluir cambios staged.",
|
|
1082
|
+
{
|
|
1083
|
+
"staged": {"type": "boolean", "default": False},
|
|
1084
|
+
"path": {"type": "string", "description": "Ruta opcional"},
|
|
1085
|
+
},
|
|
1086
|
+
),
|
|
1087
|
+
self._schema(
|
|
1088
|
+
"update_plan",
|
|
1089
|
+
"Crea o actualiza el plan de trabajo visible al usuario.",
|
|
1090
|
+
{
|
|
1091
|
+
"items": {
|
|
1092
|
+
"type": "array",
|
|
1093
|
+
"items": {
|
|
1094
|
+
"type": "object",
|
|
1095
|
+
"properties": {
|
|
1096
|
+
"step": {"type": "string"},
|
|
1097
|
+
"status": {
|
|
1098
|
+
"type": "string",
|
|
1099
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
"required": ["step", "status"],
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
["items"],
|
|
1107
|
+
),
|
|
1108
|
+
self._schema(
|
|
1109
|
+
"project_context",
|
|
1110
|
+
"Obtiene reglas, manifiestos y resumen Git del proyecto.",
|
|
1111
|
+
{},
|
|
1112
|
+
[],
|
|
1113
|
+
),
|
|
1114
|
+
self._schema(
|
|
1115
|
+
"diagnose_project",
|
|
1116
|
+
"Detecta el tipo de proyecto y ejecuta verificaciones locales. Usa quick durante el trabajo y full antes de finalizar una corrección importante.",
|
|
1117
|
+
{"mode": {"type": "string", "enum": ["quick", "full"], "default": "quick"}},
|
|
1118
|
+
[],
|
|
1119
|
+
),
|
|
1120
|
+
]
|
|
1121
|
+
return schemas
|
|
1122
|
+
|
|
1123
|
+
@staticmethod
|
|
1124
|
+
def _schema(
|
|
1125
|
+
name: str,
|
|
1126
|
+
description: str,
|
|
1127
|
+
properties: dict[str, Any],
|
|
1128
|
+
required: list[str] | None = None,
|
|
1129
|
+
) -> dict[str, Any]:
|
|
1130
|
+
parameters: dict[str, Any] = {"type": "object", "properties": properties}
|
|
1131
|
+
if required:
|
|
1132
|
+
parameters["required"] = required
|
|
1133
|
+
return {
|
|
1134
|
+
"type": "function",
|
|
1135
|
+
"function": {"name": name, "description": description, "parameters": parameters},
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
def execute(self, name: str, arguments: dict[str, Any]) -> str:
|
|
1139
|
+
function = self.functions.get(name)
|
|
1140
|
+
if function is None:
|
|
1141
|
+
return f"ERROR: herramienta desconocida: {name}"
|
|
1142
|
+
if self.state.plan_mode and name in {"apply_patch", "write_file", "delete_path"}:
|
|
1143
|
+
return "BLOQUEADO: el modo plan no permite modificar archivos"
|
|
1144
|
+
try:
|
|
1145
|
+
return truncate(str(function(**arguments)))
|
|
1146
|
+
except TypeError as exc:
|
|
1147
|
+
return f"ERROR de argumentos en {name}: {exc}"
|
|
1148
|
+
except Exception as exc:
|
|
1149
|
+
return f"ERROR en {name}: {type(exc).__name__}: {exc}"
|
|
1150
|
+
|
|
1151
|
+
def _iter_files(self, base: Path) -> Iterable[Path]:
|
|
1152
|
+
if base.is_file():
|
|
1153
|
+
yield base
|
|
1154
|
+
return
|
|
1155
|
+
for current_root, dirs, files in os.walk(base):
|
|
1156
|
+
dirs[:] = sorted(d for d in dirs if d not in DEFAULT_IGNORES)
|
|
1157
|
+
for filename in sorted(files):
|
|
1158
|
+
yield Path(current_root) / filename
|
|
1159
|
+
|
|
1160
|
+
def list_files(self, path: str = ".", max_depth: int = 4) -> str:
|
|
1161
|
+
base = self.workspace.resolve(path)
|
|
1162
|
+
max_depth = max(1, min(int(max_depth), 12))
|
|
1163
|
+
if not base.exists():
|
|
1164
|
+
return f"ERROR: no existe {path}"
|
|
1165
|
+
if base.is_file():
|
|
1166
|
+
return self.workspace.relative(base)
|
|
1167
|
+
|
|
1168
|
+
rows: list[str] = []
|
|
1169
|
+
base_depth = len(base.parts)
|
|
1170
|
+
for current_root, dirs, files in os.walk(base):
|
|
1171
|
+
current = Path(current_root)
|
|
1172
|
+
depth = len(current.parts) - base_depth
|
|
1173
|
+
dirs[:] = sorted(d for d in dirs if d not in DEFAULT_IGNORES and depth < max_depth)
|
|
1174
|
+
if current != base:
|
|
1175
|
+
rows.append(f"{' ' * depth}{current.name}/")
|
|
1176
|
+
file_indent = " " * (depth + (1 if current != base else 0))
|
|
1177
|
+
for filename in sorted(files):
|
|
1178
|
+
rows.append(f"{file_indent}{filename}")
|
|
1179
|
+
if len(rows) >= MAX_LISTED_FILES:
|
|
1180
|
+
rows.append("...[límite de archivos alcanzado]")
|
|
1181
|
+
return "\n".join(rows)
|
|
1182
|
+
return "\n".join(rows) if rows else "(carpeta vacía)"
|
|
1183
|
+
|
|
1184
|
+
def read_file(self, path: str, start_line: int = 1, end_line: int = 500) -> str:
|
|
1185
|
+
target = self.workspace.resolve(path)
|
|
1186
|
+
if not target.exists():
|
|
1187
|
+
return f"ERROR: no existe {path}"
|
|
1188
|
+
if not target.is_file():
|
|
1189
|
+
return f"ERROR: {path} no es un archivo"
|
|
1190
|
+
if target.stat().st_size > 5_000_000:
|
|
1191
|
+
return f"ERROR: archivo demasiado grande ({target.stat().st_size} bytes)"
|
|
1192
|
+
|
|
1193
|
+
start = max(1, int(start_line))
|
|
1194
|
+
end = min(max(start, int(end_line)), start + MAX_FILE_READ_LINES - 1)
|
|
1195
|
+
try:
|
|
1196
|
+
text = target.read_text(encoding="utf-8")
|
|
1197
|
+
except UnicodeDecodeError:
|
|
1198
|
+
return f"ERROR: {path} parece binario o no usa UTF-8"
|
|
1199
|
+
lines = text.splitlines()
|
|
1200
|
+
selected = lines[start - 1 : end]
|
|
1201
|
+
numbered = [f"{number:>5} | {line}" for number, line in enumerate(selected, start=start)]
|
|
1202
|
+
return (
|
|
1203
|
+
f"Archivo: {self.workspace.relative(target)} "
|
|
1204
|
+
f"(líneas {start}-{min(end, len(lines))} de {len(lines)}, sha256={sha256_text(text)[:12]})\n"
|
|
1205
|
+
+ "\n".join(numbered)
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
def read_files(self, paths: list[str]) -> str:
|
|
1209
|
+
if len(paths) > 20:
|
|
1210
|
+
return "ERROR: máximo 20 archivos por llamada"
|
|
1211
|
+
outputs = [self.read_file(path, 1, 500) for path in paths]
|
|
1212
|
+
return "\n\n" + ("\n\n" + "=" * 80 + "\n\n").join(outputs)
|
|
1213
|
+
|
|
1214
|
+
def search_text(
|
|
1215
|
+
self,
|
|
1216
|
+
query: str,
|
|
1217
|
+
path: str = ".",
|
|
1218
|
+
case_sensitive: bool = False,
|
|
1219
|
+
regex: bool = False,
|
|
1220
|
+
glob: str | None = None,
|
|
1221
|
+
) -> str:
|
|
1222
|
+
base = self.workspace.resolve(path)
|
|
1223
|
+
if not base.exists():
|
|
1224
|
+
return f"ERROR: no existe {path}"
|
|
1225
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
1226
|
+
try:
|
|
1227
|
+
pattern = re.compile(query if regex else re.escape(query), flags)
|
|
1228
|
+
except re.error as exc:
|
|
1229
|
+
return f"ERROR: expresión regular inválida: {exc}"
|
|
1230
|
+
|
|
1231
|
+
matches: list[str] = []
|
|
1232
|
+
for candidate in self._iter_files(base):
|
|
1233
|
+
if glob and not fnmatch.fnmatch(candidate.name, glob) and not fnmatch.fnmatch(self.workspace.relative(candidate), glob):
|
|
1234
|
+
continue
|
|
1235
|
+
try:
|
|
1236
|
+
if candidate.stat().st_size > 2_000_000:
|
|
1237
|
+
continue
|
|
1238
|
+
text = candidate.read_text(encoding="utf-8")
|
|
1239
|
+
except (UnicodeDecodeError, OSError):
|
|
1240
|
+
continue
|
|
1241
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
1242
|
+
if pattern.search(line):
|
|
1243
|
+
matches.append(f"{self.workspace.relative(candidate)}:{line_number}: {line.rstrip()}")
|
|
1244
|
+
if len(matches) >= MAX_SEARCH_MATCHES:
|
|
1245
|
+
matches.append("...[límite de coincidencias alcanzado]")
|
|
1246
|
+
return "\n".join(matches)
|
|
1247
|
+
return "\n".join(matches) if matches else "(sin coincidencias)"
|
|
1248
|
+
|
|
1249
|
+
def find_files(self, pattern: str, path: str = ".") -> str:
|
|
1250
|
+
base = self.workspace.resolve(path)
|
|
1251
|
+
if not base.exists():
|
|
1252
|
+
return f"ERROR: no existe {path}"
|
|
1253
|
+
matches = [
|
|
1254
|
+
self.workspace.relative(candidate)
|
|
1255
|
+
for candidate in self._iter_files(base)
|
|
1256
|
+
if fnmatch.fnmatch(self.workspace.relative(candidate), pattern)
|
|
1257
|
+
or fnmatch.fnmatch(candidate.name, pattern)
|
|
1258
|
+
]
|
|
1259
|
+
return "\n".join(matches[:MAX_LISTED_FILES]) if matches else "(sin archivos)"
|
|
1260
|
+
|
|
1261
|
+
def _mutation_allowed(self, target: Path, description: str) -> bool:
|
|
1262
|
+
return self.workspace.approvals.approve_mutation(description, self.workspace.is_outside(target))
|
|
1263
|
+
|
|
1264
|
+
def apply_patch(self, patch: str) -> str:
|
|
1265
|
+
paths = re.findall(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", patch, flags=re.MULTILINE)
|
|
1266
|
+
if not paths:
|
|
1267
|
+
return "ERROR: el parche no contiene operaciones"
|
|
1268
|
+
for path in paths:
|
|
1269
|
+
target = self.workspace.resolve(path, for_write=True)
|
|
1270
|
+
if not self._mutation_allowed(target, f"Aplicar parche sobre {path}"):
|
|
1271
|
+
return "OPERACIÓN RECHAZADA POR PERMISOS"
|
|
1272
|
+
changed, checkpoint = self.patch_engine.apply(patch)
|
|
1273
|
+
self.state.last_checkpoint = checkpoint
|
|
1274
|
+
self.mutation_checkpoints.append(checkpoint)
|
|
1275
|
+
self.changed_paths.extend(path for path in changed if path not in self.changed_paths)
|
|
1276
|
+
return f"Parche aplicado. Checkpoint: {checkpoint}\nArchivos: " + ", ".join(changed)
|
|
1277
|
+
|
|
1278
|
+
def write_file(self, path: str, content: str) -> str:
|
|
1279
|
+
target = self.workspace.resolve(path, for_write=True)
|
|
1280
|
+
if not self._mutation_allowed(target, f"Escribir {path}"):
|
|
1281
|
+
return "OPERACIÓN RECHAZADA POR PERMISOS"
|
|
1282
|
+
if target.exists() and target.is_dir():
|
|
1283
|
+
return f"ERROR: {path} es una carpeta"
|
|
1284
|
+
if target.exists():
|
|
1285
|
+
try:
|
|
1286
|
+
old = target.read_text(encoding="utf-8")
|
|
1287
|
+
except UnicodeDecodeError:
|
|
1288
|
+
return f"ERROR: {path} parece binario; write_file no lo reemplazará"
|
|
1289
|
+
else:
|
|
1290
|
+
old = ""
|
|
1291
|
+
checkpoint = self.workspace.checkpoint([target], f"write_file {path}")
|
|
1292
|
+
try:
|
|
1293
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1294
|
+
target.write_text(content, encoding="utf-8", newline="")
|
|
1295
|
+
except Exception:
|
|
1296
|
+
self.workspace.undo(checkpoint)
|
|
1297
|
+
raise
|
|
1298
|
+
self.state.last_checkpoint = checkpoint
|
|
1299
|
+
self.mutation_checkpoints.append(checkpoint)
|
|
1300
|
+
if path not in self.changed_paths:
|
|
1301
|
+
self.changed_paths.append(path)
|
|
1302
|
+
diff = "\n".join(
|
|
1303
|
+
difflib.unified_diff(
|
|
1304
|
+
old.splitlines(), content.splitlines(), fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
|
|
1305
|
+
)
|
|
1306
|
+
)
|
|
1307
|
+
return f"Escrito: {path}. Checkpoint: {checkpoint}\n{truncate(diff, 8_000)}"
|
|
1308
|
+
|
|
1309
|
+
def delete_path(self, path: str) -> str:
|
|
1310
|
+
target = self.workspace.resolve(path, for_write=True)
|
|
1311
|
+
if target == self.workspace.root:
|
|
1312
|
+
return "ERROR: no se puede eliminar la raíz del workspace"
|
|
1313
|
+
if not target.exists():
|
|
1314
|
+
return f"ERROR: no existe {path}"
|
|
1315
|
+
if not self._mutation_allowed(target, f"Eliminar {path}"):
|
|
1316
|
+
return "OPERACIÓN RECHAZADA POR PERMISOS"
|
|
1317
|
+
checkpoint = self.workspace.checkpoint([target], f"delete_path {path}")
|
|
1318
|
+
if target.is_dir():
|
|
1319
|
+
shutil.rmtree(target)
|
|
1320
|
+
else:
|
|
1321
|
+
target.unlink()
|
|
1322
|
+
self.state.last_checkpoint = checkpoint
|
|
1323
|
+
self.mutation_checkpoints.append(checkpoint)
|
|
1324
|
+
if path not in self.changed_paths:
|
|
1325
|
+
self.changed_paths.append(path)
|
|
1326
|
+
return f"Eliminado: {path}. Checkpoint: {checkpoint}"
|
|
1327
|
+
|
|
1328
|
+
def _run_command_raw(
|
|
1329
|
+
self, command: str, cwd: str = ".", timeout: int | None = None, *, internal_diagnostic: bool = False
|
|
1330
|
+
) -> CommandResult:
|
|
1331
|
+
if self.state.plan_mode:
|
|
1332
|
+
classification, _ = self.workspace.approvals.classify_command(command)
|
|
1333
|
+
if classification != "trusted":
|
|
1334
|
+
return CommandResult(
|
|
1335
|
+
command=command,
|
|
1336
|
+
cwd=str(self.workspace.root),
|
|
1337
|
+
returncode=126,
|
|
1338
|
+
duration=0.0,
|
|
1339
|
+
output="BLOQUEADO: modo plan solo permite comandos de inspección reconocidos",
|
|
1340
|
+
blocked=True,
|
|
1341
|
+
policy="modo plan",
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
try:
|
|
1345
|
+
working_dir = self.workspace.resolve(cwd)
|
|
1346
|
+
except Exception as exc:
|
|
1347
|
+
return CommandResult(command, cwd, 126, 0.0, f"ERROR: cwd inválido: {exc}", blocked=True, policy="ruta")
|
|
1348
|
+
if not working_dir.exists() or not working_dir.is_dir():
|
|
1349
|
+
return CommandResult(command, str(working_dir), 126, 0.0, "ERROR: cwd inválido", blocked=True, policy="ruta")
|
|
1350
|
+
|
|
1351
|
+
if internal_diagnostic:
|
|
1352
|
+
classification, reasons = self.workspace.approvals.classify_command(command)
|
|
1353
|
+
if self.config.permission_mode == "read-only":
|
|
1354
|
+
approved, reason = False, "diagnóstico bloqueado por modo read-only"
|
|
1355
|
+
elif classification in {"dangerous", "network", "deny"}:
|
|
1356
|
+
approved, reason = False, "; ".join(reasons)
|
|
1357
|
+
else:
|
|
1358
|
+
approved, reason = True, "verificador interno del workspace"
|
|
1359
|
+
else:
|
|
1360
|
+
approved, reason = self.workspace.approvals.approve_command(command)
|
|
1361
|
+
if not approved:
|
|
1362
|
+
return CommandResult(
|
|
1363
|
+
command=command,
|
|
1364
|
+
cwd=str(working_dir),
|
|
1365
|
+
returncode=126,
|
|
1366
|
+
duration=0.0,
|
|
1367
|
+
output=f"OPERACIÓN RECHAZADA: {reason}",
|
|
1368
|
+
blocked=True,
|
|
1369
|
+
policy=reason,
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
timeout_value = max(1, min(int(timeout or self.config.command_timeout), 3_600))
|
|
1373
|
+
started = time.perf_counter()
|
|
1374
|
+
env = os.environ.copy()
|
|
1375
|
+
env["LMCODING_WORKSPACE"] = str(self.workspace.root)
|
|
1376
|
+
env["LMCODING_PERMISSION_MODE"] = self.config.permission_mode
|
|
1377
|
+
env.setdefault("CI", "1")
|
|
1378
|
+
try:
|
|
1379
|
+
completed = subprocess.run(
|
|
1380
|
+
command,
|
|
1381
|
+
cwd=working_dir,
|
|
1382
|
+
shell=True,
|
|
1383
|
+
text=True,
|
|
1384
|
+
stdout=subprocess.PIPE,
|
|
1385
|
+
stderr=subprocess.STDOUT,
|
|
1386
|
+
timeout=timeout_value,
|
|
1387
|
+
errors="replace",
|
|
1388
|
+
env=env,
|
|
1389
|
+
)
|
|
1390
|
+
return CommandResult(
|
|
1391
|
+
command=command,
|
|
1392
|
+
cwd=str(working_dir),
|
|
1393
|
+
returncode=int(completed.returncode),
|
|
1394
|
+
duration=time.perf_counter() - started,
|
|
1395
|
+
output=completed.stdout or "(sin salida)",
|
|
1396
|
+
policy=reason,
|
|
1397
|
+
)
|
|
1398
|
+
except subprocess.TimeoutExpired as exc:
|
|
1399
|
+
partial = exc.stdout or ""
|
|
1400
|
+
if isinstance(partial, bytes):
|
|
1401
|
+
partial = partial.decode(errors="replace")
|
|
1402
|
+
return CommandResult(
|
|
1403
|
+
command=command,
|
|
1404
|
+
cwd=str(working_dir),
|
|
1405
|
+
returncode=124,
|
|
1406
|
+
duration=time.perf_counter() - started,
|
|
1407
|
+
output=f"El comando excedió {timeout_value}s. Salida parcial:\n{partial}",
|
|
1408
|
+
timed_out=True,
|
|
1409
|
+
policy=reason,
|
|
1410
|
+
)
|
|
1411
|
+
except Exception as exc:
|
|
1412
|
+
return CommandResult(
|
|
1413
|
+
command=command,
|
|
1414
|
+
cwd=str(working_dir),
|
|
1415
|
+
returncode=127,
|
|
1416
|
+
duration=time.perf_counter() - started,
|
|
1417
|
+
output=f"{type(exc).__name__}: {exc}",
|
|
1418
|
+
policy=reason,
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
def run_command(self, command: str, cwd: str = ".", timeout: int | None = None) -> str:
|
|
1422
|
+
return self._run_command_raw(command, cwd, timeout).to_text()
|
|
1423
|
+
|
|
1424
|
+
def run_diagnostics_report(self, mode: VerificationMode = "quick") -> DiagnosticReport:
|
|
1425
|
+
detected, commands = self.doctor.detect_commands(mode)
|
|
1426
|
+
if not commands:
|
|
1427
|
+
report = DiagnosticReport(mode=mode, detected=detected, results=[], skipped_reason="no se detectaron validadores locales")
|
|
1428
|
+
self.last_diagnostic_report = report
|
|
1429
|
+
self.state.last_verification = report.to_text()
|
|
1430
|
+
return report
|
|
1431
|
+
results: list[CommandResult] = []
|
|
1432
|
+
for item in commands:
|
|
1433
|
+
result = self._run_command_raw(item.command, item.cwd, item.timeout, internal_diagnostic=True)
|
|
1434
|
+
results.append(result)
|
|
1435
|
+
if result.blocked:
|
|
1436
|
+
break
|
|
1437
|
+
if not result.success and mode == "quick":
|
|
1438
|
+
break
|
|
1439
|
+
report = DiagnosticReport(mode=mode, detected=detected, results=results)
|
|
1440
|
+
self.last_diagnostic_report = report
|
|
1441
|
+
self.state.last_verification = report.to_text()
|
|
1442
|
+
return report
|
|
1443
|
+
|
|
1444
|
+
def diagnose_project(self, mode: str = "quick") -> str:
|
|
1445
|
+
selected: VerificationMode = "full" if mode == "full" else "quick"
|
|
1446
|
+
return self.run_diagnostics_report(selected).to_text()
|
|
1447
|
+
|
|
1448
|
+
def rollback_mutations(self, start_index: int) -> str:
|
|
1449
|
+
checkpoints = self.mutation_checkpoints[start_index:]
|
|
1450
|
+
if not checkpoints:
|
|
1451
|
+
return "No había cambios del agente para revertir."
|
|
1452
|
+
messages: list[str] = []
|
|
1453
|
+
for checkpoint in reversed(checkpoints):
|
|
1454
|
+
result = self.workspace.undo(checkpoint)
|
|
1455
|
+
messages.append(result)
|
|
1456
|
+
del self.mutation_checkpoints[start_index:]
|
|
1457
|
+
self.state.last_checkpoint = self.mutation_checkpoints[-1] if self.mutation_checkpoints else None
|
|
1458
|
+
return "\n".join(messages)
|
|
1459
|
+
|
|
1460
|
+
def git_status(self) -> str:
|
|
1461
|
+
if self.workspace.git_root() is None:
|
|
1462
|
+
return "(no es un repositorio Git)"
|
|
1463
|
+
return self.run_command("git status --short --branch", timeout=30)
|
|
1464
|
+
|
|
1465
|
+
def git_diff(self, staged: bool = False, path: str | None = None) -> str:
|
|
1466
|
+
if self.workspace.git_root() is None:
|
|
1467
|
+
return "(no es un repositorio Git)"
|
|
1468
|
+
command = "git diff --no-ext-diff --unified=3"
|
|
1469
|
+
if staged:
|
|
1470
|
+
command += " --cached"
|
|
1471
|
+
if path:
|
|
1472
|
+
safe_path = shlex.quote(path) if os.name != "nt" else f'"{path}"'
|
|
1473
|
+
command += f" -- {safe_path}"
|
|
1474
|
+
return self.run_command(command, timeout=30)
|
|
1475
|
+
|
|
1476
|
+
def update_plan(self, items: list[dict[str, str]]) -> str:
|
|
1477
|
+
if len(items) > 20:
|
|
1478
|
+
return "ERROR: máximo 20 pasos"
|
|
1479
|
+
plan: list[PlanItem] = []
|
|
1480
|
+
in_progress = 0
|
|
1481
|
+
for item in items:
|
|
1482
|
+
status = item.get("status", "pending")
|
|
1483
|
+
if status not in {"pending", "in_progress", "completed"}:
|
|
1484
|
+
return f"ERROR: estado inválido: {status}"
|
|
1485
|
+
if status == "in_progress":
|
|
1486
|
+
in_progress += 1
|
|
1487
|
+
plan.append(PlanItem(step=str(item.get("step", "")).strip(), status=status))
|
|
1488
|
+
if in_progress > 1:
|
|
1489
|
+
return "ERROR: solo un paso puede estar in_progress"
|
|
1490
|
+
self.state.plan = plan
|
|
1491
|
+
self._render_plan()
|
|
1492
|
+
return "Plan actualizado"
|
|
1493
|
+
|
|
1494
|
+
def _render_plan(self) -> None:
|
|
1495
|
+
if not self.state.plan:
|
|
1496
|
+
return
|
|
1497
|
+
table = Table(title="Plan", show_header=False)
|
|
1498
|
+
table.add_column("Estado", width=3)
|
|
1499
|
+
table.add_column("Paso")
|
|
1500
|
+
symbols = {"pending": "○", "in_progress": "◉", "completed": "✓"}
|
|
1501
|
+
for item in self.state.plan:
|
|
1502
|
+
table.add_row(symbols[item.status], item.step)
|
|
1503
|
+
console.print(table)
|
|
1504
|
+
|
|
1505
|
+
def project_context(self) -> str:
|
|
1506
|
+
chunks: list[str] = [f"Workspace: {self.workspace.root}"]
|
|
1507
|
+
chunks.append(f"Git branch: {self.workspace.git_branch()}")
|
|
1508
|
+
status = self.git_status()
|
|
1509
|
+
chunks.append(f"Git status:\n{truncate(status, 3_000)}")
|
|
1510
|
+
for filename in PROJECT_HINT_FILES:
|
|
1511
|
+
candidate = self.workspace.root / filename
|
|
1512
|
+
if candidate.is_file():
|
|
1513
|
+
try:
|
|
1514
|
+
text = candidate.read_text(encoding="utf-8")
|
|
1515
|
+
except Exception:
|
|
1516
|
+
continue
|
|
1517
|
+
chunks.append(f"\n## {filename}\n{truncate(text, 8_000)}")
|
|
1518
|
+
return "\n".join(chunks)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
class SlashCompleter(Completer):
|
|
1522
|
+
COMMANDS = {
|
|
1523
|
+
"/help": "mostrar comandos",
|
|
1524
|
+
"/status": "estado de sesión",
|
|
1525
|
+
"/permissions": "cambiar permisos",
|
|
1526
|
+
"/model": "cambiar modelo",
|
|
1527
|
+
"/models": "listar modelos",
|
|
1528
|
+
"/plan": "activar o desactivar modo plan",
|
|
1529
|
+
"/review": "revisar cambios Git",
|
|
1530
|
+
"/doctor": "diagnóstico completo del proyecto",
|
|
1531
|
+
"/verify": "verificación rápida o completa",
|
|
1532
|
+
"/autofix": "activar o desactivar autocorrección",
|
|
1533
|
+
"/diff": "mostrar diff",
|
|
1534
|
+
"/undo": "restaurar último checkpoint",
|
|
1535
|
+
"/compact": "compactar contexto",
|
|
1536
|
+
"/new": "nueva conversación",
|
|
1537
|
+
"/save": "guardar sesión",
|
|
1538
|
+
"/rename": "renombrar sesión",
|
|
1539
|
+
"/sessions": "listar sesiones",
|
|
1540
|
+
"/resume": "reanudar sesión",
|
|
1541
|
+
"/clear": "limpiar pantalla",
|
|
1542
|
+
"/exit": "salir",
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
|
|
1546
|
+
text = document.text_before_cursor
|
|
1547
|
+
if not text.startswith("/") or " " in text:
|
|
1548
|
+
return
|
|
1549
|
+
for command, description in self.COMMANDS.items():
|
|
1550
|
+
if command.startswith(text):
|
|
1551
|
+
yield Completion(command, start_position=-len(text), display_meta=description)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
class LMCodingAgent:
|
|
1555
|
+
def __init__(
|
|
1556
|
+
self,
|
|
1557
|
+
workspace: Workspace,
|
|
1558
|
+
config: AppConfig,
|
|
1559
|
+
state: SessionState | None = None,
|
|
1560
|
+
) -> None:
|
|
1561
|
+
self.workspace = workspace
|
|
1562
|
+
self.config = config
|
|
1563
|
+
self.client = OpenAI(base_url=config.base_url.rstrip("/"), api_key=config.api_key)
|
|
1564
|
+
self.store = SessionStore()
|
|
1565
|
+
self.state = state or SessionState.create(workspace.root, config.model)
|
|
1566
|
+
self.model = self.state.model or config.model
|
|
1567
|
+
self.toolbox = ToolBox(workspace, self.state, config)
|
|
1568
|
+
self.available_models: list[str] = []
|
|
1569
|
+
self._ensure_system_message()
|
|
1570
|
+
|
|
1571
|
+
def _ensure_system_message(self) -> None:
|
|
1572
|
+
prompt = SYSTEM_PROMPT + f"\nWorkspace: {self.workspace.root}\n"
|
|
1573
|
+
prompt += (
|
|
1574
|
+
f"Permisos actuales: mode={self.config.permission_mode}, "
|
|
1575
|
+
f"approval={self.config.approval_policy}.\n"
|
|
1576
|
+
)
|
|
1577
|
+
if self.state.plan_mode:
|
|
1578
|
+
prompt += PLAN_MODE_PROMPT
|
|
1579
|
+
if self.state.messages and self.state.messages[0].get("role") == "system":
|
|
1580
|
+
self.state.messages[0]["content"] = prompt
|
|
1581
|
+
else:
|
|
1582
|
+
self.state.messages.insert(0, {"role": "system", "content": prompt})
|
|
1583
|
+
|
|
1584
|
+
def connect(self) -> str:
|
|
1585
|
+
try:
|
|
1586
|
+
models = list(self.client.models.list().data)
|
|
1587
|
+
except Exception as exc:
|
|
1588
|
+
raise RuntimeError(
|
|
1589
|
+
"No se pudo conectar con LM Studio. Inicia el servidor local en Developer."
|
|
1590
|
+
) from exc
|
|
1591
|
+
self.available_models = [item.id for item in models]
|
|
1592
|
+
if not self.model:
|
|
1593
|
+
if not self.available_models:
|
|
1594
|
+
raise RuntimeError("LM Studio no expone ningún modelo. Carga uno primero.")
|
|
1595
|
+
self.model = self.available_models[0]
|
|
1596
|
+
self.state.model = self.model
|
|
1597
|
+
self._save()
|
|
1598
|
+
return self.model
|
|
1599
|
+
|
|
1600
|
+
def _save(self) -> None:
|
|
1601
|
+
if self.config.save_sessions:
|
|
1602
|
+
self.store.save(self.state)
|
|
1603
|
+
|
|
1604
|
+
def set_model(self, model: str) -> None:
|
|
1605
|
+
self.model = model.strip()
|
|
1606
|
+
self.state.model = self.model
|
|
1607
|
+
self._save()
|
|
1608
|
+
|
|
1609
|
+
def set_permissions(self, mode: PermissionMode, approval: ApprovalPolicy | None = None) -> None:
|
|
1610
|
+
self.config.permission_mode = mode
|
|
1611
|
+
if approval:
|
|
1612
|
+
self.config.approval_policy = approval
|
|
1613
|
+
elif mode == "read-only":
|
|
1614
|
+
self.config.approval_policy = "untrusted"
|
|
1615
|
+
elif mode == "workspace-write":
|
|
1616
|
+
self.config.approval_policy = "on-request"
|
|
1617
|
+
else:
|
|
1618
|
+
self.config.approval_policy = "never"
|
|
1619
|
+
self._ensure_system_message()
|
|
1620
|
+
|
|
1621
|
+
def set_plan_mode(self, enabled: bool) -> None:
|
|
1622
|
+
self.state.plan_mode = enabled
|
|
1623
|
+
self._ensure_system_message()
|
|
1624
|
+
self._save()
|
|
1625
|
+
|
|
1626
|
+
def new_conversation(self) -> None:
|
|
1627
|
+
self.state = SessionState.create(self.workspace.root, self.model)
|
|
1628
|
+
self.toolbox = ToolBox(self.workspace, self.state, self.config)
|
|
1629
|
+
self._ensure_system_message()
|
|
1630
|
+
self._save()
|
|
1631
|
+
|
|
1632
|
+
def resume(self, state: SessionState) -> None:
|
|
1633
|
+
self.state = state
|
|
1634
|
+
self.model = state.model or self.model
|
|
1635
|
+
self.toolbox = ToolBox(self.workspace, self.state, self.config)
|
|
1636
|
+
self._ensure_system_message()
|
|
1637
|
+
|
|
1638
|
+
def estimate_context_chars(self) -> int:
|
|
1639
|
+
return sum(len(json.dumps(message, ensure_ascii=False)) for message in self.state.messages)
|
|
1640
|
+
|
|
1641
|
+
def compact(self, force: bool = True) -> bool:
|
|
1642
|
+
size = self.estimate_context_chars()
|
|
1643
|
+
if not force and size < self.config.max_context_chars:
|
|
1644
|
+
return False
|
|
1645
|
+
if len(self.state.messages) < 8:
|
|
1646
|
+
return False
|
|
1647
|
+
|
|
1648
|
+
old_messages = self.state.messages[1:-6]
|
|
1649
|
+
if not old_messages:
|
|
1650
|
+
return False
|
|
1651
|
+
transcript = truncate(json.dumps(old_messages, ensure_ascii=False), 60_000)
|
|
1652
|
+
summary_request = [
|
|
1653
|
+
{
|
|
1654
|
+
"role": "system",
|
|
1655
|
+
"content": (
|
|
1656
|
+
"Resume una sesión de programación para conservar contexto operativo. "
|
|
1657
|
+
"Incluye objetivo, decisiones, archivos leídos/modificados, comandos, resultados, "
|
|
1658
|
+
"errores, restricciones y trabajo pendiente. No inventes nada."
|
|
1659
|
+
),
|
|
1660
|
+
},
|
|
1661
|
+
{"role": "user", "content": transcript},
|
|
1662
|
+
]
|
|
1663
|
+
try:
|
|
1664
|
+
response = self.client.chat.completions.create(
|
|
1665
|
+
model=self.model,
|
|
1666
|
+
messages=summary_request,
|
|
1667
|
+
temperature=0.0,
|
|
1668
|
+
)
|
|
1669
|
+
summary = response.choices[0].message.content or "Resumen no disponible."
|
|
1670
|
+
except Exception:
|
|
1671
|
+
summary = truncate(transcript, 20_000)
|
|
1672
|
+
self.state.messages = [
|
|
1673
|
+
self.state.messages[0],
|
|
1674
|
+
{"role": "system", "content": "Resumen de conversación anterior:\n" + summary},
|
|
1675
|
+
*self.state.messages[-6:],
|
|
1676
|
+
]
|
|
1677
|
+
self._save()
|
|
1678
|
+
return True
|
|
1679
|
+
|
|
1680
|
+
def expand_file_mentions(self, text: str) -> str:
|
|
1681
|
+
mentions = re.findall(r"(?<!\w)@([^\s]+)", text)
|
|
1682
|
+
if not mentions:
|
|
1683
|
+
return text
|
|
1684
|
+
attachments: list[str] = []
|
|
1685
|
+
for raw in mentions[:10]:
|
|
1686
|
+
clean = raw.strip('"\'')
|
|
1687
|
+
try:
|
|
1688
|
+
path = self.workspace.resolve(clean)
|
|
1689
|
+
except ValueError:
|
|
1690
|
+
continue
|
|
1691
|
+
if not path.is_file() or path.stat().st_size > 200_000:
|
|
1692
|
+
continue
|
|
1693
|
+
try:
|
|
1694
|
+
content = path.read_text(encoding="utf-8")
|
|
1695
|
+
except Exception:
|
|
1696
|
+
continue
|
|
1697
|
+
attachments.append(f"\n<file path=\"{self.workspace.relative(path)}\">\n{content}\n</file>")
|
|
1698
|
+
if attachments:
|
|
1699
|
+
return text + "\n\nArchivos adjuntos por @mención:\n" + "\n".join(attachments)
|
|
1700
|
+
return text
|
|
1701
|
+
|
|
1702
|
+
def _chat_completion(self, **kwargs: Any) -> Any:
|
|
1703
|
+
last_error: Exception | None = None
|
|
1704
|
+
for attempt in range(1, self.config.api_retries + 1):
|
|
1705
|
+
try:
|
|
1706
|
+
response = self.client.chat.completions.create(**kwargs)
|
|
1707
|
+
if not getattr(response, "choices", None):
|
|
1708
|
+
raise RuntimeError("LM Studio devolvió una respuesta sin choices")
|
|
1709
|
+
return response
|
|
1710
|
+
except Exception as exc:
|
|
1711
|
+
last_error = exc
|
|
1712
|
+
if attempt >= self.config.api_retries:
|
|
1713
|
+
break
|
|
1714
|
+
delay = self.config.retry_backoff * (2 ** (attempt - 1))
|
|
1715
|
+
console.print(
|
|
1716
|
+
f"[yellow]LM Studio falló ({type(exc).__name__}). "
|
|
1717
|
+
f"Reintento {attempt}/{self.config.api_retries}...[/yellow]"
|
|
1718
|
+
)
|
|
1719
|
+
time.sleep(delay)
|
|
1720
|
+
raise RuntimeError(f"LM Studio no respondió después de {self.config.api_retries} intentos: {last_error}")
|
|
1721
|
+
|
|
1722
|
+
@staticmethod
|
|
1723
|
+
def _parse_tool_arguments(raw: str | None) -> tuple[dict[str, Any], str | None]:
|
|
1724
|
+
value = (raw or "{}").strip()
|
|
1725
|
+
candidates = [value]
|
|
1726
|
+
if value.startswith("```"):
|
|
1727
|
+
stripped = re.sub(r"^```(?:json)?\s*|\s*```$", "", value, flags=re.IGNORECASE | re.DOTALL)
|
|
1728
|
+
candidates.append(stripped)
|
|
1729
|
+
first = value.find("{")
|
|
1730
|
+
last = value.rfind("}")
|
|
1731
|
+
if first >= 0 and last > first:
|
|
1732
|
+
candidates.append(value[first : last + 1])
|
|
1733
|
+
for candidate in candidates:
|
|
1734
|
+
try:
|
|
1735
|
+
parsed = json.loads(candidate)
|
|
1736
|
+
if isinstance(parsed, dict):
|
|
1737
|
+
return parsed, None
|
|
1738
|
+
except Exception:
|
|
1739
|
+
pass
|
|
1740
|
+
try:
|
|
1741
|
+
parsed = ast.literal_eval(candidate)
|
|
1742
|
+
if isinstance(parsed, dict):
|
|
1743
|
+
return parsed, None
|
|
1744
|
+
except Exception:
|
|
1745
|
+
pass
|
|
1746
|
+
return {}, f"ERROR: argumentos JSON inválidos: {truncate(value, 1_000)}"
|
|
1747
|
+
|
|
1748
|
+
def verify_now(self, mode: VerificationMode | None = None, *, quiet: bool = False) -> DiagnosticReport:
|
|
1749
|
+
selected = mode or self.config.verification_mode
|
|
1750
|
+
if selected == "off":
|
|
1751
|
+
report = DiagnosticReport(mode="off", detected=[], results=[], skipped_reason="verificación desactivada")
|
|
1752
|
+
else:
|
|
1753
|
+
report = self.toolbox.run_diagnostics_report(selected)
|
|
1754
|
+
self.state.last_verification = report.to_text()
|
|
1755
|
+
self._save()
|
|
1756
|
+
if not quiet:
|
|
1757
|
+
style = "green" if report.success else ("yellow" if not report.results or report.blocked else "red")
|
|
1758
|
+
console.print(Panel(Text(report.to_text()), title="doctor", border_style=style))
|
|
1759
|
+
return report
|
|
1760
|
+
|
|
1761
|
+
def doctor_report(self, mode: VerificationMode = "full") -> str:
|
|
1762
|
+
checks: list[str] = []
|
|
1763
|
+
checks.append(f"Python: {sys.version.split()[0]} ({sys.executable})")
|
|
1764
|
+
checks.append(f"LM Studio URL: {self.config.base_url}")
|
|
1765
|
+
checks.append(f"Modelo: {self.model or 'no seleccionado'}")
|
|
1766
|
+
checks.append(f"Workspace: {self.workspace.root}")
|
|
1767
|
+
checks.append(f"Git: {self.workspace.git_branch()}")
|
|
1768
|
+
checks.append(f"Permisos: {self.config.permission_mode} / {self.config.approval_policy}")
|
|
1769
|
+
report = self.verify_now(mode, quiet=True)
|
|
1770
|
+
return "DIAGNÓSTICO DE LMCODING\n" + "\n".join(f"- {item}" for item in checks) + "\n\n" + report.to_text()
|
|
1771
|
+
|
|
1772
|
+
def ask(self, user_text: str, *, quiet: bool = False) -> str:
|
|
1773
|
+
if not self.model:
|
|
1774
|
+
raise RuntimeError("No hay modelo seleccionado")
|
|
1775
|
+
if self.config.auto_compact:
|
|
1776
|
+
self.compact(force=False)
|
|
1777
|
+
|
|
1778
|
+
expanded = self.expand_file_mentions(user_text)
|
|
1779
|
+
self.state.messages.append({"role": "user", "content": expanded})
|
|
1780
|
+
self._save()
|
|
1781
|
+
|
|
1782
|
+
task_checkpoint_start = len(self.toolbox.mutation_checkpoints)
|
|
1783
|
+
repair_round = 0
|
|
1784
|
+
repeat_counts: dict[str, int] = {}
|
|
1785
|
+
last_tool_results: dict[str, str] = {}
|
|
1786
|
+
final_content = ""
|
|
1787
|
+
|
|
1788
|
+
for step in range(1, self.config.max_steps + 1):
|
|
1789
|
+
if not quiet:
|
|
1790
|
+
status = console.status(
|
|
1791
|
+
f"[bold cyan]LMCoding trabajando · paso {step}/{self.config.max_steps}[/bold cyan]",
|
|
1792
|
+
spinner="dots",
|
|
1793
|
+
)
|
|
1794
|
+
status.start()
|
|
1795
|
+
else:
|
|
1796
|
+
status = None
|
|
1797
|
+
try:
|
|
1798
|
+
response = self._chat_completion(
|
|
1799
|
+
model=self.model,
|
|
1800
|
+
messages=self.state.messages,
|
|
1801
|
+
tools=self.toolbox.schemas,
|
|
1802
|
+
tool_choice="auto",
|
|
1803
|
+
temperature=self.config.temperature,
|
|
1804
|
+
)
|
|
1805
|
+
finally:
|
|
1806
|
+
if status:
|
|
1807
|
+
status.stop()
|
|
1808
|
+
|
|
1809
|
+
usage = getattr(response, "usage", None)
|
|
1810
|
+
if usage:
|
|
1811
|
+
self.state.total_prompt_tokens += int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
1812
|
+
self.state.total_completion_tokens += int(getattr(usage, "completion_tokens", 0) or 0)
|
|
1813
|
+
|
|
1814
|
+
message = response.choices[0].message
|
|
1815
|
+
tool_calls = message.tool_calls or []
|
|
1816
|
+
assistant_message: dict[str, Any] = {
|
|
1817
|
+
"role": "assistant",
|
|
1818
|
+
"content": message.content or "",
|
|
1819
|
+
}
|
|
1820
|
+
if tool_calls:
|
|
1821
|
+
assistant_message["tool_calls"] = [
|
|
1822
|
+
{
|
|
1823
|
+
"id": call.id,
|
|
1824
|
+
"type": "function",
|
|
1825
|
+
"function": {
|
|
1826
|
+
"name": call.function.name,
|
|
1827
|
+
"arguments": call.function.arguments,
|
|
1828
|
+
},
|
|
1829
|
+
}
|
|
1830
|
+
for call in tool_calls
|
|
1831
|
+
]
|
|
1832
|
+
self.state.messages.append(assistant_message)
|
|
1833
|
+
self._save()
|
|
1834
|
+
|
|
1835
|
+
if not tool_calls:
|
|
1836
|
+
final_content = message.content or "(sin respuesta)"
|
|
1837
|
+
has_new_mutations = len(self.toolbox.mutation_checkpoints) > task_checkpoint_start
|
|
1838
|
+
should_verify = (
|
|
1839
|
+
self.config.self_heal
|
|
1840
|
+
and not self.state.plan_mode
|
|
1841
|
+
and self.config.verification_mode != "off"
|
|
1842
|
+
and has_new_mutations
|
|
1843
|
+
)
|
|
1844
|
+
if should_verify:
|
|
1845
|
+
report = self.verify_now(self.config.verification_mode, quiet=True)
|
|
1846
|
+
if report.success or not report.results:
|
|
1847
|
+
verification_note = "\n\n✅ " + report.to_text(limit=4_000)
|
|
1848
|
+
final_content += verification_note
|
|
1849
|
+
elif report.blocked:
|
|
1850
|
+
final_content += "\n\n⚠️ La verificación automática fue bloqueada por la política de permisos.\n" + report.to_text(limit=4_000)
|
|
1851
|
+
elif repair_round < self.config.max_fix_attempts:
|
|
1852
|
+
repair_round += 1
|
|
1853
|
+
self.state.repair_cycles += 1
|
|
1854
|
+
if not quiet:
|
|
1855
|
+
console.print(
|
|
1856
|
+
Panel(
|
|
1857
|
+
Text(report.to_text(limit=8_000)),
|
|
1858
|
+
title=f"[red]Autocorrección {repair_round}/{self.config.max_fix_attempts}[/red]",
|
|
1859
|
+
border_style="red",
|
|
1860
|
+
)
|
|
1861
|
+
)
|
|
1862
|
+
self.state.messages.append(
|
|
1863
|
+
{
|
|
1864
|
+
"role": "user",
|
|
1865
|
+
"content": AUTO_REPAIR_PROMPT + "\n\n" + report.to_text(limit=16_000),
|
|
1866
|
+
}
|
|
1867
|
+
)
|
|
1868
|
+
self._save()
|
|
1869
|
+
continue
|
|
1870
|
+
else:
|
|
1871
|
+
failure_text = report.to_text(limit=8_000)
|
|
1872
|
+
if self.config.rollback_on_failure:
|
|
1873
|
+
rollback = self.toolbox.rollback_mutations(task_checkpoint_start)
|
|
1874
|
+
final_content += (
|
|
1875
|
+
"\n\n❌ No fue posible dejar el proyecto verificado después de "
|
|
1876
|
+
f"{repair_round} ciclos. Los cambios de esta tarea fueron revertidos.\n\n"
|
|
1877
|
+
+ failure_text
|
|
1878
|
+
+ "\n\nREVERSIÓN:\n"
|
|
1879
|
+
+ rollback
|
|
1880
|
+
)
|
|
1881
|
+
else:
|
|
1882
|
+
final_content += (
|
|
1883
|
+
"\n\n❌ Persisten errores de verificación. Los cambios se conservaron porque rollback_on_failure=false.\n\n"
|
|
1884
|
+
+ failure_text
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
if not quiet:
|
|
1888
|
+
console.print(
|
|
1889
|
+
Panel(Markdown(final_content), title="[bold cyan]LMCoding[/bold cyan]", border_style="bright_magenta")
|
|
1890
|
+
)
|
|
1891
|
+
self._save()
|
|
1892
|
+
return final_content
|
|
1893
|
+
|
|
1894
|
+
loop_detected = False
|
|
1895
|
+
for call in tool_calls:
|
|
1896
|
+
tool_name = call.function.name
|
|
1897
|
+
arguments, parse_error = self._parse_tool_arguments(call.function.arguments)
|
|
1898
|
+
signature = tool_name + ":" + json.dumps(arguments, ensure_ascii=False, sort_keys=True)
|
|
1899
|
+
repeat_counts[signature] = repeat_counts.get(signature, 0) + 1
|
|
1900
|
+
|
|
1901
|
+
if not quiet:
|
|
1902
|
+
self._print_tool_call(tool_name, arguments)
|
|
1903
|
+
|
|
1904
|
+
if repeat_counts[signature] > self.config.max_repeat_tool_calls:
|
|
1905
|
+
tool_result = (
|
|
1906
|
+
"BLOQUEADO: se detectó un bucle de herramienta. La misma llamada ya se repitió "
|
|
1907
|
+
f"{repeat_counts[signature] - 1} veces. Inspecciona nueva evidencia y cambia de estrategia."
|
|
1908
|
+
)
|
|
1909
|
+
loop_detected = True
|
|
1910
|
+
else:
|
|
1911
|
+
tool_result = parse_error or self.toolbox.execute(tool_name, arguments)
|
|
1912
|
+
previous = last_tool_results.get(signature)
|
|
1913
|
+
if previous == tool_result and repeat_counts[signature] >= self.config.max_repeat_tool_calls:
|
|
1914
|
+
loop_detected = True
|
|
1915
|
+
last_tool_results[signature] = tool_result
|
|
1916
|
+
|
|
1917
|
+
if not quiet and self.config.show_tool_output:
|
|
1918
|
+
console.print(
|
|
1919
|
+
Panel(
|
|
1920
|
+
Text(truncate(tool_result, 6_000)),
|
|
1921
|
+
title="[dim]resultado[/dim]",
|
|
1922
|
+
border_style="dim",
|
|
1923
|
+
)
|
|
1924
|
+
)
|
|
1925
|
+
self.state.messages.append(
|
|
1926
|
+
{"role": "tool", "tool_call_id": call.id, "content": tool_result}
|
|
1927
|
+
)
|
|
1928
|
+
self._save()
|
|
1929
|
+
|
|
1930
|
+
if loop_detected:
|
|
1931
|
+
self.state.messages.append(
|
|
1932
|
+
{
|
|
1933
|
+
"role": "user",
|
|
1934
|
+
"content": (
|
|
1935
|
+
"Se detectó repetición improductiva. No repitas la misma llamada. "
|
|
1936
|
+
"Relee el error, inspecciona otros archivos o utiliza una estrategia distinta."
|
|
1937
|
+
),
|
|
1938
|
+
}
|
|
1939
|
+
)
|
|
1940
|
+
self._save()
|
|
1941
|
+
|
|
1942
|
+
final_content = "Se alcanzó el máximo de pasos antes de completar y verificar la tarea."
|
|
1943
|
+
if self.config.rollback_on_failure and len(self.toolbox.mutation_checkpoints) > task_checkpoint_start:
|
|
1944
|
+
rollback = self.toolbox.rollback_mutations(task_checkpoint_start)
|
|
1945
|
+
final_content += " Los cambios incompletos de esta tarea fueron revertidos.\n" + rollback
|
|
1946
|
+
if not quiet:
|
|
1947
|
+
console.print(f"[yellow]{final_content}[/yellow]")
|
|
1948
|
+
return final_content
|
|
1949
|
+
|
|
1950
|
+
@staticmethod
|
|
1951
|
+
def _print_tool_call(name: str, arguments: dict[str, Any]) -> None:
|
|
1952
|
+
summary = json.dumps(arguments, ensure_ascii=False, indent=2)
|
|
1953
|
+
console.print(
|
|
1954
|
+
Panel(Text(truncate(summary, 4_000)), title=f"[magenta]› {name}[/magenta]", border_style="magenta")
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
def review(self) -> str:
|
|
1958
|
+
diff = self.toolbox.git_diff()
|
|
1959
|
+
prompt = REVIEW_PROMPT + "\n\nDiff actual:\n" + truncate(diff, 50_000)
|
|
1960
|
+
return self.ask(prompt)
|
|
1961
|
+
|
|
1962
|
+
def status_table(self) -> Table:
|
|
1963
|
+
table = Table(title=f"Estado de {COMMAND_BRAND}", show_header=False)
|
|
1964
|
+
table.add_column("Campo", style="bright_magenta")
|
|
1965
|
+
table.add_column("Valor")
|
|
1966
|
+
table.add_row("Versión", APP_VERSION)
|
|
1967
|
+
table.add_row("Modelo", self.model or "-")
|
|
1968
|
+
table.add_row("Workspace", str(self.workspace.root))
|
|
1969
|
+
table.add_row("Git", self.workspace.git_branch())
|
|
1970
|
+
table.add_row("Permisos", f"{self.config.permission_mode} / {self.config.approval_policy}")
|
|
1971
|
+
table.add_row("Modo plan", "sí" if self.state.plan_mode else "no")
|
|
1972
|
+
table.add_row(
|
|
1973
|
+
"Autocorrección",
|
|
1974
|
+
f"{'sí' if self.config.self_heal else 'no'} / {self.config.verification_mode} / intentos {self.config.max_fix_attempts}",
|
|
1975
|
+
)
|
|
1976
|
+
table.add_row("Sesión", f"{self.state.name} ({self.state.id[:8]})")
|
|
1977
|
+
table.add_row("Contexto estimado", f"{self.estimate_context_chars():,} caracteres")
|
|
1978
|
+
table.add_row(
|
|
1979
|
+
"Tokens reportados",
|
|
1980
|
+
f"entrada {self.state.total_prompt_tokens:,} / salida {self.state.total_completion_tokens:,}",
|
|
1981
|
+
)
|
|
1982
|
+
table.add_row("Último checkpoint", self.state.last_checkpoint or "-")
|
|
1983
|
+
table.add_row("Última verificación", truncate(self.state.last_verification or "-", 140))
|
|
1984
|
+
table.add_row("Ciclos de reparación", str(self.state.repair_cycles))
|
|
1985
|
+
return table
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
class InteractiveApp:
|
|
1989
|
+
def __init__(self, agent: LMCodingAgent) -> None:
|
|
1990
|
+
self.agent = agent
|
|
1991
|
+
ensure_home()
|
|
1992
|
+
bindings = KeyBindings()
|
|
1993
|
+
|
|
1994
|
+
@bindings.add("escape", "enter")
|
|
1995
|
+
def submit_multiline(event: Any) -> None:
|
|
1996
|
+
event.current_buffer.validate_and_handle()
|
|
1997
|
+
|
|
1998
|
+
self.prompt = PromptSession(
|
|
1999
|
+
history=FileHistory(str(HISTORY_FILE)),
|
|
2000
|
+
completer=SlashCompleter(),
|
|
2001
|
+
complete_while_typing=True,
|
|
2002
|
+
multiline=True,
|
|
2003
|
+
key_bindings=bindings,
|
|
2004
|
+
bottom_toolbar=self._toolbar,
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
def _toolbar(self) -> HTML:
|
|
2008
|
+
branch = self.agent.workspace.git_branch()
|
|
2009
|
+
mode = self.agent.config.permission_mode
|
|
2010
|
+
plan = " PLAN" if self.agent.state.plan_mode else ""
|
|
2011
|
+
heal = f" HEAL:{self.agent.config.verification_mode}" if self.agent.config.self_heal else " HEAL:off"
|
|
2012
|
+
return HTML(
|
|
2013
|
+
f" <b>{COMMAND_BRAND}</b> {APP_VERSION} | {self.agent.model} | {branch} | {mode}{plan}{heal} "
|
|
2014
|
+
"| Esc+Enter enviar · Enter nueva línea "
|
|
2015
|
+
)
|
|
2016
|
+
|
|
2017
|
+
def run(self) -> int:
|
|
2018
|
+
self.print_banner()
|
|
2019
|
+
while True:
|
|
2020
|
+
try:
|
|
2021
|
+
text = self.prompt.prompt(HTML("<ansimagenta><b>›</b></ansimagenta> ")).strip()
|
|
2022
|
+
except (EOFError, KeyboardInterrupt):
|
|
2023
|
+
console.print("\n[dim]Sesión guardada. Cerrando LMCoding.[/dim]")
|
|
2024
|
+
return 0
|
|
2025
|
+
if not text:
|
|
2026
|
+
continue
|
|
2027
|
+
if text.startswith("/"):
|
|
2028
|
+
if self.handle_command(text):
|
|
2029
|
+
return 0
|
|
2030
|
+
continue
|
|
2031
|
+
if text.startswith("!"):
|
|
2032
|
+
result = self.agent.toolbox.run_command(text[1:].strip())
|
|
2033
|
+
console.print(Panel(Text(result), title="shell", border_style="yellow"))
|
|
2034
|
+
continue
|
|
2035
|
+
try:
|
|
2036
|
+
self.agent.ask(text)
|
|
2037
|
+
except KeyboardInterrupt:
|
|
2038
|
+
console.print("\n[yellow]Tarea cancelada.[/yellow]")
|
|
2039
|
+
except Exception as exc:
|
|
2040
|
+
console.print(Panel(f"{type(exc).__name__}: {exc}", title="[red]Error[/red]", border_style="red"))
|
|
2041
|
+
|
|
2042
|
+
def print_banner(self) -> None:
|
|
2043
|
+
text = Text()
|
|
2044
|
+
text.append(COMMAND_BRAND, style="bold bright_magenta")
|
|
2045
|
+
text.append(f" v{APP_VERSION}\n", style="dim")
|
|
2046
|
+
text.append(" · motor LMCoding\n", style="dim")
|
|
2047
|
+
text.append("Agente local autocorrectivo para LM Studio\n")
|
|
2048
|
+
text.append(f"modelo: {self.agent.model}\n", style="green")
|
|
2049
|
+
text.append(f"workspace: {self.agent.workspace.root}\n", style="yellow")
|
|
2050
|
+
text.append("Escribe /help para comandos. Usa @archivo para adjuntarlo.", style="dim")
|
|
2051
|
+
console.print(Panel(text, border_style="bright_magenta"))
|
|
2052
|
+
|
|
2053
|
+
def handle_command(self, text: str) -> bool:
|
|
2054
|
+
command, _, argument = text.partition(" ")
|
|
2055
|
+
command = command.lower()
|
|
2056
|
+
argument = argument.strip()
|
|
2057
|
+
|
|
2058
|
+
if command in {"/exit", "/quit"}:
|
|
2059
|
+
self.agent._save()
|
|
2060
|
+
return True
|
|
2061
|
+
if command == "/help":
|
|
2062
|
+
self.print_help()
|
|
2063
|
+
elif command == "/status":
|
|
2064
|
+
console.print(self.agent.status_table())
|
|
2065
|
+
elif command == "/models":
|
|
2066
|
+
self.print_models()
|
|
2067
|
+
elif command == "/model":
|
|
2068
|
+
self.change_model(argument)
|
|
2069
|
+
elif command == "/permissions":
|
|
2070
|
+
self.change_permissions(argument)
|
|
2071
|
+
elif command == "/plan":
|
|
2072
|
+
self.toggle_plan(argument)
|
|
2073
|
+
elif command == "/review":
|
|
2074
|
+
self.agent.review()
|
|
2075
|
+
elif command == "/doctor":
|
|
2076
|
+
console.print(Panel(Text(self.agent.doctor_report("full")), title="doctor", border_style="bright_magenta"))
|
|
2077
|
+
elif command == "/verify":
|
|
2078
|
+
selected: VerificationMode = "full" if argument.lower() == "full" else "quick"
|
|
2079
|
+
self.agent.verify_now(selected)
|
|
2080
|
+
elif command == "/autofix":
|
|
2081
|
+
value = argument.lower()
|
|
2082
|
+
if value in {"off", "0", "false", "no"}:
|
|
2083
|
+
self.agent.config.self_heal = False
|
|
2084
|
+
else:
|
|
2085
|
+
self.agent.config.self_heal = True
|
|
2086
|
+
if value in {"quick", "full"}:
|
|
2087
|
+
self.agent.config.verification_mode = value # type: ignore[assignment]
|
|
2088
|
+
console.print(
|
|
2089
|
+
f"[green]Autocorrección: {'activada' if self.agent.config.self_heal else 'desactivada'} "
|
|
2090
|
+
f"({self.agent.config.verification_mode})[/green]"
|
|
2091
|
+
)
|
|
2092
|
+
elif command == "/diff":
|
|
2093
|
+
console.print(Panel(Text(self.agent.toolbox.git_diff()), title="git diff", border_style="blue"))
|
|
2094
|
+
elif command == "/undo":
|
|
2095
|
+
checkpoint = argument or self.agent.state.last_checkpoint
|
|
2096
|
+
result = self.agent.workspace.undo(checkpoint)
|
|
2097
|
+
console.print(Panel(Text(result), title="undo", border_style="yellow"))
|
|
2098
|
+
elif command == "/compact":
|
|
2099
|
+
changed = self.agent.compact(force=True)
|
|
2100
|
+
console.print("[green]Contexto compactado.[/green]" if changed else "[dim]No fue necesario compactar.[/dim]")
|
|
2101
|
+
elif command == "/new":
|
|
2102
|
+
self.agent.new_conversation()
|
|
2103
|
+
console.print(f"[green]Nueva sesión: {self.agent.state.id[:8]}[/green]")
|
|
2104
|
+
elif command == "/save":
|
|
2105
|
+
self.agent._save()
|
|
2106
|
+
console.print("[green]Sesión guardada.[/green]")
|
|
2107
|
+
elif command == "/rename":
|
|
2108
|
+
if not argument:
|
|
2109
|
+
argument = Prompt.ask("Nombre de la sesión").strip()
|
|
2110
|
+
self.agent.state.name = argument
|
|
2111
|
+
self.agent._save()
|
|
2112
|
+
console.print(f"[green]Sesión renombrada: {argument}[/green]")
|
|
2113
|
+
elif command == "/sessions":
|
|
2114
|
+
self.print_sessions()
|
|
2115
|
+
elif command == "/resume":
|
|
2116
|
+
self.resume_session(argument)
|
|
2117
|
+
elif command == "/clear":
|
|
2118
|
+
console.clear()
|
|
2119
|
+
self.print_banner()
|
|
2120
|
+
else:
|
|
2121
|
+
console.print(f"[red]Comando desconocido: {command}[/red]")
|
|
2122
|
+
return False
|
|
2123
|
+
|
|
2124
|
+
@staticmethod
|
|
2125
|
+
def print_help() -> None:
|
|
2126
|
+
table = Table(title="Comandos")
|
|
2127
|
+
table.add_column("Comando", style="bright_magenta")
|
|
2128
|
+
table.add_column("Función")
|
|
2129
|
+
rows = [
|
|
2130
|
+
("/status", "modelo, permisos, Git, tokens y sesión"),
|
|
2131
|
+
("/permissions [read-only|workspace-write|full-access]", "cambiar autonomía"),
|
|
2132
|
+
("/model [ID]", "seleccionar modelo"),
|
|
2133
|
+
("/models", "listar modelos"),
|
|
2134
|
+
("/plan [on|off]", "modo de planificación sin cambios"),
|
|
2135
|
+
("/review", "revisión del árbol de trabajo"),
|
|
2136
|
+
("/doctor", "diagnóstico completo del entorno y proyecto"),
|
|
2137
|
+
("/verify [quick|full]", "ejecutar validadores detectados"),
|
|
2138
|
+
("/autofix [on|off|quick|full]", "controlar autocorrección"),
|
|
2139
|
+
("/diff", "mostrar git diff"),
|
|
2140
|
+
("/undo [checkpoint]", "restaurar cambios del agente"),
|
|
2141
|
+
("/compact", "resumir historial antiguo"),
|
|
2142
|
+
("/new", "crear conversación nueva"),
|
|
2143
|
+
("/sessions", "listar sesiones guardadas"),
|
|
2144
|
+
("/resume ID", "reanudar una sesión"),
|
|
2145
|
+
("/rename NOMBRE", "renombrar sesión"),
|
|
2146
|
+
("!comando", "ejecutar shell con la política actual"),
|
|
2147
|
+
("@ruta", "adjuntar un archivo al mensaje"),
|
|
2148
|
+
("/exit", "guardar y salir"),
|
|
2149
|
+
]
|
|
2150
|
+
for row in rows:
|
|
2151
|
+
table.add_row(*row)
|
|
2152
|
+
console.print(table)
|
|
2153
|
+
|
|
2154
|
+
def print_models(self) -> None:
|
|
2155
|
+
table = Table(title="Modelos de LM Studio")
|
|
2156
|
+
table.add_column("ID")
|
|
2157
|
+
table.add_column("Activo")
|
|
2158
|
+
for model in self.agent.available_models:
|
|
2159
|
+
table.add_row(model, "✓" if model == self.agent.model else "")
|
|
2160
|
+
console.print(table)
|
|
2161
|
+
|
|
2162
|
+
def change_model(self, argument: str) -> None:
|
|
2163
|
+
if not argument:
|
|
2164
|
+
self.print_models()
|
|
2165
|
+
argument = Prompt.ask("ID del modelo").strip()
|
|
2166
|
+
if argument:
|
|
2167
|
+
self.agent.set_model(argument)
|
|
2168
|
+
console.print(f"[green]Modelo activo: {argument}[/green]")
|
|
2169
|
+
|
|
2170
|
+
def change_permissions(self, argument: str) -> None:
|
|
2171
|
+
aliases = {"auto": "workspace-write", "readonly": "read-only", "full": "full-access"}
|
|
2172
|
+
value = aliases.get(argument.lower(), argument.lower()) if argument else ""
|
|
2173
|
+
if value not in {"read-only", "workspace-write", "full-access"}:
|
|
2174
|
+
table = Table(title="Permisos")
|
|
2175
|
+
table.add_column("Modo")
|
|
2176
|
+
table.add_column("Comportamiento")
|
|
2177
|
+
table.add_row("read-only", "inspecciona; pide permiso para cambiar o ejecutar")
|
|
2178
|
+
table.add_row("workspace-write", "edita dentro del proyecto; pregunta por red o riesgo")
|
|
2179
|
+
table.add_row("full-access", "acceso amplio y sin confirmaciones")
|
|
2180
|
+
console.print(table)
|
|
2181
|
+
value = Prompt.ask(
|
|
2182
|
+
"Modo",
|
|
2183
|
+
choices=["read-only", "workspace-write", "full-access"],
|
|
2184
|
+
default=self.agent.config.permission_mode,
|
|
2185
|
+
)
|
|
2186
|
+
if value == "full-access":
|
|
2187
|
+
confirmed = Confirm.ask(
|
|
2188
|
+
"[red]Full access permite comandos y archivos fuera del proyecto sin confirmación. ¿Continuar?[/red]",
|
|
2189
|
+
default=False,
|
|
2190
|
+
)
|
|
2191
|
+
if not confirmed:
|
|
2192
|
+
console.print("[yellow]Cambio cancelado.[/yellow]")
|
|
2193
|
+
return
|
|
2194
|
+
self.agent.set_permissions(value) # type: ignore[arg-type]
|
|
2195
|
+
console.print(
|
|
2196
|
+
f"[green]Permisos: {self.agent.config.permission_mode} / "
|
|
2197
|
+
f"{self.agent.config.approval_policy}[/green]"
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
def toggle_plan(self, argument: str) -> None:
|
|
2201
|
+
if argument.lower() in {"on", "1", "true", "si", "sí"}:
|
|
2202
|
+
enabled = True
|
|
2203
|
+
elif argument.lower() in {"off", "0", "false", "no"}:
|
|
2204
|
+
enabled = False
|
|
2205
|
+
else:
|
|
2206
|
+
enabled = not self.agent.state.plan_mode
|
|
2207
|
+
self.agent.set_plan_mode(enabled)
|
|
2208
|
+
console.print(f"[green]Modo plan: {'activado' if enabled else 'desactivado'}[/green]")
|
|
2209
|
+
|
|
2210
|
+
def print_sessions(self) -> None:
|
|
2211
|
+
states = self.agent.store.list_states(self.agent.workspace.root)
|
|
2212
|
+
table = Table(title="Sesiones")
|
|
2213
|
+
table.add_column("ID")
|
|
2214
|
+
table.add_column("Nombre")
|
|
2215
|
+
table.add_column("Actualizada")
|
|
2216
|
+
table.add_column("Modelo")
|
|
2217
|
+
for state in states[:30]:
|
|
2218
|
+
table.add_row(state.id[:8], state.name, state.updated_at[:19], state.model or "-")
|
|
2219
|
+
console.print(table)
|
|
2220
|
+
|
|
2221
|
+
def resume_session(self, argument: str) -> None:
|
|
2222
|
+
if not argument:
|
|
2223
|
+
self.print_sessions()
|
|
2224
|
+
argument = Prompt.ask("ID o nombre").strip()
|
|
2225
|
+
try:
|
|
2226
|
+
state = self.agent.store.load(argument)
|
|
2227
|
+
except Exception as exc:
|
|
2228
|
+
console.print(f"[red]{exc}[/red]")
|
|
2229
|
+
return
|
|
2230
|
+
if Path(state.workspace).resolve() != self.agent.workspace.root:
|
|
2231
|
+
console.print("[red]La sesión pertenece a otro workspace.[/red]")
|
|
2232
|
+
return
|
|
2233
|
+
self.agent.resume(state)
|
|
2234
|
+
console.print(f"[green]Sesión reanudada: {state.name} ({state.id[:8]})[/green]")
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def cli_values_from_args(args: argparse.Namespace) -> dict[str, Any]:
|
|
2238
|
+
return {
|
|
2239
|
+
"base_url": getattr(args, "base_url", None),
|
|
2240
|
+
"api_key": getattr(args, "api_key", None),
|
|
2241
|
+
"model": getattr(args, "model", None),
|
|
2242
|
+
"permission_mode": getattr(args, "permission_mode", None),
|
|
2243
|
+
"approval_policy": getattr(args, "approval_policy", None),
|
|
2244
|
+
"max_steps": getattr(args, "max_steps", None),
|
|
2245
|
+
"temperature": getattr(args, "temperature", None),
|
|
2246
|
+
"self_heal": getattr(args, "self_heal", None),
|
|
2247
|
+
"verification_mode": getattr(args, "verification_mode", None),
|
|
2248
|
+
"max_fix_attempts": getattr(args, "max_fix_attempts", None),
|
|
2249
|
+
"rollback_on_failure": getattr(args, "rollback_on_failure", None),
|
|
2250
|
+
"api_retries": getattr(args, "api_retries", None),
|
|
2251
|
+
"save_sessions": False if getattr(args, "no_save", False) else None,
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
def add_common_flags(parser: argparse.ArgumentParser, *, suppress_defaults: bool = False) -> None:
|
|
2256
|
+
missing = argparse.SUPPRESS if suppress_defaults else None
|
|
2257
|
+
parser.add_argument(
|
|
2258
|
+
"-C", "--cd", "--workspace", dest="workspace",
|
|
2259
|
+
default=argparse.SUPPRESS if suppress_defaults else ".",
|
|
2260
|
+
)
|
|
2261
|
+
parser.add_argument(
|
|
2262
|
+
"--base-url",
|
|
2263
|
+
default=argparse.SUPPRESS if suppress_defaults else os.getenv("LMSTUDIO_BASE_URL"),
|
|
2264
|
+
)
|
|
2265
|
+
parser.add_argument(
|
|
2266
|
+
"--api-key",
|
|
2267
|
+
default=argparse.SUPPRESS if suppress_defaults else os.getenv("LMSTUDIO_API_KEY"),
|
|
2268
|
+
)
|
|
2269
|
+
parser.add_argument(
|
|
2270
|
+
"-m", "--model",
|
|
2271
|
+
default=argparse.SUPPRESS if suppress_defaults else os.getenv("LMSTUDIO_MODEL"),
|
|
2272
|
+
)
|
|
2273
|
+
parser.add_argument(
|
|
2274
|
+
"-s",
|
|
2275
|
+
"--sandbox",
|
|
2276
|
+
dest="permission_mode",
|
|
2277
|
+
choices=["read-only", "workspace-write", "full-access"],
|
|
2278
|
+
default=missing,
|
|
2279
|
+
)
|
|
2280
|
+
parser.add_argument(
|
|
2281
|
+
"-a",
|
|
2282
|
+
"--ask-for-approval",
|
|
2283
|
+
dest="approval_policy",
|
|
2284
|
+
choices=["untrusted", "on-request", "never"],
|
|
2285
|
+
default=missing,
|
|
2286
|
+
)
|
|
2287
|
+
parser.add_argument("--max-steps", type=int, default=missing)
|
|
2288
|
+
parser.add_argument("--temperature", type=float, default=missing)
|
|
2289
|
+
parser.add_argument(
|
|
2290
|
+
"--self-heal",
|
|
2291
|
+
action=argparse.BooleanOptionalAction,
|
|
2292
|
+
default=missing,
|
|
2293
|
+
help="activar/desactivar diagnóstico, reparación y verificación automáticos",
|
|
2294
|
+
)
|
|
2295
|
+
parser.add_argument(
|
|
2296
|
+
"--verification",
|
|
2297
|
+
dest="verification_mode",
|
|
2298
|
+
choices=["off", "quick", "full"],
|
|
2299
|
+
default=missing,
|
|
2300
|
+
)
|
|
2301
|
+
parser.add_argument("--fix-attempts", dest="max_fix_attempts", type=int, default=missing)
|
|
2302
|
+
parser.add_argument(
|
|
2303
|
+
"--rollback-on-failure",
|
|
2304
|
+
action=argparse.BooleanOptionalAction,
|
|
2305
|
+
default=missing,
|
|
2306
|
+
help="revertir cambios de la tarea si no superan las verificaciones",
|
|
2307
|
+
)
|
|
2308
|
+
parser.add_argument("--api-retries", type=int, default=missing)
|
|
2309
|
+
parser.add_argument("--no-save", action="store_true", default=missing)
|
|
2310
|
+
parser.add_argument(
|
|
2311
|
+
"--full-auto",
|
|
2312
|
+
action="store_true",
|
|
2313
|
+
default=missing,
|
|
2314
|
+
help="workspace-write + on-request (modo recomendado)",
|
|
2315
|
+
)
|
|
2316
|
+
parser.add_argument(
|
|
2317
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
2318
|
+
action="store_true",
|
|
2319
|
+
default=missing,
|
|
2320
|
+
help="full-access + never; úsalo solo en entornos confiables",
|
|
2321
|
+
)
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2325
|
+
parser = argparse.ArgumentParser(
|
|
2326
|
+
prog=(Path(sys.argv[0]).stem or "llmCodex"),
|
|
2327
|
+
description="Agente de programación local avanzado para LM Studio",
|
|
2328
|
+
)
|
|
2329
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {APP_VERSION}")
|
|
2330
|
+
subparsers = parser.add_subparsers(dest="subcommand")
|
|
2331
|
+
|
|
2332
|
+
interactive = subparsers.add_parser("interactive", help="abrir interfaz interactiva")
|
|
2333
|
+
add_common_flags(interactive, suppress_defaults=True)
|
|
2334
|
+
|
|
2335
|
+
execute = subparsers.add_parser("exec", help="ejecutar una tarea no interactiva")
|
|
2336
|
+
execute.add_argument("prompt", nargs="?", help="tarea; si se omite, lee stdin")
|
|
2337
|
+
execute.add_argument("--output", help="guardar respuesta final en un archivo")
|
|
2338
|
+
execute.add_argument("--json", action="store_true", help="imprimir resultado JSON")
|
|
2339
|
+
add_common_flags(execute, suppress_defaults=True)
|
|
2340
|
+
|
|
2341
|
+
resume = subparsers.add_parser("resume", help="reanudar una sesión")
|
|
2342
|
+
resume.add_argument("session", nargs="?")
|
|
2343
|
+
resume.add_argument("--last", action="store_true")
|
|
2344
|
+
add_common_flags(resume, suppress_defaults=True)
|
|
2345
|
+
|
|
2346
|
+
doctor = subparsers.add_parser("doctor", help="diagnosticar entorno y proyecto")
|
|
2347
|
+
doctor.add_argument("--mode", choices=["quick", "full"], default="full")
|
|
2348
|
+
add_common_flags(doctor, suppress_defaults=True)
|
|
2349
|
+
|
|
2350
|
+
verify = subparsers.add_parser("verify", help="ejecutar verificaciones sin pedir una corrección al modelo")
|
|
2351
|
+
verify.add_argument("--mode", choices=["quick", "full"], default="quick")
|
|
2352
|
+
add_common_flags(verify, suppress_defaults=True)
|
|
2353
|
+
|
|
2354
|
+
fix = subparsers.add_parser("fix", help="detectar, corregir y verificar errores automáticamente")
|
|
2355
|
+
fix.add_argument("prompt", nargs="?", default="Detecta y corrige todos los errores reproducibles del proyecto. Ejecuta diagnóstico completo y no termines hasta verificar los cambios.")
|
|
2356
|
+
fix.add_argument("--output")
|
|
2357
|
+
fix.add_argument("--json", action="store_true")
|
|
2358
|
+
add_common_flags(fix, suppress_defaults=True)
|
|
2359
|
+
|
|
2360
|
+
sessions = subparsers.add_parser("sessions", help="listar sesiones guardadas")
|
|
2361
|
+
sessions.add_argument("-C", "--cd", "--workspace", dest="workspace")
|
|
2362
|
+
|
|
2363
|
+
delete = subparsers.add_parser("delete", help="eliminar una sesión guardada")
|
|
2364
|
+
delete.add_argument("session")
|
|
2365
|
+
delete.add_argument("--force", action="store_true")
|
|
2366
|
+
|
|
2367
|
+
# Permite ejecutar sin escribir 'interactive'.
|
|
2368
|
+
add_common_flags(parser)
|
|
2369
|
+
return parser
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
def normalize_args(args: argparse.Namespace) -> argparse.Namespace:
|
|
2373
|
+
if getattr(args, "full_auto", False):
|
|
2374
|
+
args.permission_mode = "workspace-write"
|
|
2375
|
+
args.approval_policy = "on-request"
|
|
2376
|
+
if getattr(args, "dangerously_bypass_approvals_and_sandbox", False):
|
|
2377
|
+
args.permission_mode = "full-access"
|
|
2378
|
+
args.approval_policy = "never"
|
|
2379
|
+
return args
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
def create_agent(args: argparse.Namespace, state: SessionState | None = None, *, connect: bool = True) -> LMCodingAgent:
|
|
2383
|
+
workspace_path = Path(getattr(args, "workspace", ".") or ".").expanduser().resolve()
|
|
2384
|
+
config = AppConfig.from_layers(workspace_path, cli_values_from_args(args))
|
|
2385
|
+
workspace = Workspace(workspace_path, config)
|
|
2386
|
+
agent = LMCodingAgent(workspace, config, state=state)
|
|
2387
|
+
if connect:
|
|
2388
|
+
agent.connect()
|
|
2389
|
+
return agent
|
|
2390
|
+
|
|
2391
|
+
|
|
2392
|
+
def print_connection_error(exc: Exception, base_url: str | None = None) -> None:
|
|
2393
|
+
url = base_url or DEFAULT_BASE_URL
|
|
2394
|
+
console.print(
|
|
2395
|
+
Panel(
|
|
2396
|
+
f"{exc}\n\n"
|
|
2397
|
+
"En LM Studio:\n"
|
|
2398
|
+
"1. Carga un modelo con tool use.\n"
|
|
2399
|
+
"2. Abre Developer.\n"
|
|
2400
|
+
"3. Pulsa Start Server.\n\n"
|
|
2401
|
+
f"URL esperada: {url}",
|
|
2402
|
+
title="[red]No se pudo conectar[/red]",
|
|
2403
|
+
border_style="red",
|
|
2404
|
+
)
|
|
2405
|
+
)
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def command_doctor(args: argparse.Namespace) -> int:
|
|
2410
|
+
agent = create_agent(args, connect=False)
|
|
2411
|
+
connection_note = "LM Studio: conectado"
|
|
2412
|
+
try:
|
|
2413
|
+
agent.connect()
|
|
2414
|
+
except Exception as exc:
|
|
2415
|
+
connection_note = f"LM Studio: no disponible ({type(exc).__name__}: {exc})"
|
|
2416
|
+
result = connection_note + "\n\n" + agent.doctor_report(args.mode)
|
|
2417
|
+
console.print(Panel(Text(result), title=f"{COMMAND_BRAND} Doctor", border_style="bright_magenta"))
|
|
2418
|
+
report = agent.toolbox.last_diagnostic_report
|
|
2419
|
+
return 0 if report is None or report.success or not report.results else 1
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def command_verify(args: argparse.Namespace) -> int:
|
|
2423
|
+
agent = create_agent(args, connect=False)
|
|
2424
|
+
report = agent.verify_now(args.mode, quiet=True)
|
|
2425
|
+
print(report.to_text())
|
|
2426
|
+
if report.blocked:
|
|
2427
|
+
return 2
|
|
2428
|
+
return 0 if report.success or not report.results else 1
|
|
2429
|
+
|
|
2430
|
+
|
|
2431
|
+
def command_fix(args: argparse.Namespace) -> int:
|
|
2432
|
+
args.self_heal = True
|
|
2433
|
+
args.verification_mode = "full"
|
|
2434
|
+
if getattr(args, "max_fix_attempts", None) is None:
|
|
2435
|
+
args.max_fix_attempts = 5
|
|
2436
|
+
try:
|
|
2437
|
+
agent = create_agent(args)
|
|
2438
|
+
result = agent.ask(args.prompt, quiet=True)
|
|
2439
|
+
except Exception as exc:
|
|
2440
|
+
print_connection_error(exc, getattr(args, "base_url", None))
|
|
2441
|
+
return 1
|
|
2442
|
+
if args.output:
|
|
2443
|
+
Path(args.output).write_text(result, encoding="utf-8")
|
|
2444
|
+
if args.json:
|
|
2445
|
+
print(json.dumps({
|
|
2446
|
+
"session_id": agent.state.id,
|
|
2447
|
+
"model": agent.model,
|
|
2448
|
+
"result": result,
|
|
2449
|
+
"verification": agent.state.last_verification,
|
|
2450
|
+
"repair_cycles": agent.state.repair_cycles,
|
|
2451
|
+
}, ensure_ascii=False))
|
|
2452
|
+
else:
|
|
2453
|
+
print(result)
|
|
2454
|
+
report = agent.toolbox.last_diagnostic_report
|
|
2455
|
+
return 0 if report is None or report.success or not report.results else 1
|
|
2456
|
+
|
|
2457
|
+
def command_sessions(args: argparse.Namespace) -> int:
|
|
2458
|
+
store = SessionStore()
|
|
2459
|
+
workspace = Path(args.workspace).expanduser().resolve() if args.workspace else None
|
|
2460
|
+
states = store.list_states(workspace)
|
|
2461
|
+
table = Table(title="Sesiones LMCoding")
|
|
2462
|
+
table.add_column("ID")
|
|
2463
|
+
table.add_column("Nombre")
|
|
2464
|
+
table.add_column("Workspace")
|
|
2465
|
+
table.add_column("Actualizada")
|
|
2466
|
+
table.add_column("Modelo")
|
|
2467
|
+
for state in states:
|
|
2468
|
+
table.add_row(state.id[:8], state.name, state.workspace, state.updated_at[:19], state.model or "-")
|
|
2469
|
+
console.print(table)
|
|
2470
|
+
return 0
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
def command_delete(args: argparse.Namespace) -> int:
|
|
2474
|
+
store = SessionStore()
|
|
2475
|
+
try:
|
|
2476
|
+
state = store.load(args.session)
|
|
2477
|
+
except Exception as exc:
|
|
2478
|
+
console.print(f"[red]{exc}[/red]")
|
|
2479
|
+
return 1
|
|
2480
|
+
if not args.force and not Confirm.ask(f"¿Eliminar la sesión '{state.name}'?", default=False):
|
|
2481
|
+
return 1
|
|
2482
|
+
store.delete(state.id)
|
|
2483
|
+
console.print("[green]Sesión eliminada.[/green]")
|
|
2484
|
+
return 0
|
|
2485
|
+
|
|
2486
|
+
|
|
2487
|
+
def command_exec(args: argparse.Namespace) -> int:
|
|
2488
|
+
prompt = args.prompt
|
|
2489
|
+
if not prompt:
|
|
2490
|
+
prompt = sys.stdin.read().strip()
|
|
2491
|
+
if not prompt:
|
|
2492
|
+
console.print("[red]Falta una tarea.[/red]")
|
|
2493
|
+
return 2
|
|
2494
|
+
try:
|
|
2495
|
+
agent = create_agent(args)
|
|
2496
|
+
result = agent.ask(prompt, quiet=True)
|
|
2497
|
+
except Exception as exc:
|
|
2498
|
+
print_connection_error(exc, getattr(args, "base_url", None))
|
|
2499
|
+
return 1
|
|
2500
|
+
|
|
2501
|
+
if args.output:
|
|
2502
|
+
Path(args.output).write_text(result, encoding="utf-8")
|
|
2503
|
+
if args.json:
|
|
2504
|
+
print(
|
|
2505
|
+
json.dumps(
|
|
2506
|
+
{
|
|
2507
|
+
"session_id": agent.state.id,
|
|
2508
|
+
"model": agent.model,
|
|
2509
|
+
"result": result,
|
|
2510
|
+
"prompt_tokens": agent.state.total_prompt_tokens,
|
|
2511
|
+
"completion_tokens": agent.state.total_completion_tokens,
|
|
2512
|
+
},
|
|
2513
|
+
ensure_ascii=False,
|
|
2514
|
+
)
|
|
2515
|
+
)
|
|
2516
|
+
else:
|
|
2517
|
+
print(result)
|
|
2518
|
+
return 0
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
def command_resume(args: argparse.Namespace) -> int:
|
|
2522
|
+
workspace = Path(args.workspace).expanduser().resolve()
|
|
2523
|
+
store = SessionStore()
|
|
2524
|
+
try:
|
|
2525
|
+
state = store.latest(workspace) if args.last or not args.session else store.load(args.session)
|
|
2526
|
+
if Path(state.workspace).resolve() != workspace:
|
|
2527
|
+
console.print("[red]La sesión pertenece a otro workspace.[/red]")
|
|
2528
|
+
return 1
|
|
2529
|
+
agent = create_agent(args, state=state)
|
|
2530
|
+
except Exception as exc:
|
|
2531
|
+
print_connection_error(exc, getattr(args, "base_url", None))
|
|
2532
|
+
return 1
|
|
2533
|
+
return InteractiveApp(agent).run()
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
def main() -> int:
|
|
2537
|
+
ensure_home()
|
|
2538
|
+
parser = build_parser()
|
|
2539
|
+
args = normalize_args(parser.parse_args())
|
|
2540
|
+
|
|
2541
|
+
if args.subcommand == "doctor":
|
|
2542
|
+
return command_doctor(args)
|
|
2543
|
+
if args.subcommand == "verify":
|
|
2544
|
+
return command_verify(args)
|
|
2545
|
+
if args.subcommand == "fix":
|
|
2546
|
+
return command_fix(args)
|
|
2547
|
+
if args.subcommand == "sessions":
|
|
2548
|
+
return command_sessions(args)
|
|
2549
|
+
if args.subcommand == "delete":
|
|
2550
|
+
return command_delete(args)
|
|
2551
|
+
if args.subcommand == "exec":
|
|
2552
|
+
return command_exec(args)
|
|
2553
|
+
if args.subcommand == "resume":
|
|
2554
|
+
return command_resume(args)
|
|
2555
|
+
|
|
2556
|
+
try:
|
|
2557
|
+
agent = create_agent(args)
|
|
2558
|
+
except Exception as exc:
|
|
2559
|
+
print_connection_error(exc, getattr(args, "base_url", None))
|
|
2560
|
+
return 1
|
|
2561
|
+
return InteractiveApp(agent).run()
|
|
2562
|
+
|
|
2563
|
+
|
|
2564
|
+
if __name__ == "__main__":
|
|
2565
|
+
raise SystemExit(main())
|