nexo-brain 7.23.13 → 7.24.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/automation_supervisor.py +1 -1
  6. package/src/cli.py +255 -9
  7. package/src/cognitive_control_observatory.py +224 -0
  8. package/src/dashboard/app.py +26 -9
  9. package/src/db/__init__.py +2 -0
  10. package/src/db/_learnings.py +1 -1
  11. package/src/db/_memory_v2.py +107 -1
  12. package/src/db/_protocol.py +2 -2
  13. package/src/db/_reminders.py +132 -4
  14. package/src/db/_schema.py +2 -2
  15. package/src/events_bus.py +4 -5
  16. package/src/learning_resolver.py +419 -0
  17. package/src/lifecycle_events.py +9 -9
  18. package/src/local_context/api.py +67 -5
  19. package/src/local_context/usage_events.py +24 -0
  20. package/src/memory_observation_processor.py +28 -0
  21. package/src/memory_retrieval.py +5 -5
  22. package/src/operator_language.py +2 -0
  23. package/src/plugins/backup.py +1 -1
  24. package/src/plugins/cortex.py +21 -21
  25. package/src/plugins/episodic_memory.py +11 -11
  26. package/src/plugins/goal_engine.py +3 -3
  27. package/src/plugins/personal_scripts.py +75 -0
  28. package/src/plugins/protocol.py +10 -1
  29. package/src/pre_answer_router.py +116 -0
  30. package/src/r_catalog.py +4 -5
  31. package/src/saved_not_used_audit.py +31 -31
  32. package/src/script_registry.py +444 -1
  33. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  34. package/src/scripts/nexo-daily-self-audit.py +46 -13
  35. package/src/scripts/nexo-email-migrate-config.py +2 -2
  36. package/src/scripts/nexo-email-monitor.py +19 -19
  37. package/src/scripts/nexo-followup-hygiene.py +40 -8
  38. package/src/scripts/nexo-followup-runner.py +31 -31
  39. package/src/scripts/nexo-inbox-hook.sh +1 -1
  40. package/src/scripts/nexo-learning-validator.py +24 -3
  41. package/src/server.py +73 -1
  42. package/src/system_catalog.py +31 -31
  43. package/src/tools_learnings.py +96 -65
  44. package/src/tools_memory_v2.py +2 -2
  45. package/src/tools_sessions.py +25 -7
  46. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  47. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  48. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  49. package/tool-enforcement-map.json +143 -13
@@ -158,7 +158,7 @@ def format_markdown(report: dict[str, Any]) -> str:
158
158
  lines = [
159
159
  "## saved_not_used audit",
160
160
  "",
161
- "| Store | Productor | Consumidor | Ultima escritura | Ultimo uso | Riesgo | Prueba | Estado |",
161
+ "| Store | Producer | Consumer | Last write | Last use | Risk | Test | Status |",
162
162
  "|---|---|---|---|---|---|---|---|",
163
163
  ]
164
164
  for item in stores or []:
@@ -176,12 +176,12 @@ def format_markdown(report: dict[str, Any]) -> str:
176
176
  )
177
177
  )
178
178
 
179
- lines.extend(["", "### Alertas", ""])
179
+ lines.extend(["", "### Alerts", ""])
180
180
  if not findings:
181
- lines.append("- Sin alertas saved_not_used.")
181
+ lines.append("- No saved_not_used alerts.")
182
182
  for finding in findings or []:
183
183
  lines.append(
184
- "- {severity} `{alert_id}` en `{store_id}`: {risk} Prueba: {test}".format(
184
+ "- {severity} `{alert_id}` in `{store_id}`: {risk} Test: {test}".format(
185
185
  severity=_md(finding.get("severity")),
186
186
  alert_id=_md(finding.get("alert_id")),
187
187
  store_id=_md(finding.get("store_id")),
@@ -196,8 +196,8 @@ def _audit_local_context(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
196
196
  store = _path_text(cfg.local_context_db_path)
197
197
  producer = "nexo-local-index.py -> local_context.api"
198
198
  consumer = "nexo_context_router / nexo_local_context / pre_action_context"
199
- risk = "Indice local escrito pero no consultado antes de responder."
200
- test = "local_assets/chunks/entities > 0 exige local_context_queries reciente."
199
+ risk = "Local index is written but may not be consulted before answering."
200
+ test = "local_assets/chunks/entities > 0 requires a recent local_context_queries row."
201
201
  if not cfg.local_context_db_path or not cfg.local_context_db_path.exists():
202
202
  row = _row("local_context", producer, store, consumer, "", "", risk, test, "missing", "P2", {"path_exists": False})
203
203
  return row, []
@@ -264,8 +264,8 @@ def _audit_local_context(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
264
264
  consumer,
265
265
  last_write,
266
266
  last_use,
267
- "Tablas local_* legacy no vacias pueden atraer consumidores antiguos.",
268
- "legacy local_* debe estar vacio o marcado como compat si existe sidecar vivo.",
267
+ "Non-empty legacy local_* tables may attract old consumers.",
268
+ "legacy local_* must be empty or marked as compatibility if a live sidecar exists.",
269
269
  {"legacy_main_db_counts": legacy_counts},
270
270
  )
271
271
  )
@@ -277,8 +277,8 @@ def _audit_memory_pipeline(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list
277
277
  store_id = "memory_observations_pipeline"
278
278
  producer = "record_memory_event"
279
279
  consumer = "memory_observation_worker -> nexo_memory_search / nexo_memory_answer"
280
- risk = "Eventos capturados pueden no transformarse en memoria consultable."
281
- test = "memory_events y memory_observation_queue deben terminar en memory_observations."
280
+ risk = "Captured events may fail to become searchable memory."
281
+ test = "memory_events and memory_observation_queue must end in memory_observations."
282
282
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
283
283
  if conn is None:
284
284
  return row, []
@@ -308,8 +308,8 @@ def _audit_session_diary(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
308
308
  store_id = "session_diary"
309
309
  producer = "write_session_diary / lifecycle fallback"
310
310
  consumer = "startup briefing / nexo_session_diary_read / continuity resume"
311
- risk = "Diarios guardados pueden no entrar en continuidad si solo quedan como filas historicas."
312
- test = "session_diary debe tener consumidor MCP/startup documentado y fila reciente legible."
311
+ risk = "Saved diaries may miss continuity if they only remain as historical rows."
312
+ test = "session_diary must have a documented MCP/startup consumer and a readable recent row."
313
313
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
314
314
  if conn is None:
315
315
  return row, []
@@ -324,8 +324,8 @@ def _audit_followups_reminders(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow,
324
324
  store_id = "followups_reminders"
325
325
  producer = "followup/reminder create/update"
326
326
  consumer = "nexo_reminders / followup-runner / dashboard"
327
- risk = "Items pendientes pueden acumularse sin runner ni lectura humana efectiva."
328
- test = "followups/reminders deben tener item_history de consumo o aparecer en nexo_reminders."
327
+ risk = "Pending items can accumulate without an effective runner or human read path."
328
+ test = "followups/reminders must have item_history consumption or appear in nexo_reminders."
329
329
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
330
330
  if conn is None:
331
331
  return row, []
@@ -344,8 +344,8 @@ def _audit_workflows(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[Saved
344
344
  store_id = "workflows"
345
345
  producer = "nexo_goal_open / nexo_workflow_open / nexo_workflow_update"
346
346
  consumer = "workflow_resume / workflow_replay / daily audit"
347
- risk = "Workflows abiertos sin checkpoints pierden reanudacion real."
348
- test = "workflow_runs abiertos deben tener workflow_checkpoints o updates."
347
+ risk = "Open workflows without checkpoints lose real resumability."
348
+ test = "Open workflow_runs must have workflow_checkpoints or updates."
349
349
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
350
350
  if conn is None:
351
351
  return row, []
@@ -371,8 +371,8 @@ def _audit_change_log(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[Save
371
371
  store_id = "change_log"
372
372
  producer = "log_change / nexo_task_close"
373
373
  consumer = "nexo_change_log / FTS / daily audit / memory export"
374
- risk = "Cambios guardados pueden no aparecer en auditorias futuras si el consumidor no consulta change_log."
375
- test = "change_log debe tener filas con verify y consumidor MCP."
374
+ risk = "Saved changes may not appear in future audits if the consumer does not query change_log."
375
+ test = "change_log must have rows with verify and an MCP consumer."
376
376
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
377
377
  if conn is None:
378
378
  return row, []
@@ -387,7 +387,7 @@ def _audit_change_log(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[Save
387
387
  if missing_verify:
388
388
  severity = "P2"
389
389
  status = "weak_evidence"
390
- findings.append(_finding("change_log_missing_verify", "P2", store_id, producer, row.store, consumer, last_write, "static:nexo_change_log", "Change log sin campo verify reduce prueba de consumo.", test, evidence))
390
+ findings.append(_finding("change_log_missing_verify", "P2", store_id, producer, row.store, consumer, last_write, "static:nexo_change_log", "Change log rows without verify weaken consumption evidence.", test, evidence))
391
391
  return _row(store_id, producer, row.store, consumer, last_write, "static:nexo_change_log", risk, test, status, severity, evidence), findings
392
392
 
393
393
 
@@ -395,8 +395,8 @@ def _audit_continuity_lifecycle(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow,
395
395
  store_id = "continuity_lifecycle"
396
396
  producer = "Desktop lifecycle bridge / write_continuity_snapshot"
397
397
  consumer = "resume-bundle / lifecycle canonical completion / Desktop flush"
398
- risk = "Continuidad local o eventos de cierre pueden quedar escritos sin confirmacion canonica."
399
- test = "continuity_queue vacia y lifecycle_events terminal/canonical_done."
398
+ risk = "Local continuity or close events may be written without canonical confirmation."
399
+ test = "continuity_queue must be empty and lifecycle_events must be terminal/canonical_done."
400
400
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
401
401
  pending_lifecycle = 0
402
402
  snapshots = 0
@@ -434,8 +434,8 @@ def _audit_transcripts(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[Sav
434
434
  store_id = "transcripts"
435
435
  producer = "Claude/Codex clients JSONL"
436
436
  consumer = "nexo_transcript_search / nexo_transcript_read fallback"
437
- risk = "Sesiones cortas o fuera de roots pueden quedar invisibles al preguntar por trabajo previo."
438
- test = "JSONL con >=3 mensajes de usuario debe estar en roots conocidos."
437
+ risk = "Short sessions or sessions outside roots may stay invisible when asking about prior work."
438
+ test = "JSONL with >=3 user messages must be in known roots."
439
439
  files = _transcript_files(cfg.transcript_roots)
440
440
  counts = [_transcript_user_message_count(path) for path in files]
441
441
  usable = sum(1 for count in counts if count >= 3)
@@ -460,8 +460,8 @@ def _audit_email_db(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[SavedN
460
460
  store_id = "email_db"
461
461
  producer = "email_sent_events / nexo-email-monitor"
462
462
  consumer = "check-context email lookup / local-context email roots / cognitive ingest"
463
- risk = "Emails enviados o monitorizados pueden quedar fuera de continuidad si no se proyectan a memoria."
464
- test = "sent_email_events debe tener consumidor directo o memory_events source_type=email_sent."
463
+ risk = "Sent or monitored emails may miss continuity if they are not projected to memory."
464
+ test = "sent_email_events must have a direct consumer or memory_events source_type=email_sent."
465
465
  store = _path_text(cfg.email_db_path)
466
466
  if not cfg.email_db_path or not cfg.email_db_path.exists():
467
467
  return _row(store_id, producer, store, consumer, "", "", risk, test, "missing", "P2", {"path_exists": False}), []
@@ -476,7 +476,7 @@ def _audit_email_db(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[SavedN
476
476
  if sent and not memory_email_events:
477
477
  severity = "P2"
478
478
  status = "direct_only"
479
- findings.append(_finding("email_db_without_memory_projection", "P2", store_id, producer, store, consumer, last_write, "direct:check-context", "Email DB tiene consumidor directo, pero no hay proyeccion a memory_events.", test, evidence))
479
+ findings.append(_finding("email_db_without_memory_projection", "P2", store_id, producer, store, consumer, last_write, "direct:check-context", "Email DB has a direct consumer, but no projection to memory_events.", test, evidence))
480
480
  return _row(store_id, producer, store, consumer, last_write, "direct:check-context", risk, test, status, severity, evidence), findings
481
481
 
482
482
 
@@ -484,8 +484,8 @@ def _audit_plugins(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[SavedNo
484
484
  store_id = "plugins_catalog_live"
485
485
  producer = "plugin_loader._update_registry"
486
486
  consumer = "MCP live tool list / nexo_plugin_list / system catalog"
487
- risk = "Catalogo puede prometer herramientas que el cliente no tiene cargadas."
488
- test = "tools en tabla plugins o catalogo deben estar presentes en live_tools."
487
+ risk = "Catalog may promise tools the client has not loaded."
488
+ test = "tools in the plugins table or catalog must be present in live_tools."
489
489
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
490
490
  catalog_tools = set(cfg.plugin_catalog_tools)
491
491
  last_write = ""
@@ -508,7 +508,7 @@ def _audit_plugins(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[SavedNo
508
508
  elif catalog_tools and not live_tools:
509
509
  severity = "P2"
510
510
  status = "live_unknown"
511
- findings.append(_finding("plugin_live_tools_not_provided", "P2", store_id, producer, row.store, consumer, last_write, "", "No hay snapshot live para comparar catalogo de plugins.", test, evidence))
511
+ findings.append(_finding("plugin_live_tools_not_provided", "P2", store_id, producer, row.store, consumer, last_write, "", "There is no live snapshot to compare the plugin catalog.", test, evidence))
512
512
  return _row(store_id, producer, row.store, consumer, last_write, f"live_tools={len(live_tools)}" if live_tools else "", risk, test, status, severity, evidence), findings
513
513
 
514
514
 
@@ -516,8 +516,8 @@ def _audit_cron_spool(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[Save
516
516
  store_id = "cron_spool"
517
517
  producer = "nexo-cron-wrapper.sh"
518
518
  consumer = "cron_runs summary / cron spool reconciler / watchdog"
519
- risk = "Spool JSON o cron_runs abiertos dejan automatismos en estado ambiguo."
520
- test = "cron-spool debe estar vacio y cron_runs no-Evolution debe tener ended_at."
519
+ risk = "Spool JSON or open cron_runs leave automations in an ambiguous state."
520
+ test = "cron-spool must be empty and non-Evolution cron_runs must have ended_at."
521
521
  row, conn = _open_main_row(cfg, store_id, producer, consumer, risk, test)
522
522
  open_runs = 0
523
523
  last_write = ""
@@ -91,7 +91,53 @@ METADATA_KEYS = {
91
91
  "idempotent",
92
92
  "max_catchup_age",
93
93
  "doctor_allow_db",
94
+ "agent",
95
+ "agent_title",
96
+ "agent_description",
97
+ "agent_conversation_id",
98
+ "agent_created_from",
99
+ "agent_archived",
100
+ "agent_enabled_before_archive",
101
+ "agent_icon",
94
102
  }
103
+ AGENT_METADATA_KEYS = {
104
+ "agent",
105
+ "agent_title",
106
+ "agent_description",
107
+ "agent_conversation_id",
108
+ "agent_created_from",
109
+ "agent_archived",
110
+ "agent_enabled_before_archive",
111
+ "agent_icon",
112
+ }
113
+ METADATA_WRITE_ORDER = [
114
+ "name",
115
+ "description",
116
+ "runtime",
117
+ "agent",
118
+ "agent_title",
119
+ "agent_description",
120
+ "agent_conversation_id",
121
+ "agent_created_from",
122
+ "agent_archived",
123
+ "agent_enabled_before_archive",
124
+ "agent_icon",
125
+ "cron_id",
126
+ "schedule_required",
127
+ "schedule",
128
+ "interval_seconds",
129
+ "recovery_policy",
130
+ "run_on_boot",
131
+ "run_on_wake",
132
+ "idempotent",
133
+ "max_catchup_age",
134
+ "timeout",
135
+ "requires",
136
+ "tools",
137
+ "hidden",
138
+ "category",
139
+ "doctor_allow_db",
140
+ ]
95
141
  SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
96
142
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
97
143
  SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
@@ -1130,7 +1176,10 @@ def _compact_schedule_from_record(record: dict) -> str:
1130
1176
  with contextlib.suppress(Exception):
1131
1177
  payload = json.loads(raw)
1132
1178
  if isinstance(payload, list):
1133
- payload = payload[0] if len(payload) == 1 and isinstance(payload[0], dict) else None
1179
+ if len(payload) == 1 and isinstance(payload[0], dict):
1180
+ payload = payload[0]
1181
+ else:
1182
+ return ""
1134
1183
  if isinstance(payload, dict):
1135
1184
  hour = payload.get("Hour")
1136
1185
  minute = payload.get("Minute")
@@ -1221,6 +1270,400 @@ def _write_metadata_block(path: Path, metadata_lines: list[str]) -> None:
1221
1270
  path.write_text("".join(filtered))
1222
1271
 
1223
1272
 
1273
+ def _metadata_lines_from_values(path: Path, metadata: dict) -> list[str]:
1274
+ prefix = _metadata_comment_prefix(path)
1275
+ keys = [key for key in METADATA_WRITE_ORDER if key in metadata]
1276
+ keys.extend(sorted(key for key in metadata if key in METADATA_KEYS and key not in keys))
1277
+ lines: list[str] = []
1278
+ for key in keys:
1279
+ value = str(metadata.get(key) or "").strip()
1280
+ if not value:
1281
+ continue
1282
+ lines.append(f"{prefix} nexo: {key}={value}")
1283
+ return lines
1284
+
1285
+
1286
+ def _unknown_inline_metadata_lines(path: Path) -> list[str]:
1287
+ try:
1288
+ raw = path.read_text(errors="ignore")
1289
+ except Exception:
1290
+ return []
1291
+ lines = raw.splitlines(keepends=True)
1292
+ insert_at = 1 if lines and lines[0].startswith("#!") else 0
1293
+ preserved: list[str] = []
1294
+ for index, line in enumerate(lines):
1295
+ if index < insert_at or index >= 25:
1296
+ continue
1297
+ stripped = line.strip()
1298
+ payload = ""
1299
+ if stripped.startswith("# nexo:"):
1300
+ payload = stripped[len("# nexo:"):].strip()
1301
+ elif stripped.startswith("// nexo:"):
1302
+ payload = stripped[len("// nexo:"):].strip()
1303
+ if "=" not in payload:
1304
+ continue
1305
+ key, _value = payload.split("=", 1)
1306
+ if key.strip() not in METADATA_KEYS:
1307
+ preserved.append(line.rstrip("\n") + "\n")
1308
+ return preserved
1309
+
1310
+
1311
+ def _update_script_metadata(path: Path, updates: dict, *, remove: set[str] | None = None) -> dict:
1312
+ metadata = dict(parse_inline_metadata(path))
1313
+ for key in remove or set():
1314
+ metadata.pop(key, None)
1315
+ for key, value in updates.items():
1316
+ if key not in METADATA_KEYS:
1317
+ continue
1318
+ text = str(value or "").strip()
1319
+ if text:
1320
+ metadata[key] = _normalize_metadata_value(key, text)
1321
+ else:
1322
+ metadata.pop(key, None)
1323
+ _write_metadata_block(path, _unknown_inline_metadata_lines(path) + _metadata_lines_from_values(path, metadata))
1324
+ return parse_inline_metadata(path)
1325
+
1326
+
1327
+ def _is_agent_metadata(metadata: dict | None) -> bool:
1328
+ return _truthy((metadata or {}).get("agent"))
1329
+
1330
+
1331
+ def _is_agent_archived(metadata: dict | None) -> bool:
1332
+ return _truthy((metadata or {}).get("agent_archived"))
1333
+
1334
+
1335
+ def _format_agent_calendar_value(raw: str) -> tuple[str, str]:
1336
+ compact = _compact_schedule_from_record({
1337
+ "schedule_type": "calendar",
1338
+ "schedule_value": raw,
1339
+ "schedule_label": raw,
1340
+ })
1341
+ if not compact:
1342
+ return "", ""
1343
+ parts = compact.split(":")
1344
+ if len(parts) not in {2, 3}:
1345
+ return "", "calendar"
1346
+ try:
1347
+ hour = int(parts[0])
1348
+ minute = int(parts[1])
1349
+ weekday = int(parts[2]) if len(parts) == 3 else None
1350
+ except (TypeError, ValueError):
1351
+ return "", "calendar"
1352
+ if len(parts) == 3:
1353
+ return compact, f"{hour:02d}:{minute:02d} weekday={weekday}"
1354
+ return compact, f"{hour:02d}:{minute:02d} daily"
1355
+
1356
+
1357
+ def _agent_schedule_from_script(script: dict) -> dict:
1358
+ schedules = script.get("schedules") if isinstance(script.get("schedules"), list) else []
1359
+ if schedules:
1360
+ schedule = dict(schedules[0])
1361
+ schedule_type = str(schedule.get("schedule_type") or "")
1362
+ schedule_value = str(schedule.get("schedule_value") or "")
1363
+ label = str(schedule.get("schedule_label") or schedule_value or schedule_type)
1364
+ interval_seconds = 0
1365
+ daily_at = ""
1366
+ if schedule_type == "interval":
1367
+ with contextlib.suppress(Exception):
1368
+ interval_seconds = int(schedule_value)
1369
+ elif schedule_type == "calendar":
1370
+ daily_at, formatted = _format_agent_calendar_value(schedule_value)
1371
+ label = formatted or label
1372
+ return {
1373
+ "schedule_type": schedule_type,
1374
+ "schedule_value": schedule_value,
1375
+ "schedule_label": label,
1376
+ "effective_schedule_label": label,
1377
+ "interval_seconds": interval_seconds,
1378
+ "daily_at": daily_at,
1379
+ "cron_id": str(schedule.get("cron_id") or ""),
1380
+ "schedule_source": "runtime",
1381
+ "schedules": schedules,
1382
+ }
1383
+
1384
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
1385
+ declared = get_declared_schedule(metadata, str(script.get("name") or ""))
1386
+ if declared.get("valid") and declared.get("required"):
1387
+ return {
1388
+ "schedule_type": str(declared.get("schedule_type") or ""),
1389
+ "schedule_value": str(declared.get("schedule_value") or ""),
1390
+ "schedule_label": str(declared.get("schedule_label") or ""),
1391
+ "effective_schedule_label": str(declared.get("schedule_label") or ""),
1392
+ "interval_seconds": int(declared.get("interval_seconds", 0) or 0),
1393
+ "daily_at": str(declared.get("schedule") or ""),
1394
+ "cron_id": str(declared.get("cron_id") or ""),
1395
+ "schedule_source": "metadata",
1396
+ "schedules": [],
1397
+ }
1398
+
1399
+ return {
1400
+ "schedule_type": "manual",
1401
+ "schedule_value": "",
1402
+ "schedule_label": "",
1403
+ "effective_schedule_label": "",
1404
+ "interval_seconds": 0,
1405
+ "daily_at": "",
1406
+ "cron_id": str(metadata.get("cron_id") or script.get("name") or ""),
1407
+ "schedule_source": "",
1408
+ "schedules": [],
1409
+ }
1410
+
1411
+
1412
+ def _agent_health(script: dict) -> str:
1413
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
1414
+ if _is_agent_archived(metadata):
1415
+ return "archived"
1416
+ if not bool(script.get("enabled", True)):
1417
+ return "disabled"
1418
+ exit_code = script.get("last_exit_code")
1419
+ if exit_code is None:
1420
+ return "unknown"
1421
+ return "ok" if exit_code == 0 else "failing"
1422
+
1423
+
1424
+ def _agent_row(script: dict) -> dict:
1425
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
1426
+ schedule = _agent_schedule_from_script(script)
1427
+ title = str(metadata.get("agent_title") or script.get("name") or "").strip()
1428
+ description = str(
1429
+ metadata.get("agent_description")
1430
+ or script.get("description")
1431
+ or metadata.get("description")
1432
+ or ""
1433
+ ).strip()
1434
+ return {
1435
+ "name": script.get("name", ""),
1436
+ "title": title,
1437
+ "description": description,
1438
+ "path": script.get("path", ""),
1439
+ "runtime": script.get("runtime", "unknown"),
1440
+ "enabled": bool(script.get("enabled", True)),
1441
+ "archived": _is_agent_archived(metadata),
1442
+ "health": _agent_health(script),
1443
+ "last_run_at": script.get("last_run_at", ""),
1444
+ "last_exit_code": script.get("last_exit_code"),
1445
+ "conversation_id": str(metadata.get("agent_conversation_id") or ""),
1446
+ "created_from": str(metadata.get("agent_created_from") or ""),
1447
+ "icon": str(metadata.get("agent_icon") or "automation"),
1448
+ "metadata": metadata,
1449
+ "schedule_configurable": True,
1450
+ **schedule,
1451
+ }
1452
+
1453
+
1454
+ def list_agents(*, include_archived: bool = False) -> list[dict]:
1455
+ """List personal scripts explicitly marked as NEXO agents."""
1456
+ from db import init_db, list_personal_scripts
1457
+
1458
+ init_db()
1459
+ sync_personal_scripts()
1460
+ rows = []
1461
+ for script in list_personal_scripts(include_disabled=True):
1462
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
1463
+ if not _is_agent_metadata(metadata):
1464
+ continue
1465
+ if _is_agent_archived(metadata) and not include_archived:
1466
+ continue
1467
+ rows.append(_agent_row(script))
1468
+ rows.sort(key=lambda row: (bool(row.get("archived")), str(row.get("title") or row.get("name") or "").lower()))
1469
+ return rows
1470
+
1471
+
1472
+ def get_agent_status(name_or_path: str) -> dict:
1473
+ from db import init_db, get_personal_script, list_personal_scripts
1474
+
1475
+ init_db()
1476
+ sync_personal_scripts()
1477
+ script = get_personal_script(name_or_path)
1478
+ if not script:
1479
+ return {"ok": False, "error": f"Agent not found: {name_or_path}"}
1480
+ metadata = script.get("metadata") if isinstance(script.get("metadata"), dict) else {}
1481
+ if not _is_agent_metadata(metadata):
1482
+ return {"ok": False, "error": f"Personal script is not marked as an agent: {name_or_path}"}
1483
+ for row in list_personal_scripts(include_disabled=True):
1484
+ if row.get("path") == script.get("path"):
1485
+ script = row
1486
+ break
1487
+ return {"ok": True, "agent": _agent_row(script)}
1488
+
1489
+
1490
+ def create_agent_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
1491
+ created = create_script(name, description=description, runtime=runtime, force=force)
1492
+ path = Path(created["path"])
1493
+ metadata = parse_inline_metadata(path)
1494
+ updates = {
1495
+ "agent": "true",
1496
+ "agent_title": name,
1497
+ "agent_description": description or metadata.get("description") or f"Agent: {created['name']}",
1498
+ }
1499
+ _update_script_metadata(path, updates)
1500
+ sync_result = sync_personal_scripts()
1501
+ return {
1502
+ **created,
1503
+ "agent": True,
1504
+ "sync": sync_result,
1505
+ }
1506
+
1507
+
1508
+ def set_agent_enabled(name_or_path: str, enabled: bool) -> dict:
1509
+ status = get_agent_status(name_or_path)
1510
+ if not status.get("ok"):
1511
+ return status
1512
+ agent = status["agent"]
1513
+ if enabled and agent.get("archived"):
1514
+ _update_script_metadata(Path(agent["path"]), {"agent_archived": "false"})
1515
+ result = set_personal_script_enabled(agent["path"], enabled)
1516
+ if not result.get("ok"):
1517
+ return result
1518
+ refreshed = get_agent_status(agent["path"])
1519
+ return {
1520
+ "ok": True,
1521
+ "name": agent["name"],
1522
+ "enabled": enabled,
1523
+ "changed": bool(result.get("changed")),
1524
+ "agent": refreshed.get("agent") if refreshed.get("ok") else agent,
1525
+ }
1526
+
1527
+
1528
+ def archive_agent(name_or_path: str, *, archived: bool = True) -> dict:
1529
+ status = get_agent_status(name_or_path)
1530
+ if not status.get("ok"):
1531
+ return status
1532
+ agent = status["agent"]
1533
+ path = Path(agent["path"])
1534
+ metadata = parse_inline_metadata(path)
1535
+ if archived:
1536
+ _update_script_metadata(path, {
1537
+ "agent_archived": "true",
1538
+ "agent_enabled_before_archive": "true" if agent.get("enabled", True) else "false",
1539
+ })
1540
+ next_enabled = False
1541
+ else:
1542
+ previous_enabled = metadata.get("agent_enabled_before_archive")
1543
+ next_enabled = _truthy(previous_enabled) if previous_enabled else True
1544
+ _update_script_metadata(path, {"agent_archived": "false"}, remove={"agent_enabled_before_archive"})
1545
+ toggle = set_personal_script_enabled(agent["path"], next_enabled)
1546
+ refreshed = get_agent_status(agent["path"])
1547
+ return {
1548
+ "ok": bool(toggle.get("ok", True)) and bool(refreshed.get("ok", True)),
1549
+ "name": agent["name"],
1550
+ "archived": archived,
1551
+ "agent": refreshed.get("agent") if refreshed.get("ok") else agent,
1552
+ }
1553
+
1554
+
1555
+ def _agent_schedule_ensure_error(result: dict, *, cron_id: str, path: Path) -> str:
1556
+ if not isinstance(result, dict):
1557
+ return "schedule ensure returned an invalid response"
1558
+ if result.get("ok") is False:
1559
+ return str(result.get("error") or "schedule ensure failed")
1560
+ path_text = str(path)
1561
+ for item in result.get("invalid", []) if isinstance(result.get("invalid"), list) else []:
1562
+ if item.get("path") == path_text or item.get("cron_id") == cron_id:
1563
+ return str(item.get("error") or "invalid schedule metadata")
1564
+ for bucket in ("created", "repaired"):
1565
+ for item in result.get(bucket, []) if isinstance(result.get(bucket), list) else []:
1566
+ if item.get("cron_id") != cron_id:
1567
+ continue
1568
+ response = str(item.get("result") or "")
1569
+ if response.upper().startswith("ERROR:"):
1570
+ return response
1571
+ return ""
1572
+
1573
+
1574
+ def set_agent_schedule(
1575
+ name_or_path: str,
1576
+ *,
1577
+ interval_seconds: int | None = None,
1578
+ daily_at: str | None = None,
1579
+ clear: bool = False,
1580
+ ) -> dict:
1581
+ status = get_agent_status(name_or_path)
1582
+ if not status.get("ok"):
1583
+ return status
1584
+ agent = status["agent"]
1585
+ path = Path(agent["path"])
1586
+ metadata = parse_inline_metadata(path)
1587
+ cron_id = _safe_slug(str(metadata.get("cron_id") or agent.get("name") or path.stem))
1588
+ runtime = classify_runtime(path, metadata)
1589
+ if runtime == "unknown":
1590
+ runtime = "shell" if path.suffix.lower() == ".sh" else "python"
1591
+
1592
+ remove = {"schedule", "interval_seconds", "recovery_policy", "run_on_boot", "run_on_wake", "idempotent", "max_catchup_age"}
1593
+ updates = {
1594
+ "agent": "true",
1595
+ "name": metadata.get("name") or agent.get("name") or _logical_personal_script_name(path.stem),
1596
+ "description": metadata.get("description") or agent.get("description") or f"Agent: {agent.get('name')}",
1597
+ "runtime": runtime,
1598
+ "cron_id": cron_id,
1599
+ }
1600
+ if clear:
1601
+ remove.add("schedule_required")
1602
+ _update_script_metadata(path, updates, remove=remove)
1603
+ removed = unschedule_personal_script(str(path))
1604
+ sync_result = sync_personal_scripts()
1605
+ refreshed = get_agent_status(str(path))
1606
+ return {
1607
+ "ok": bool(removed.get("ok", True)),
1608
+ "name": agent["name"],
1609
+ "cleared": True,
1610
+ "removed": removed,
1611
+ "sync": sync_result,
1612
+ "agent": refreshed.get("agent") if refreshed.get("ok") else agent,
1613
+ }
1614
+
1615
+ if interval_seconds is not None:
1616
+ try:
1617
+ interval = int(interval_seconds)
1618
+ except (TypeError, ValueError):
1619
+ return {"ok": False, "error": f"Invalid interval_seconds: {interval_seconds}"}
1620
+ if interval <= 0:
1621
+ return {"ok": False, "error": "interval_seconds must be > 0"}
1622
+ updates.update({
1623
+ "schedule_required": "true",
1624
+ "interval_seconds": str(interval),
1625
+ "recovery_policy": "run_once_on_wake",
1626
+ })
1627
+ elif daily_at:
1628
+ schedule_value = _normalize_schedule_metadata(str(daily_at).strip())
1629
+ updates.update({
1630
+ "schedule_required": "true",
1631
+ "schedule": schedule_value,
1632
+ "recovery_policy": "catchup",
1633
+ })
1634
+ else:
1635
+ return {"ok": False, "error": "Choose interval_seconds, daily_at, or clear=true"}
1636
+
1637
+ candidate_metadata = dict(metadata)
1638
+ for key in remove:
1639
+ candidate_metadata.pop(key, None)
1640
+ for key, value in updates.items():
1641
+ if key in METADATA_KEYS:
1642
+ candidate_metadata[key] = _normalize_metadata_value(key, str(value or ""))
1643
+ declared = get_declared_schedule(candidate_metadata, str(agent.get("name") or path.stem))
1644
+ if not declared.get("valid"):
1645
+ return {
1646
+ "ok": False,
1647
+ "error": str(declared.get("error") or "invalid schedule metadata"),
1648
+ "cron_id": cron_id,
1649
+ }
1650
+
1651
+ _update_script_metadata(path, updates, remove=remove)
1652
+ removed = unschedule_personal_script(str(path))
1653
+ ensured = ensure_personal_schedules(dry_run=False)
1654
+ ensure_error = _agent_schedule_ensure_error(ensured, cron_id=cron_id, path=path)
1655
+ refreshed = get_agent_status(str(path))
1656
+ return {
1657
+ "ok": not ensure_error,
1658
+ "name": agent["name"],
1659
+ "cron_id": cron_id,
1660
+ "error": ensure_error,
1661
+ "removed": removed,
1662
+ "ensure_schedules": ensured,
1663
+ "agent": refreshed.get("agent") if refreshed.get("ok") else agent,
1664
+ }
1665
+
1666
+
1224
1667
  def repair_orphan_personal_schedule_metadata(*, dry_run: bool = False) -> dict:
1225
1668
  """Infer inline metadata for personal LaunchAgents that predate the registry.
1226
1669