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/README.md +69 -0
- package/package.json +30 -0
- package/run-agent-lite.js +534 -0
- package/run_agent_lite.py +410 -0
- package/soma-lite.js +708 -0
- package/soma_lite.py +600 -0
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.")
|