soma-lite 1.0.0

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.
package/soma_lite.py ADDED
@@ -0,0 +1,600 @@
1
+ import os
2
+ import json
3
+ import subprocess
4
+ import re
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ import traceback
8
+ import shutil
9
+
10
+
11
+ class SimulatedStatefulTerminal:
12
+ """
13
+ Terminal simulada con estado persistente.
14
+ Mantiene cwd, variables de entorno e historial entre comandos.
15
+ """
16
+ def __init__(self, initial_cwd: str):
17
+ self.initial_cwd = Path(initial_cwd).resolve()
18
+ self.cwd = self.initial_cwd
19
+ self.env_vars = {}
20
+ self.command_history = []
21
+ self.last_output = ""
22
+ self.last_command = ""
23
+
24
+ def get_prompt_info(self) -> dict:
25
+ """Devuelve información para mostrar en el prompt del agente."""
26
+ # Listar archivos en directorio actual (máximo 10)
27
+ try:
28
+ files = []
29
+ for i, f in enumerate(self.cwd.iterdir()):
30
+ if i >= 10:
31
+ files.append(f"... y {sum(1 for _ in self.cwd.iterdir()) - 10} más")
32
+ break
33
+ if f.is_dir():
34
+ files.append(f"📁 {f.name}/")
35
+ else:
36
+ size = f.stat().st_size
37
+ files.append(f"📄 {f.name} ({size}b)")
38
+ except Exception:
39
+ files = ["[Error listando archivos]"]
40
+
41
+ return {
42
+ "cwd": str(self.cwd),
43
+ "relative_cwd": self.cwd.relative_to(self.initial_cwd) if str(self.cwd).startswith(str(self.initial_cwd)) else self.cwd,
44
+ "files": files,
45
+ "env_count": len(self.env_vars),
46
+ "history_count": len(self.command_history)
47
+ }
48
+
49
+ def execute(self, command: str) -> str:
50
+ """Ejecuta un comando manteniendo el estado."""
51
+ self.last_command = command
52
+
53
+ # Lista de comandos potencialmente peligrosos o que cuelgan
54
+ dangerous_patterns = [
55
+ (r'^python\s*$', "python sin argumentos entra en modo interactivo"),
56
+ (r'^python3\s*$', "python3 sin argumentos entra en modo interactivo"),
57
+ (r'^node\s*$', "node sin argumentos entra en modo interactivo"),
58
+ (r'^bash\s*$', "bash sin argumentos entra en modo interactivo"),
59
+ (r'^sh\s*$', "sh sin argumentos entra en modo interactivo"),
60
+ ]
61
+
62
+ for pattern, reason in dangerous_patterns:
63
+ if re.match(pattern, command.strip(), re.IGNORECASE):
64
+ return f"⚠️ COMANDO BLOQUEADO: {reason}. El comando no se ejecutó para evitar que el terminal se quede colgado."
65
+
66
+ # Pre-procesar: expandir variables $VAR
67
+ expanded_command = command
68
+ for key, val in self.env_vars.items():
69
+ expanded_command = expanded_command.replace(f"${key}", val)
70
+ expanded_command = expanded_command.replace(f"${{{key}}}", val)
71
+
72
+ # Detectar y procesar 'cd'
73
+ cd_match = re.match(r'^cd\s+(.+)$', expanded_command.strip())
74
+ if cd_match:
75
+ target_path = cd_match.group(1).strip()
76
+ result = self._change_directory(target_path)
77
+ self.command_history.append({"cmd": command, "cwd": str(self.cwd), "result": result})
78
+ return result
79
+
80
+ # Detectar 'export VAR=valor'
81
+ export_match = re.match(r'^export\s+(\w+)=(.+)$', expanded_command.strip())
82
+ if export_match:
83
+ key, val = export_match.groups()
84
+ # Eliminar comillas si las hay
85
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
86
+ val = val[1:-1]
87
+ self.env_vars[key] = val
88
+ result = f"✓ Variable de entorno establecida: {key}={val}"
89
+ self.command_history.append({"cmd": command, "cwd": str(self.cwd), "result": result})
90
+ return result
91
+
92
+ # Detectar 'unset VAR'
93
+ unset_match = re.match(r'^unset\s+(\w+)$', expanded_command.strip())
94
+ if unset_match:
95
+ key = unset_match.group(1)
96
+ if key in self.env_vars:
97
+ del self.env_vars[key]
98
+ result = f"✓ Variable {key} eliminada"
99
+ else:
100
+ result = f"Variable {key} no estaba definida"
101
+ self.command_history.append({"cmd": command, "cwd": str(self.cwd), "result": result})
102
+ return result
103
+
104
+ # Para cualquier otro comando, ejecutar con subprocess
105
+ try:
106
+ # Preparar el entorno con las variables guardadas
107
+ env = os.environ.copy()
108
+ env.update(self.env_vars)
109
+
110
+ result = subprocess.run(
111
+ expanded_command,
112
+ shell=True,
113
+ cwd=self.cwd,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=30,
117
+ env=env
118
+ )
119
+
120
+ output = result.stdout
121
+ if result.stderr:
122
+ output += "\n" + result.stderr if output else result.stderr
123
+
124
+ if not output.strip():
125
+ output = "✓ Comando ejecutado exitosamente (sin salida)"
126
+
127
+ self.last_output = output
128
+ self.command_history.append({
129
+ "cmd": command,
130
+ "cwd": str(self.cwd),
131
+ "result": output[:200] + "..." if len(output) > 200 else output
132
+ })
133
+
134
+ return output
135
+
136
+ except subprocess.TimeoutExpired:
137
+ result = "⏱️ Error: Comando excedió el timeout de 30 segundos. Posibles causas:\n- Comando interactivo que espera input (ej: 'python' sin argumentos)\n- Proceso bloqueado o en loop infinito"
138
+ self.command_history.append({"cmd": command, "cwd": str(self.cwd), "result": result})
139
+ return result
140
+ except Exception as e:
141
+ result = f"❌ Error ejecutando comando: {str(e)}"
142
+ self.command_history.append({"cmd": command, "cwd": str(self.cwd), "result": result})
143
+ return result
144
+
145
+ def _change_directory(self, target: str) -> str:
146
+ """Cambia el directorio de trabajo."""
147
+ # Manejar rutas relativas y absolutas
148
+ if target.startswith("/") or target.startswith("\\"):
149
+ new_path = Path(target)
150
+ elif target == "~":
151
+ new_path = Path.home()
152
+ elif target == "-":
153
+ # Volver al directorio anterior (simplificado)
154
+ if len(self.command_history) > 0:
155
+ for entry in reversed(self.command_history[:-1]):
156
+ if entry["cwd"] != str(self.cwd):
157
+ new_path = Path(entry["cwd"])
158
+ break
159
+ else:
160
+ return "⚠️ No hay directorio anterior en el historial"
161
+ else:
162
+ return "⚠️ No hay directorio anterior"
163
+ else:
164
+ new_path = (self.cwd / target).resolve()
165
+
166
+ # Verificar que existe y es directorio
167
+ if not new_path.exists():
168
+ return f"❌ Error: El directorio no existe: {target}"
169
+ if not new_path.is_dir():
170
+ return f"❌ Error: No es un directorio: {target}"
171
+
172
+ old_cwd = self.cwd
173
+ self.cwd = new_path
174
+ return f"📁 Directorio cambiado: {old_cwd} → {self.cwd}"
175
+
176
+ def get_state_summary(self) -> str:
177
+ """Devuelve resumen visual del estado para el prompt."""
178
+ info = self.get_prompt_info()
179
+ files_str = "\n".join([f" {f}" for f in info["files"]]) if info["files"] else " (vacío)"
180
+
181
+ return f"""┌─ Terminal Stateful ─────────────────────────┐
182
+ │ 📂 CWD: {info['relative_cwd']}
183
+ │ 📝 Último: {self.last_command[:40]}{'...' if len(self.last_command) > 40 else ''}
184
+ │ 🔧 Variables: {info['env_count']} | Historial: {info['history_count']} cmds
185
+ │ 📂 Archivos en CWD:
186
+ {files_str}
187
+ └─────────────────────────────────────────────┘"""
188
+
189
+
190
+ class SOMALite:
191
+ def __init__(self, workspace: str):
192
+ self.workspace = Path(workspace).resolve()
193
+ self.soma = self.workspace / ".soma"
194
+ self.turns_since_checkpoint = 0
195
+ self.action_log = []
196
+ self.msg_counter = 0
197
+ # Inicializar terminal stateful
198
+ self.terminal = SimulatedStatefulTerminal(str(self.workspace))
199
+ self.init_soma()
200
+
201
+ def init_soma(self):
202
+ self.soma.mkdir(parents=True, exist_ok=True)
203
+
204
+ identity_path = self.soma / "identity.md"
205
+ if not identity_path.exists():
206
+ identity_path.write_text("""# SOMA Lite (Sovereign Operating Memory Architecture)
207
+
208
+ Eres un agente con memoria persistente. SOMA gestiona tu contexto mediante capas:
209
+ - L1 (Contexto Actual): Lo que ves ahora (identidad, dashboard, archivos, log reciente y notas).
210
+ - L2 (Memoria Completa): El archivo `session_log.jsonl` guarda todo lo que has hecho.
211
+
212
+ ## 🖥️ Terminal Stateful (Con Estado)
213
+ La terminal MANTIENE ESTADO entre comandos:
214
+ - `cd carpeta` - Cambia de directorio y persiste para siguientes comandos
215
+ - `export VAR=valor` - Define variables de entorno disponibles en comandos posteriores
216
+ - `unset VAR` - Elimina una variable de entorno
217
+ - El CWD actual se muestra en el dashboard bajo "📂 CWD"
218
+
219
+ ## Tus Herramientas
220
+ Todas las herramientas aceptan un parámetro opcional `reason` para explicar tu CoT (Chain of Thought), aunque en las notas es opcional.
221
+
222
+ 1. `execute_command(command, reason)` - Ejecuta comandos de consola (stateful: recuerda cd y exports).
223
+ 2. `read_file(path, reason)` - Lee el contenido de un archivo.
224
+ 3. `write_file(path, content, reason)` - Crea o sobrescribe un archivo.
225
+ 4. `add_note(title, content)` - Crea una nueva nota con un ID (n1, n2...).
226
+ 5. `update_note(id, title, content)` - Actualiza una nota existente por su ID.
227
+ 6. `delete_note(id)` - Elimina una nota permanentemente.
228
+ 7. `collapse_note(id, collapsed)` - Colapsa (True) o expande (False) una nota en tu contexto para ahorrar espacio.
229
+ 8. `checkpoint(description)` - Si Pm > 70%, usa esto para limpiar el `action_log`.
230
+ 9. `finish_task(status, summary, feedback)` - Úsalo para terminar.
231
+
232
+ ## Reglas de Operación
233
+ 1. EXPLICACIÓN (Opcional pero recomendada): Usa `reason` cuando la acción no sea obvia.
234
+ 2. GESTIÓN DE NOTAS: Usa las notas para guardar descubrimientos, planes y datos clave.
235
+ 3. AHORRO DE CONTEXTO: Si una nota ya no es crítica pero quieres conservarla, usa `collapse_note` con `collapsed=True`.
236
+ 4. NO REPETIR: Si ves en <action_log> un comando idéntico con el mismo resultado, prueba algo nuevo.
237
+ 5. GESTIÓN DE MEMORIA: Si el dashboard indica `State: ROJO`, llama a `checkpoint()`.
238
+ """, encoding="utf-8")
239
+
240
+ task_path = self.soma / "task.md"
241
+ if not task_path.exists():
242
+ task_path.write_text("""# OBJETIVO DE LA TAREA
243
+ [Define aquí la meta principal]
244
+ """, encoding="utf-8")
245
+
246
+ notes_path = self.soma / "notes.json"
247
+ if not notes_path.exists():
248
+ notes_path.write_text(json.dumps({"notes": [], "next_id": 1}), encoding="utf-8")
249
+
250
+ log_path = self.soma / "session_log.jsonl"
251
+ if not log_path.exists():
252
+ log_path.touch()
253
+
254
+ changelog_path = self.soma / "CHANGELOG.md"
255
+ if not changelog_path.exists():
256
+ changelog_path.write_text("# CHANGELOG\nRegistro de hitos consolidados.\n\n", encoding="utf-8")
257
+
258
+ def calculate_pm(self) -> float:
259
+ """Estimación ultra-simple: chars / 4"""
260
+ identity = (self.soma / "identity.md").read_text(encoding="utf-8")
261
+ task = (self.soma / "task.md").read_text(encoding="utf-8")
262
+ notes_str = self._render_notes()
263
+ changelog = (self.soma / "CHANGELOG.md").read_text(encoding="utf-8")
264
+ action_log_str = self.format_action_log()
265
+
266
+ # Calcula asumiendo 1 carácter = 0.25 tokens
267
+ total_chars = len(identity) + len(task) + len(notes_str) + len(changelog) + len(action_log_str) + 400
268
+ estimated_tokens = total_chars // 4
269
+ pm = (estimated_tokens / 128000) * 100 # Asume 128k contexto
270
+ return pm
271
+
272
+ def truncate_result(self, tool: str, result: str) -> str:
273
+ """Trunca resultados largos para ahorrar tokens.
274
+ Para execute_command, guarda el principio y el final si es muy largo."""
275
+ limits = {
276
+ "execute_command": 1000,
277
+ "read_file": 2000,
278
+ "write_file": 50,
279
+ "update_notes": 100,
280
+ "checkpoint": 100,
281
+ "finish_task": 500
282
+ }
283
+ limit = limits.get(tool, 300)
284
+
285
+ if len(result) > limit:
286
+ if tool == "execute_command" and limit >= 500:
287
+ half = limit // 2
288
+ return result[:half] + f"\n\n... [{len(result)-limit} chars ocultos para ahorrar contexto] ...\n\n" + result[-half:]
289
+ else:
290
+ return result[:limit] + f"\n... [{len(result)-limit} chars más]"
291
+ return result
292
+
293
+ def format_action_log(self):
294
+ """Formato de historial enriquecido con el 'reason' del agente.
295
+ Muestra todas las acciones desde el último checkpoint."""
296
+ lines = []
297
+ for entry in self.action_log:
298
+ args_copy = entry.get('args', {}).copy()
299
+ reason = args_copy.pop('reason', 'Sin motivo especificado')
300
+ args_str = json.dumps(args_copy)
301
+
302
+ res = str(entry['result']).strip()
303
+ # Envolver resultados en bloques de código si tienen varias líneas
304
+ if "\n" in res:
305
+ res = f"\n```\n{res}\n```"
306
+ else:
307
+ res = f"`{res}`"
308
+
309
+ lines.append(f"- [{entry['id']}] {entry['tool']}({args_str})\n **Why**: {reason}\n **Result**: {res}")
310
+ return "\n".join(lines)
311
+
312
+ def build_prompt(self):
313
+ identity = (self.soma / "identity.md").read_text(encoding="utf-8")
314
+ task = (self.soma / "task.md").read_text(encoding="utf-8")
315
+ notes_str = self._render_notes()
316
+ pm = self.calculate_pm()
317
+
318
+ # Dashboard Dashboard
319
+ state_icon = "ROJO" if pm > 70 else "AMARILLO" if pm > 60 else "VERDE"
320
+ changelog = (self.soma / "CHANGELOG.md").read_text(encoding="utf-8")
321
+
322
+ # Obtener estado de la terminal stateful
323
+ terminal_state = self.terminal.get_state_summary()
324
+
325
+ # Resumen de notas para el dashboard
326
+ notes_data = self._read_notes()
327
+ notes_summary = []
328
+ for n in notes_data["notes"]:
329
+ short_title = n["title"][:10] + ".." if len(n["title"]) > 10 else n["title"]
330
+ status = "(C)" if n.get("collapsed") else ""
331
+ notes_summary.append(f"[{n['id']}] {short_title}{status}")
332
+
333
+ notes_dash = ", ".join(notes_summary) if notes_summary else "vacío"
334
+
335
+ return f"""<identity>
336
+ {identity}
337
+ </identity>
338
+
339
+ <dashboard>
340
+ Pm: {pm:.1f}% | Turns: {self.turns_since_checkpoint}/20 | State: {state_icon}
341
+ 📝 Notas: {notes_dash}
342
+ {terminal_state}
343
+ </dashboard>
344
+
345
+ <changelog>
346
+ {changelog}
347
+ </changelog>
348
+
349
+ <task>
350
+ {task}
351
+ </task>
352
+
353
+ <notes>
354
+ {notes_str}
355
+ </notes>
356
+
357
+ <action_log>
358
+ {self.format_action_log()}
359
+ </action_log>"""
360
+
361
+ def log_to_l2(self, tool: str, args: dict, result: str):
362
+ self.msg_counter += 1
363
+ entry = {
364
+ "id": f"msg_{self.msg_counter:03d}",
365
+ "tool": tool,
366
+ "args": args,
367
+ "result": self.truncate_result(tool, result),
368
+ "timestamp": datetime.now().isoformat()
369
+ }
370
+
371
+ with open(self.soma / "session_log.jsonl", "a", encoding="utf-8") as f:
372
+ f.write(json.dumps(entry) + "\n")
373
+
374
+ self.action_log.append(entry)
375
+
376
+ # =========================================================================
377
+ # Herramientas
378
+ # =========================================================================
379
+
380
+ def execute_command(self, command: str) -> str:
381
+ """Ejecuta comando en terminal stateful. Mantiene cwd y variables."""
382
+ return self.terminal.execute(command)
383
+
384
+ def read_file(self, path: str) -> str:
385
+ """Lee archivo. Retorna contenido completo."""
386
+ target_path = (self.workspace / path).resolve()
387
+ if not str(target_path).startswith(str(self.workspace)):
388
+ return "Error: Path outside workspace."
389
+ if not target_path.exists():
390
+ return f"Error: File {path} not found."
391
+ try:
392
+ return target_path.read_text(encoding="utf-8")
393
+ except Exception as e:
394
+ return f"Error reading file: {str(e)}"
395
+
396
+ def write_file(self, path: str, content: str) -> str:
397
+ """Escribe archivo. Crea dirs si no existen."""
398
+ target_path = (self.workspace / path).resolve()
399
+ if not str(target_path).startswith(str(self.workspace)):
400
+ return "Error: Path outside workspace."
401
+ try:
402
+ target_path.parent.mkdir(parents=True, exist_ok=True)
403
+ target_path.write_text(content, encoding="utf-8")
404
+ return "success"
405
+ except Exception as e:
406
+ return f"Error writing file: {str(e)}"
407
+
408
+ def _read_notes(self) -> dict:
409
+ notes_path = self.soma / "notes.json"
410
+ try:
411
+ return json.loads(notes_path.read_text(encoding="utf-8"))
412
+ except Exception:
413
+ return {"notes": [], "next_id": 1}
414
+
415
+ def _write_notes(self, data: dict):
416
+ notes_path = self.soma / "notes.json"
417
+ notes_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
418
+
419
+ def _render_notes(self) -> str:
420
+ data = self._read_notes()
421
+ if not data["notes"]:
422
+ return "No hay notas guardadas."
423
+
424
+ lines = []
425
+ for n in data["notes"]:
426
+ if n.get("collapsed"):
427
+ lines.append(f"### [{n['id']}] {n['title']} (COLAPSADA)")
428
+ else:
429
+ lines.append(f"### [{n['id']}] {n['title']}")
430
+ lines.append(f"{n['content']}\n")
431
+ return "\n".join(lines)
432
+
433
+ def add_note(self, title: str, content: str) -> str:
434
+ """Añade una nueva nota structurada."""
435
+ data = self._read_notes()
436
+ note_id = f"n{data['next_id']}"
437
+ data["notes"].append({
438
+ "id": note_id,
439
+ "title": title,
440
+ "content": content,
441
+ "collapsed": False
442
+ })
443
+ data["next_id"] += 1
444
+ self._write_notes(data)
445
+ return f"Nota creada con ID: {note_id}"
446
+
447
+ def update_note(self, id: str, title: str = None, content: str = None) -> str:
448
+ """Actualiza una nota existente."""
449
+ data = self._read_notes()
450
+ for n in data["notes"]:
451
+ if n["id"] == id:
452
+ if title: n["title"] = title
453
+ if content: n["content"] = content
454
+ self._write_notes(data)
455
+ return "success"
456
+ return f"Error: Nota con ID {id} no encontrada."
457
+
458
+ def delete_note(self, id: str) -> str:
459
+ """Elimina una nota."""
460
+ data = self._read_notes()
461
+ initial_len = len(data["notes"])
462
+ data["notes"] = [n for n in data["notes"] if n["id"] != id]
463
+ if len(data["notes"]) < initial_len:
464
+ self._write_notes(data)
465
+ return "success"
466
+ return f"Error: Nota con ID {id} no encontrada."
467
+
468
+ def collapse_note(self, id: str, collapsed: bool = True) -> str:
469
+ """Colapsa o expande una nota."""
470
+ data = self._read_notes()
471
+ for n in data["notes"]:
472
+ if n["id"] == id:
473
+ n["collapsed"] = collapsed
474
+ self._write_notes(data)
475
+ return "success"
476
+ return f"Error: Nota con ID {id} no encontrada."
477
+
478
+ def finish_task(self, status: str, summary: str, feedback: str) -> str:
479
+ """Marca la tarea como finalizada."""
480
+ return f"TASK_FINISHED [{status.upper()}]: {summary}"
481
+
482
+ def checkpoint(self, description: str) -> str:
483
+ """Consolida trabajo y limpia el historial de acciones de L1."""
484
+ self.action_log = [] # Reset completo del log local tras consolidar
485
+
486
+ try:
487
+ # 1. Actualizar CHANGELOG.md antes de Git
488
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
489
+ changelog_entry = f"- [{timestamp}] {description}\n"
490
+ changelog_path = self.soma / "CHANGELOG.md"
491
+ with open(changelog_path, "a", encoding="utf-8") as f:
492
+ f.write(changelog_entry)
493
+
494
+ # 2. Operación Git / Backup
495
+ git_check = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=self.workspace, capture_output=True)
496
+ if git_check.returncode == 0:
497
+ subprocess.run(["git", "add", "."], cwd=self.workspace, check=False)
498
+ subprocess.run(["git", "commit", "-m", f"SOMA Lite: {description}"], cwd=self.workspace, check=False)
499
+ git_status = "Git commit created."
500
+ else:
501
+ # Backup if no git
502
+ backup_dir = self.workspace / ".soma_backup_temp"
503
+ if backup_dir.exists():
504
+ shutil.rmtree(backup_dir)
505
+ shutil.copytree(self.soma, backup_dir)
506
+ git_status = "Git repository not found. Local backup created at .soma_backup_temp."
507
+ except Exception as e:
508
+ git_status = f"Checkpoint backup/git failed: {e}"
509
+
510
+ self.turns_since_checkpoint = 0
511
+ return f"success. {git_status}"
512
+
513
+ # =========================================================================
514
+ # Ciclo de Ejecución (Orquestador Base)
515
+ # =========================================================================
516
+
517
+ def invoke_tool(self, tool_name: str, args: dict):
518
+ result = ""
519
+ if tool_name == "execute_command":
520
+ result = self.execute_command(args.get("command", ""))
521
+ elif tool_name == "read_file":
522
+ result = self.read_file(args.get("path", ""))
523
+ elif tool_name == "write_file":
524
+ result = self.write_file(args.get("path", ""), args.get("content", ""))
525
+ elif tool_name == "add_note":
526
+ result = self.add_note(args.get("title", ""), args.get("content", ""))
527
+ elif tool_name == "update_note":
528
+ result = self.update_note(args.get("id", ""), args.get("title"), args.get("content"))
529
+ elif tool_name == "delete_note":
530
+ result = self.delete_note(args.get("id", ""))
531
+ elif tool_name == "collapse_note":
532
+ result = self.collapse_note(args.get("id", ""), args.get("collapsed", True))
533
+ elif tool_name == "checkpoint":
534
+ result = self.checkpoint(args.get("description", ""))
535
+ elif tool_name == "finish_task":
536
+ result = self.finish_task(args.get("status", "success"), args.get("summary", ""), args.get("feedback", ""))
537
+ else:
538
+ result = f"Error: Tool {tool_name} not found."
539
+
540
+ self.log_to_l2(tool_name, args, result)
541
+ self.turns_since_checkpoint += 1
542
+
543
+ warning = ""
544
+ pm = self.calculate_pm()
545
+ if pm > 70 or self.turns_since_checkpoint >= 20:
546
+ warning = "🔴 Dashboard Crítico. Llama a checkpoint() para consolidar el action_log."
547
+ elif pm > 60:
548
+ warning = "🟡 Advertencia de Memoria. Considera llamar a checkpoint() pronto."
549
+
550
+ return result, warning
551
+
552
+ if __name__ == "__main__":
553
+ print("Iniciando pruebas de SOMALite...")
554
+
555
+ # Creamos un directorio temporal de pruebas para no ensuciar tu repo principal
556
+ test_workspace = Path.cwd() / "soma_lite_test_env"
557
+ test_workspace.mkdir(exist_ok=True)
558
+
559
+ soma = SOMALite(test_workspace)
560
+ print(f"✅ Orquestador inicializado en: {test_workspace}")
561
+
562
+ print("\n--- TEST 1: execute_command (con truncamiento inteligente para salidas largas) ---")
563
+ res, warn = soma.invoke_tool("execute_command", {"command": "python -c \"print('X' * 1500)\""})
564
+ print(f"Salida de length inicial: 1500 -> truncada a length: {len(res)} chars")
565
+ print(f"Muestra inicial y final de res:\\n{res[:60]}...{res[-60:]}")
566
+
567
+ print("\n--- TEST 2: write_file y read_file ---")
568
+ soma.invoke_tool("write_file", {"path": "test_doc.txt", "content": "Hola mundo desde SOMA Lite"})
569
+ res, warn = soma.invoke_tool("read_file", {"path": "test_doc.txt"})
570
+ print(f"Contenido leído desde archivo: {res}")
571
+
572
+ print("\n--- TEST 3: Gestion de Notas Estructuradas ---")
573
+ soma.invoke_tool("add_note", {"title": "Arquitectura", "content": "Usar capas L1, L2, L3."})
574
+ soma.invoke_tool("add_note", {"title": "Bugs", "content": "Falta manejar excepciones en el parser."})
575
+ print("Notas despues de añadir 2:")
576
+ print(soma._render_notes())
577
+
578
+ soma.invoke_tool("collapse_note", {"id": "n1", "collapsed": True})
579
+ print("\nNotas despues de colapsar n1:")
580
+ print(soma._render_notes())
581
+
582
+ soma.invoke_tool("update_note", {"id": "n2", "title": "Bugs Criticos", "content": "Urgente: revisar timeouts."})
583
+ print("\nNotas despues de actualizar n2:")
584
+ print(soma._render_notes())
585
+
586
+ res = (test_workspace / ".soma" / "notes.json").read_text(encoding="utf-8")
587
+ print("\nContenido de notes.json:", res)
588
+
589
+ print("\n--- TEST 4: Generar el Prompt completo ---")
590
+ prompt = soma.build_prompt()
591
+ print("--- INICIO DEL PROMPT (Lo que vería el LLM en el Turno 4) ---")
592
+ print(prompt)
593
+ print("--- FIN DEL PROMPT ---")
594
+
595
+ print("\n--- TEST 5: checkpoint ---")
596
+ res, warn = soma.invoke_tool("checkpoint", {"description": "Test de checkpoint funcional"})
597
+ print(f"Resultado checkpoint: {res}")
598
+ print(f"Action log L1 después del checkpoint tiene {len(soma.action_log)} elementos (debe ser 3 o menos).")
599
+
600
+ print("\n✅ ¡Todos los 5 tests pasaron exitosamente! SOMALite está funcionando.")