local-delegate-mcp 0.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.
@@ -0,0 +1,12 @@
1
+ """local-delegate — MCP que delega tareas mecánicas texto->texto a un LLM local.
2
+
3
+ Cliente genérico de cualquier endpoint OpenAI-compatible (llama-swap, Ollama,
4
+ LM Studio, vLLM). Ver README para configuración.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .server import main
10
+
11
+ __all__ = ["main"]
12
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Permite ejecutar el server con ``python -m local_delegate``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .server import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,83 @@
1
+ """autostart.py — arranque OPCIONAL de un backend local (opt-in).
2
+
3
+ Desactivado por defecto (``LOCAL_DELEGATE_AUTOSTART=0``). El paquete asume que tu
4
+ endpoint OpenAI-compatible ya está corriendo. Este módulo es una comodidad para quien
5
+ use **llama-swap** y quiera que el MCP lo levante solo; es específico de ese backend.
6
+ Para Ollama / LM Studio / vLLM, arranca el servicio por tu cuenta.
7
+
8
+ Config por entorno:
9
+ LLAMASWAP_EXE ruta al ejecutable (si no, se busca ``llama-swap`` en el PATH)
10
+ LLAMASWAP_CONFIG ruta al config.yaml de llama-swap (opcional)
11
+ LLAMASWAP_LISTEN host:puerto en que escucha llama-swap (default 127.0.0.1:9292)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+
24
+ from . import config
25
+
26
+
27
+ def _backend_up() -> bool:
28
+ """True si el endpoint OpenAI-compatible responde a /models."""
29
+ try:
30
+ with httpx.Client(timeout=2.0) as c:
31
+ return c.get(f"{config.BASE_URL}/models").is_success
32
+ except httpx.HTTPError:
33
+ return False
34
+
35
+
36
+ def _find_exe() -> str | None:
37
+ for cand in (os.environ.get("LLAMASWAP_EXE", ""), "llama-swap"):
38
+ if cand and (cand == "llama-swap" or Path(cand).is_file()):
39
+ return cand
40
+ return None
41
+
42
+
43
+ def ensure_backend(wait: float = 0.0) -> bool:
44
+ """Si el backend no responde, intenta arrancar llama-swap como proceso independiente.
45
+
46
+ Idempotente: si ya está arriba no hace nada. Con varias instancias del MCP la primera
47
+ gana el puerto y las demás ven que ya está arriba. Devuelve True si acaba escuchando.
48
+ Solo se invoca cuando ``LOCAL_DELEGATE_AUTOSTART`` está activo.
49
+ """
50
+ if _backend_up():
51
+ return True
52
+ exe = _find_exe()
53
+ if exe:
54
+ listen = os.environ.get("LLAMASWAP_LISTEN", "127.0.0.1:9292")
55
+ cfg = os.environ.get("LLAMASWAP_CONFIG", "")
56
+ args = [exe]
57
+ if cfg:
58
+ args += ["--config", cfg]
59
+ args += ["--listen", listen]
60
+ flags = 0
61
+ if sys.platform == "win32":
62
+ # CREATE_NO_WINDOW da a llama-swap una consola OCULTA que sus hijos (p. ej.
63
+ # nvidia-smi para el GPU monitoring) heredan -> sin ventana emergente. No usar
64
+ # DETACHED_PROCESS: sin consola, nvidia-smi crearía su propia ventana huérfana.
65
+ # Sigue sobreviviendo al MCP porque Windows no mata a los hijos al salir el padre.
66
+ flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
67
+ try:
68
+ subprocess.Popen(
69
+ args,
70
+ creationflags=flags,
71
+ stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ close_fds=True,
75
+ )
76
+ except OSError:
77
+ return _backend_up()
78
+ deadline = time.monotonic() + wait
79
+ while time.monotonic() < deadline:
80
+ if _backend_up():
81
+ return True
82
+ time.sleep(1.0)
83
+ return _backend_up()
@@ -0,0 +1,91 @@
1
+ """config.py — configuración por variables de entorno, sin rutas hardcodeadas.
2
+
3
+ local-delegate es un cliente GENÉRICO de cualquier endpoint OpenAI-compatible
4
+ (llama-swap, Ollama, LM Studio, vLLM…). Todo se configura por variables de entorno
5
+ con defaults sensatos y multiplataforma. El log de uso vive en el directorio de datos
6
+ del usuario (vía ``platformdirs``), nunca en una ruta absoluta de una máquina concreta.
7
+
8
+ Los defaults del catálogo de modelos son solo eso, *defaults documentados*: apuntan a
9
+ los ids de un setup de referencia con llama-swap. Cámbialos por env para tu backend
10
+ (p. ej. ``LOCAL_DELEGATE_MODEL_MECHANICAL=llama3.1`` con Ollama).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ from platformdirs import user_data_dir
19
+
20
+ APP_NAME = "local-delegate"
21
+
22
+
23
+ def _env(name: str, default: str) -> str:
24
+ return os.environ.get(name, default)
25
+
26
+
27
+ def _env_int(name: str, default: int) -> int:
28
+ try:
29
+ return int(os.environ.get(name, str(default)))
30
+ except ValueError:
31
+ return default
32
+
33
+
34
+ def _env_flag(name: str, default: bool) -> bool:
35
+ raw = os.environ.get(name)
36
+ if raw is None:
37
+ return default
38
+ return raw.strip().lower() not in {"0", "false", "no", "off", ""}
39
+
40
+
41
+ # --- Endpoint OpenAI-compatible ---------------------------------------------
42
+ BASE_URL = _env("LOCAL_DELEGATE_BASE_URL", "http://127.0.0.1:9292/v1").rstrip("/")
43
+ API_KEY = _env("LOCAL_DELEGATE_API_KEY", "") # opcional; algunos endpoints lo exigen
44
+ HTTP_TIMEOUT = float(_env("LOCAL_DELEGATE_TIMEOUT", "180"))
45
+
46
+
47
+ # --- Log de uso/ahorro (JSONL) ----------------------------------------------
48
+ def _default_log() -> Path:
49
+ return Path(user_data_dir(APP_NAME, appauthor=False)) / "usage.jsonl"
50
+
51
+
52
+ _log_env = os.environ.get("LOCAL_DELEGATE_LOG")
53
+ USAGE_LOG: Path = Path(_log_env) if _log_env else _default_log()
54
+
55
+
56
+ # --- Catálogo de modelos (roles configurables, defaults documentados) -------
57
+ MODEL_MECHANICAL = _env(
58
+ "LOCAL_DELEGATE_MODEL_MECHANICAL", "gemma3-4b"
59
+ ) # clasificar, extraer, resumen corto
60
+ MODEL_LONG = _env("LOCAL_DELEGATE_MODEL_LONG", "llama31-8b") # documentos largos (ctx amplio)
61
+ MODEL_CODE = _env("LOCAL_DELEGATE_MODEL_CODE", "qwen25-coder-14b") # código / boilerplate
62
+ MODEL_FAST = _env("LOCAL_DELEGATE_MODEL_FAST", "qwen35-2b") # ultrarrápido / trivial
63
+ ALLOWED_MODELS: set[str] = {MODEL_MECHANICAL, MODEL_LONG, MODEL_CODE, MODEL_FAST}
64
+
65
+ # Umbral para elegir el modelo "largo" vs "mecánico" en tools que enrutan por tamaño.
66
+ LONG_INPUT_CHARS = _env_int("LOCAL_DELEGATE_LONG_INPUT_CHARS", 6000)
67
+
68
+ # Tope de entrada por modelo (evita desbordar el ctx del backend).
69
+ _MAX_CHARS_DEFAULT = 20000
70
+ MAX_CHARS: dict[str, int] = {
71
+ MODEL_MECHANICAL: _env_int("LOCAL_DELEGATE_MAX_CHARS_MECHANICAL", 20000),
72
+ MODEL_LONG: _env_int("LOCAL_DELEGATE_MAX_CHARS_LONG", 48000),
73
+ MODEL_CODE: _env_int("LOCAL_DELEGATE_MAX_CHARS_CODE", 20000),
74
+ MODEL_FAST: _env_int("LOCAL_DELEGATE_MAX_CHARS_FAST", 12000),
75
+ }
76
+
77
+
78
+ def max_chars_for(model: str) -> int:
79
+ """Tope de caracteres de entrada para un modelo (default si no está en el catálogo)."""
80
+ return MAX_CHARS.get(model, _MAX_CHARS_DEFAULT)
81
+
82
+
83
+ # --- Web de métricas embebida -----------------------------------------------
84
+ WEB_ENABLED = _env_flag("LOCAL_DELEGATE_WEB", True)
85
+ WEB_HOST = _env("LOCAL_DELEGATE_WEB_HOST", "127.0.0.1")
86
+ WEB_PORT = _env_int("LOCAL_DELEGATE_WEB_PORT", 9393)
87
+ CHARS_PER_TOKEN = 4 # aproximación: tokens ~ chars / 4
88
+
89
+
90
+ # --- Auto-arranque del backend (opt-in, específico de llama-swap) -----------
91
+ AUTOSTART = _env_flag("LOCAL_DELEGATE_AUTOSTART", False)
@@ -0,0 +1,479 @@
1
+ """local-delegate — servidor MCP stdio.
2
+
3
+ Expone un endpoint LLM local OpenAI-compatible (llama-swap, Ollama, LM Studio, vLLM…)
4
+ como herramientas texto->texto para que Claude Code delegue pasos acotados (resumir,
5
+ clasificar, extraer, boilerplate) y conserve cuota de la suscripción. Los modelos locales
6
+ NO usan tool-calling: el server arma el prompt + guardrails, hace POST al endpoint y
7
+ devuelve SOLO texto.
8
+
9
+ summarize/extract/… pueden leer el archivo del lado del servidor (vía 'path') para que el
10
+ input grande NUNCA entre al contexto de Claude.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+ from mcp.server.fastmcp import FastMCP
22
+
23
+ from . import autostart, config
24
+
25
+ mcp = FastMCP("local-delegate")
26
+
27
+
28
+ # --- Helpers ----------------------------------------------------------------
29
+ def _read_input(text: str | None, path: str | None, max_chars: int) -> str:
30
+ """Devuelve el contenido a procesar. Si viene 'path', lo lee server-side."""
31
+ if path:
32
+ p = Path(path)
33
+ if not p.is_file():
34
+ raise ValueError(f"No existe el archivo: {path}")
35
+ content = p.read_text(encoding="utf-8", errors="replace")
36
+ elif text is not None:
37
+ content = text
38
+ else:
39
+ raise ValueError("Debes proporcionar 'text' o 'path'.")
40
+ if len(content) > max_chars:
41
+ content = content[:max_chars] + "\n[...contenido truncado...]"
42
+ return content
43
+
44
+
45
+ def _log_event(
46
+ *, tool: str, model: str, source: str, chars_in: int, chars_out: int, latency_ms: int, ok: bool
47
+ ) -> None:
48
+ """Escribe una línea JSONL en USAGE_LOG. Nunca debe romper una tool."""
49
+ try:
50
+ rec = {
51
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
52
+ "tool": tool,
53
+ "model": model,
54
+ "source": source, # "path" = leído server-side (no entró al contexto de Claude)
55
+ "chars_in": int(chars_in),
56
+ "chars_out": int(chars_out),
57
+ "latency_ms": int(latency_ms),
58
+ "ok": bool(ok),
59
+ }
60
+ config.USAGE_LOG.parent.mkdir(parents=True, exist_ok=True)
61
+ with config.USAGE_LOG.open("a", encoding="utf-8") as f:
62
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
63
+ except OSError:
64
+ pass # el logging es best-effort; jamás propaga
65
+
66
+
67
+ def _post_chat(model: str, payload: dict) -> str:
68
+ """POST al endpoint /chat/completions con reintento opcional si el backend está caído."""
69
+ headers = {"Authorization": f"Bearer {config.API_KEY}"} if config.API_KEY else {}
70
+ for attempt in (1, 2):
71
+ try:
72
+ with httpx.Client(timeout=config.HTTP_TIMEOUT) as client:
73
+ r = client.post(
74
+ f"{config.BASE_URL}/chat/completions", json=payload, headers=headers
75
+ )
76
+ r.raise_for_status()
77
+ return r.json()["choices"][0]["message"]["content"].strip()
78
+ except httpx.ConnectError:
79
+ # El backend no está escuchando. Si el auto-arranque está activo, intenta
80
+ # levantarlo (opt-in, específico de llama-swap) y reintenta una vez.
81
+ if attempt == 1 and config.AUTOSTART and autostart.ensure_backend(wait=30):
82
+ continue
83
+ return (
84
+ f"[local-delegate error] no se pudo conectar al endpoint ({config.BASE_URL}). "
85
+ "¿Está corriendo tu backend OpenAI-compatible?"
86
+ )
87
+ except httpx.HTTPStatusError as e:
88
+ return f"[local-delegate error] {model} respondió {e.response.status_code}: {e.response.text[:300]}"
89
+ except httpx.HTTPError as e:
90
+ return f"[local-delegate error] fallo de conexión al endpoint ({config.BASE_URL}): {e}"
91
+ except (KeyError, IndexError, ValueError) as e:
92
+ return f"[local-delegate error] respuesta inesperada de {model}: {e}"
93
+ return f"[local-delegate error] no se pudo completar la petición a {model}."
94
+
95
+
96
+ def _chat(
97
+ model: str,
98
+ system: str,
99
+ user: str,
100
+ max_tokens: int,
101
+ temperature: float = 0.2,
102
+ *,
103
+ tool: str = "local_delegate",
104
+ chars_in: int = 0,
105
+ source: str = "inline",
106
+ ) -> str:
107
+ """POST al endpoint. Devuelve solo texto y registra la llamada en USAGE_LOG."""
108
+ payload = {
109
+ "model": model,
110
+ "messages": [
111
+ {"role": "system", "content": system},
112
+ {"role": "user", "content": user},
113
+ ],
114
+ "max_tokens": max_tokens,
115
+ "temperature": temperature,
116
+ "stream": False,
117
+ }
118
+ t0 = time.monotonic()
119
+ result = _post_chat(model, payload)
120
+ latency_ms = int((time.monotonic() - t0) * 1000)
121
+ ok = not result.startswith("[local-delegate error]")
122
+ _log_event(
123
+ tool=tool,
124
+ model=model,
125
+ source=source,
126
+ chars_in=chars_in,
127
+ chars_out=len(result),
128
+ latency_ms=latency_ms,
129
+ ok=ok,
130
+ )
131
+ return result
132
+
133
+
134
+ def _strip_fences(s: str) -> str:
135
+ """Quita fences markdown (```json / ```python / ```) que a veces envuelven la salida."""
136
+ s = s.strip()
137
+ if s.startswith("```"):
138
+ lines = s.splitlines()
139
+ lines = lines[1:] # descarta la línea de apertura del fence
140
+ if lines and lines[-1].strip().startswith("```"):
141
+ lines = lines[:-1]
142
+ s = "\n".join(lines).strip()
143
+ return s
144
+
145
+
146
+ def _guard(formato: str, max_words: int | None = None) -> str:
147
+ limite = f" Máximo {max_words} palabras." if max_words else ""
148
+ return (
149
+ "Responde directo desde el input. NO uses herramientas, NO busques en internet. "
150
+ f"Output EXACTO: {formato}.{limite} Nada fuera del formato."
151
+ )
152
+
153
+
154
+ # --- Tools ------------------------------------------------------------------
155
+ @mcp.tool()
156
+ def local_summarize(
157
+ text: str | None = None,
158
+ path: str | None = None,
159
+ max_words: int = 150,
160
+ ) -> str:
161
+ """Resume texto o el contenido de un archivo con un modelo local, sin gastar contexto de Claude.
162
+
163
+ Usa esto para resumir archivos/documentos grandes: pasa 'path' y el archivo se lee del lado
164
+ del servidor, de modo que el contenido completo NO entra al contexto de Claude (solo vuelve el
165
+ resumen corto). Alternativamente pasa 'text'. Enruta al modelo mecánico (entradas cortas) o al
166
+ modelo de contexto largo (documentos grandes) automáticamente.
167
+
168
+ Args:
169
+ text: Texto a resumir (usa esto o 'path').
170
+ path: Ruta a un archivo cuyo contenido se resume (leído server-side).
171
+ max_words: Longitud máxima del resumen en palabras.
172
+ """
173
+ probe = path and Path(path).is_file()
174
+ raw_len = Path(path).stat().st_size if probe else len(text or "")
175
+ model = config.MODEL_LONG if raw_len > config.LONG_INPUT_CHARS else config.MODEL_MECHANICAL
176
+ content = _read_input(text, path, config.max_chars_for(model))
177
+ system = _guard("un resumen en prosa clara", max_words)
178
+ user = f"Resume el siguiente contenido:\n\n{content}"
179
+ return _chat(
180
+ model,
181
+ system,
182
+ user,
183
+ max_tokens=int(max_words * 2) + 64,
184
+ tool="local_summarize",
185
+ chars_in=len(content),
186
+ source="path" if path else "inline",
187
+ )
188
+
189
+
190
+ @mcp.tool()
191
+ def local_classify(text: str, labels: list[str]) -> str:
192
+ """Clasifica un texto en UNA de las etiquetas dadas, con un modelo local.
193
+
194
+ Devuelve exactamente una etiqueta de la lista, sin texto adicional.
195
+
196
+ Args:
197
+ text: Texto a clasificar.
198
+ labels: Lista de etiquetas candidatas.
199
+ """
200
+ etiquetas = ", ".join(labels)
201
+ system = _guard(f"exactamente una de estas etiquetas: [{etiquetas}]", max_words=5)
202
+ user = f"Clasifica este texto:\n\n{text}"
203
+ return _chat(
204
+ config.MODEL_MECHANICAL,
205
+ system,
206
+ user,
207
+ max_tokens=16,
208
+ temperature=0.0,
209
+ tool="local_classify",
210
+ chars_in=len(text),
211
+ source="inline",
212
+ )
213
+
214
+
215
+ @mcp.tool()
216
+ def local_extract(
217
+ fields: list[str],
218
+ text: str | None = None,
219
+ path: str | None = None,
220
+ ) -> str:
221
+ """Extrae campos estructurados de un texto/archivo como JSON, con un modelo local.
222
+
223
+ Pasa 'path' para leer el archivo server-side (no gasta contexto de Claude) o 'text'.
224
+ Devuelve un objeto JSON con exactamente las claves pedidas.
225
+
226
+ Args:
227
+ fields: Nombres de los campos a extraer (claves del JSON).
228
+ text: Texto fuente (usa esto o 'path').
229
+ path: Ruta a un archivo fuente (leído server-side).
230
+ """
231
+ content = _read_input(text, path, config.max_chars_for(config.MODEL_MECHANICAL))
232
+ claves = ", ".join(f'"{f}"' for f in fields)
233
+ system = _guard(f"un objeto JSON válido con exactamente estas claves: {{{claves}}}")
234
+ user = f"Extrae los campos del siguiente contenido:\n\n{content}"
235
+ return _strip_fences(
236
+ _chat(
237
+ config.MODEL_MECHANICAL,
238
+ system,
239
+ user,
240
+ max_tokens=512,
241
+ temperature=0.0,
242
+ tool="local_extract",
243
+ chars_in=len(content),
244
+ source="path" if path else "inline",
245
+ )
246
+ )
247
+
248
+
249
+ @mcp.tool()
250
+ def local_boilerplate(spec: str, language: str) -> str:
251
+ """Genera código boilerplate a partir de una especificación, con un modelo local de código.
252
+
253
+ Devuelve solo el código, sin explicaciones ni fences markdown.
254
+
255
+ Args:
256
+ spec: Descripción de lo que debe generar el código.
257
+ language: Lenguaje de programación (p. ej. 'python', 'typescript').
258
+ """
259
+ system = _guard(f"solo código {language} válido, sin explicaciones ni ```")
260
+ user = f"Genera {language} para: {spec}"
261
+ return _strip_fences(
262
+ _chat(
263
+ config.MODEL_CODE,
264
+ system,
265
+ user,
266
+ max_tokens=1536,
267
+ temperature=0.1,
268
+ tool="local_boilerplate",
269
+ chars_in=len(spec),
270
+ source="inline",
271
+ )
272
+ )
273
+
274
+
275
+ @mcp.tool()
276
+ def local_delegate(
277
+ task: str,
278
+ input: str,
279
+ output_format: str,
280
+ model: str | None = None,
281
+ ) -> str:
282
+ """Tool genérica de escape: delega una tarea texto->texto a un modelo local.
283
+
284
+ Úsala cuando ninguna tool específica encaje. Arma el prompt con guardrails y devuelve texto.
285
+
286
+ Args:
287
+ task: Instrucción de la tarea (una frase con formato de salida explícito).
288
+ input: Contenido sobre el que operar.
289
+ output_format: Formato exacto de salida esperado.
290
+ model: Modelo a usar; uno de los ids configurados en el catálogo. Por defecto el mecánico.
291
+ """
292
+ chosen = model or config.MODEL_MECHANICAL
293
+ if chosen not in config.ALLOWED_MODELS:
294
+ return f"[local-delegate error] modelo inválido '{chosen}'. Válidos: {sorted(config.ALLOWED_MODELS)}"
295
+ system = _guard(output_format)
296
+ user = f"{task}\n\nInput:\n{input}"
297
+ return _chat(
298
+ chosen,
299
+ system,
300
+ user,
301
+ max_tokens=1024,
302
+ tool="local_delegate",
303
+ chars_in=len(input),
304
+ source="inline",
305
+ )
306
+
307
+
308
+ @mcp.tool()
309
+ def local_lint_summary(
310
+ path: str | None = None,
311
+ text: str | None = None,
312
+ max_words: int = 200,
313
+ ) -> str:
314
+ """Resume salida de linters/tests/CI con un modelo local, sin gastar contexto de Claude.
315
+
316
+ Pensada para logs largos y ruidosos (ESLint, clippy, pytest, tsc, CI). Pasa 'path' y el
317
+ archivo se lee del lado del servidor, de modo que el log completo NO entra al contexto de
318
+ Claude: solo vuelve un resumen agrupado por archivo con el conteo por tipo de error/regla y
319
+ lo más importante primero. Alternativamente pasa 'text'. Enruta al modelo mecánico (corto) o
320
+ al de contexto largo (largo) automáticamente.
321
+
322
+ Args:
323
+ path: Ruta al archivo de salida de lint/tests (leído server-side). Usa esto o 'text'.
324
+ text: Salida de lint/tests como texto.
325
+ max_words: Longitud máxima del resumen en palabras.
326
+ """
327
+ probe = path and Path(path).is_file()
328
+ raw_len = Path(path).stat().st_size if probe else len(text or "")
329
+ model = config.MODEL_LONG if raw_len > config.LONG_INPUT_CHARS else config.MODEL_MECHANICAL
330
+ content = _read_input(text, path, config.max_chars_for(model))
331
+ system = _guard(
332
+ "un resumen de los problemas agrupados por archivo, con el conteo por tipo de "
333
+ "error/regla y los más relevantes primero",
334
+ max_words,
335
+ )
336
+ user = f"Resume la siguiente salida de linter/tests:\n\n{content}"
337
+ return _chat(
338
+ model,
339
+ system,
340
+ user,
341
+ max_tokens=int(max_words * 2) + 96,
342
+ tool="local_lint_summary",
343
+ chars_in=len(content),
344
+ source="path" if path else "inline",
345
+ )
346
+
347
+
348
+ @mcp.tool()
349
+ def local_commit_msg(
350
+ diff: str | None = None,
351
+ path: str | None = None,
352
+ style: str = "conventional",
353
+ ) -> str:
354
+ """Redacta un mensaje de commit a partir de un diff, con un modelo local de código.
355
+
356
+ Pasa 'path' a un archivo de diff (p. ej. la salida de `git diff` volcada a fichero) y se lee
357
+ server-side, de modo que el diff completo NO entra al contexto de Claude. Alternativamente
358
+ pasa 'diff' como texto. Revisa SIEMPRE el mensaje antes de usarlo.
359
+
360
+ Args:
361
+ diff: El diff como texto (usa esto o 'path').
362
+ path: Ruta a un archivo con el diff (leído server-side).
363
+ style: 'conventional' (Conventional Commits) o 'plain'.
364
+ """
365
+ content = _read_input(diff, path, config.max_chars_for(config.MODEL_CODE))
366
+ if style == "conventional":
367
+ fmt = (
368
+ "un mensaje de commit estilo Conventional Commits: primera línea "
369
+ "'tipo(scope): resumen' en imperativo y <=72 caracteres; cuerpo opcional con "
370
+ "viñetas '- '"
371
+ )
372
+ else:
373
+ fmt = (
374
+ "un mensaje de commit: primera línea imperativa <=72 caracteres y cuerpo "
375
+ "opcional con viñetas"
376
+ )
377
+ system = _guard(fmt)
378
+ user = f"Escribe el mensaje de commit para este diff:\n\n{content}"
379
+ return _chat(
380
+ config.MODEL_CODE,
381
+ system,
382
+ user,
383
+ max_tokens=256,
384
+ temperature=0.2,
385
+ tool="local_commit_msg",
386
+ chars_in=len(content),
387
+ source="path" if path else "inline",
388
+ )
389
+
390
+
391
+ @mcp.tool()
392
+ def local_translate(
393
+ target_lang: str,
394
+ text: str | None = None,
395
+ path: str | None = None,
396
+ ) -> str:
397
+ """Traduce texto o el contenido de un archivo con un modelo local, sin gastar contexto de Claude.
398
+
399
+ Pasa 'path' para leer el archivo server-side (el original no entra al contexto de Claude) o
400
+ 'text'. Conserva el formato del original y devuelve SOLO la traducción. Enruta al modelo
401
+ mecánico (corto) o al de contexto largo (largo) automáticamente.
402
+
403
+ Args:
404
+ target_lang: Idioma destino (p. ej. 'español', 'inglés', 'francés').
405
+ text: Texto a traducir (usa esto o 'path').
406
+ path: Ruta a un archivo cuyo contenido se traduce (leído server-side).
407
+ """
408
+ probe = path and Path(path).is_file()
409
+ raw_len = Path(path).stat().st_size if probe else len(text or "")
410
+ model = config.MODEL_LONG if raw_len > config.LONG_INPUT_CHARS else config.MODEL_MECHANICAL
411
+ content = _read_input(text, path, config.max_chars_for(model))
412
+ system = _guard(
413
+ f"la traducción fiel al {target_lang}, conservando el formato y sin comentarios"
414
+ )
415
+ user = f"Traduce al {target_lang} el siguiente texto:\n\n{content}"
416
+ return _chat(
417
+ model,
418
+ system,
419
+ user,
420
+ max_tokens=min(len(content) // 2 + 128, 2048),
421
+ tool="local_translate",
422
+ chars_in=len(content),
423
+ source="path" if path else "inline",
424
+ )
425
+
426
+
427
+ @mcp.tool()
428
+ def local_explain_code(
429
+ code: str | None = None,
430
+ path: str | None = None,
431
+ question: str | None = None,
432
+ ) -> str:
433
+ """Explica en prosa qué hace un fragmento/archivo de código, con un modelo local de código.
434
+
435
+ Pasa 'path' para leer el archivo server-side (el código completo NO entra al contexto de
436
+ Claude; solo vuelve la explicación) o 'code'. Opcionalmente enfoca la explicación con
437
+ 'question'. Revisa la explicación: la genera un modelo local.
438
+
439
+ Args:
440
+ code: Código a explicar (usa esto o 'path').
441
+ path: Ruta a un archivo de código (leído server-side).
442
+ question: Pregunta o foco concreto (opcional).
443
+ """
444
+ content = _read_input(code, path, config.max_chars_for(config.MODEL_CODE))
445
+ extra = f" Enfócate en: {question}." if question else ""
446
+ system = _guard(
447
+ f"una explicación clara en prosa de qué hace el código y cómo.{extra}", max_words=250
448
+ )
449
+ user = f"Explica el siguiente código:\n\n{content}"
450
+ return _chat(
451
+ config.MODEL_CODE,
452
+ system,
453
+ user,
454
+ max_tokens=700,
455
+ tool="local_explain_code",
456
+ chars_in=len(content),
457
+ source="path" if path else "inline",
458
+ )
459
+
460
+
461
+ def main() -> None:
462
+ """Punto de entrada del MCP stdio (usado por [project.scripts] local-delegate)."""
463
+ # Auto-arranque del backend solo si el usuario lo pidió explícitamente (opt-in).
464
+ if config.AUTOSTART:
465
+ autostart.ensure_backend(wait=0)
466
+ # Web de métricas embebida en un hilo daemon: vive y muere con este proceso MCP.
467
+ # Si el puerto ya está ocupado (otra instancia de Claude), run_in_thread devuelve None.
468
+ if config.WEB_ENABLED:
469
+ try:
470
+ from .web import metrics
471
+
472
+ metrics.run_in_thread(host=config.WEB_HOST, port=config.WEB_PORT)
473
+ except Exception:
474
+ pass # la web nunca debe impedir que arranque el MCP
475
+ mcp.run()
476
+
477
+
478
+ if __name__ == "__main__":
479
+ main()
@@ -0,0 +1 @@
1
+ """Web de métricas embebida (dashboard de uso/ahorro)."""