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())