nexo-brain 7.23.13 → 7.25.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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/auto_update.py +30 -0
  6. package/src/automation_supervisor.py +1 -1
  7. package/src/cli.py +255 -9
  8. package/src/cognitive_control_observatory.py +224 -0
  9. package/src/crons/manifest.json +13 -0
  10. package/src/dashboard/app.py +26 -9
  11. package/src/db/__init__.py +2 -0
  12. package/src/db/_fts.py +38 -8
  13. package/src/db/_learnings.py +1 -1
  14. package/src/db/_memory_v2.py +107 -1
  15. package/src/db/_protocol.py +2 -2
  16. package/src/db/_reminders.py +132 -4
  17. package/src/db/_schema.py +48 -2
  18. package/src/doctor/providers/runtime.py +69 -0
  19. package/src/events_bus.py +4 -5
  20. package/src/learning_resolver.py +419 -0
  21. package/src/lifecycle_events.py +9 -9
  22. package/src/local_context/api.py +67 -5
  23. package/src/local_context/usage_events.py +24 -0
  24. package/src/memory_fabric.py +536 -0
  25. package/src/memory_observation_processor.py +28 -0
  26. package/src/memory_retrieval.py +5 -5
  27. package/src/operator_language.py +2 -0
  28. package/src/plugins/backup.py +1 -1
  29. package/src/plugins/cortex.py +21 -21
  30. package/src/plugins/episodic_memory.py +11 -11
  31. package/src/plugins/goal_engine.py +3 -3
  32. package/src/plugins/personal_scripts.py +75 -0
  33. package/src/plugins/protocol.py +10 -1
  34. package/src/pre_answer_router.py +120 -3
  35. package/src/r_catalog.py +4 -5
  36. package/src/saved_not_used_audit.py +31 -31
  37. package/src/script_registry.py +444 -1
  38. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  39. package/src/scripts/nexo-backup.sh +30 -0
  40. package/src/scripts/nexo-daily-self-audit.py +46 -13
  41. package/src/scripts/nexo-email-migrate-config.py +2 -2
  42. package/src/scripts/nexo-email-monitor.py +19 -19
  43. package/src/scripts/nexo-followup-hygiene.py +40 -8
  44. package/src/scripts/nexo-followup-runner.py +31 -31
  45. package/src/scripts/nexo-inbox-hook.sh +1 -1
  46. package/src/scripts/nexo-learning-validator.py +24 -3
  47. package/src/scripts/nexo-memory-fabric.py +45 -0
  48. package/src/server.py +73 -1
  49. package/src/system_catalog.py +31 -31
  50. package/src/tools_learnings.py +96 -65
  51. package/src/tools_memory_v2.py +2 -2
  52. package/src/tools_sessions.py +25 -7
  53. package/src/tools_transcripts.py +50 -8
  54. package/src/transcript_index.py +105 -2
  55. package/src/transcript_utils.py +65 -13
  56. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  57. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  58. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  59. package/tool-enforcement-map.json +143 -13
package/src/cli.py CHANGED
@@ -20,8 +20,10 @@ Entry points:
20
20
  nexo scripts run NAME_OR_PATH [-- args...]
21
21
  nexo scripts doctor [NAME_OR_PATH] [--json]
22
22
  nexo scripts call TOOL --input JSON [--json-output]
23
+ nexo agents list|create|status|enable|disable|schedule|archive|run [--json]
23
24
  nexo pre-answer route [--json] [--payload JSON|--payload-file PATH|--payload-stdin] [--query TEXT]
24
- nexo memory-observations process [--json]
25
+ nexo cognitive-control observatory [--json]
26
+ nexo memory-observations process|intraday [--json]
25
27
  nexo local-context status|run-once|reconcile|pause|resume|roots|exclusions|query|diagnostics|models [--json]
26
28
  nexo automations reactivate NAME [--test-run] [--json]
27
29
  nexo skills list [--level ...] [--source-kind ...] [--json]
@@ -1057,6 +1059,145 @@ def _automations_set_schedule(args):
1057
1059
  return 0
1058
1060
 
1059
1061
 
1062
+ def _agents_list(args):
1063
+ from script_registry import list_agents
1064
+
1065
+ rows = list_agents(include_archived=bool(getattr(args, "all", False)))
1066
+ if args.json:
1067
+ print(json.dumps({"ok": True, "agents": rows}, indent=2, ensure_ascii=False))
1068
+ return 0
1069
+ if not rows:
1070
+ print("No agents registered.")
1071
+ return 0
1072
+ for row in rows:
1073
+ state = "archived" if row.get("archived") else ("enabled" if row.get("enabled", True) else "disabled")
1074
+ schedule = str(row.get("effective_schedule_label") or "manual")
1075
+ print(f"{row.get('name')} [{state}] · {schedule} · {row.get('title') or row.get('description') or ''}")
1076
+ return 0
1077
+
1078
+
1079
+ def _agents_create(args):
1080
+ from script_registry import create_agent_script
1081
+
1082
+ try:
1083
+ result = create_agent_script(
1084
+ args.name,
1085
+ description=args.description,
1086
+ runtime=args.runtime,
1087
+ force=args.force,
1088
+ )
1089
+ except (FileExistsError, ValueError) as e:
1090
+ if args.json:
1091
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
1092
+ else:
1093
+ print(str(e), file=sys.stderr)
1094
+ return 1
1095
+ if args.json:
1096
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1097
+ else:
1098
+ print(f"Created agent script: {result['path']}")
1099
+ return 0
1100
+
1101
+
1102
+ def _agents_status(args):
1103
+ from script_registry import get_agent_status
1104
+
1105
+ result = get_agent_status(args.name)
1106
+ if args.json:
1107
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1108
+ return 0 if result.get("ok") else 1
1109
+ if not result.get("ok"):
1110
+ print(result.get("error", "Failed to read agent status"), file=sys.stderr)
1111
+ return 1
1112
+ agent = result.get("agent") or {}
1113
+ state = "archived" if agent.get("archived") else ("enabled" if agent.get("enabled") else "DISABLED")
1114
+ print(f"{agent.get('name')} [{state}] -> {agent.get('title') or agent.get('description') or ''}")
1115
+ schedule = str(agent.get("effective_schedule_label") or "").strip()
1116
+ if schedule:
1117
+ print(f" schedule: {schedule} ({agent.get('schedule_source') or 'metadata'})")
1118
+ if agent.get("last_run_at"):
1119
+ print(f" last run: {agent.get('last_run_at')} (exit={agent.get('last_exit_code')})")
1120
+ return 0
1121
+
1122
+
1123
+ def _agents_set_enabled(args, enabled):
1124
+ from script_registry import set_agent_enabled
1125
+
1126
+ result = set_agent_enabled(args.name, enabled)
1127
+ if args.json:
1128
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1129
+ return 0 if result.get("ok") else 1
1130
+ if not result.get("ok"):
1131
+ print(result.get("error", "Failed to toggle agent"), file=sys.stderr)
1132
+ return 1
1133
+ verb = "enabled" if enabled else "disabled"
1134
+ print(f"Agent {result['name']} {verb}.")
1135
+ return 0
1136
+
1137
+
1138
+ def _agents_archive(args):
1139
+ from script_registry import archive_agent
1140
+
1141
+ result = archive_agent(args.name, archived=not bool(getattr(args, "restore", False)))
1142
+ if args.json:
1143
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1144
+ return 0 if result.get("ok") else 1
1145
+ if not result.get("ok"):
1146
+ print(result.get("error", "Failed to archive agent"), file=sys.stderr)
1147
+ return 1
1148
+ print(f"Agent {result['name']} {'restored' if args.restore else 'archived'}.")
1149
+ return 0
1150
+
1151
+
1152
+ def _agents_set_schedule(args):
1153
+ from script_registry import set_agent_schedule
1154
+
1155
+ interval_seconds = None
1156
+ daily_at = None
1157
+ if getattr(args, "every_minutes", None) is not None:
1158
+ interval_seconds = int(args.every_minutes) * 60
1159
+ elif getattr(args, "every_seconds", None) is not None:
1160
+ interval_seconds = int(args.every_seconds)
1161
+ elif getattr(args, "daily_at", None):
1162
+ daily_at = str(args.daily_at).strip()
1163
+
1164
+ result = set_agent_schedule(
1165
+ args.name,
1166
+ interval_seconds=interval_seconds,
1167
+ daily_at=daily_at,
1168
+ clear=bool(getattr(args, "reset", False)),
1169
+ )
1170
+ if args.json:
1171
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1172
+ return 0 if result.get("ok") else 1
1173
+ if not result.get("ok"):
1174
+ print(result.get("error", "Failed to update agent cadence"), file=sys.stderr)
1175
+ return 1
1176
+ agent = result.get("agent") or {}
1177
+ label = str(agent.get("effective_schedule_label") or "").strip()
1178
+ print(f"Agent schedule updated for {result['name']}: {label or 'manual'}")
1179
+ return 0
1180
+
1181
+
1182
+ def _agents_run(args):
1183
+ from script_registry import get_agent_status
1184
+
1185
+ status = get_agent_status(args.name)
1186
+ if not status.get("ok"):
1187
+ print(status.get("error", "Agent not found"), file=sys.stderr)
1188
+ return 1
1189
+ agent = status.get("agent") or {}
1190
+ if agent.get("archived"):
1191
+ print(f"Agent is archived: {agent.get('name') or args.name}", file=sys.stderr)
1192
+ return 1
1193
+ if agent.get("enabled") is False:
1194
+ print(f"Agent is disabled: {agent.get('name') or args.name}", file=sys.stderr)
1195
+ return 1
1196
+ args.name = agent.get("path") or args.name
1197
+ args.script_args = list(getattr(args, "script_args", []) or [])
1198
+ return _scripts_run(args)
1199
+
1200
+
1060
1201
  def _core_schedules_list(args):
1061
1202
  from core_schedule_controls import list_core_schedules
1062
1203
 
@@ -1610,6 +1751,30 @@ def _memory_observations_process(args) -> int:
1610
1751
  )
1611
1752
 
1612
1753
 
1754
+ def _memory_observations_intraday(args) -> int:
1755
+ from memory_observation_processor import process_intraday_cycle
1756
+
1757
+ return _print_json_or_text(
1758
+ process_intraday_cycle(
1759
+ process_limit=int(getattr(args, "limit", 20) or 20),
1760
+ backfill_limit=int(getattr(args, "backfill_limit", 20) or 20),
1761
+ pending_sla_seconds=int(getattr(args, "pending_sla_seconds", 3600) or 3600),
1762
+ ),
1763
+ as_json=bool(getattr(args, "json", False)),
1764
+ )
1765
+
1766
+
1767
+ def _cognitive_control_observatory(args) -> int:
1768
+ from cognitive_control_observatory import build_cognitive_control_observatory
1769
+
1770
+ return _print_json_or_text(
1771
+ build_cognitive_control_observatory(
1772
+ window_seconds=int(getattr(args, "window_seconds", 86400) or 86400),
1773
+ ),
1774
+ as_json=bool(getattr(args, "json", False)),
1775
+ )
1776
+
1777
+
1613
1778
  def _local_context_status(args) -> int:
1614
1779
  import local_context
1615
1780
  return _local_context_emit(local_context.status(), args)
@@ -2217,13 +2382,13 @@ def _prompt_model_recommendations(*, interactive: bool) -> None:
2217
2382
  effort_str = f" / {entry['current_effort']}" if entry["current_effort"] else ""
2218
2383
  prev_effort = f" / {entry['user_effort']}" if entry["user_effort"] else ""
2219
2384
  print()
2220
- print(f"[NEXO] ⭐ Nueva recomendación de modelo para {client}:")
2385
+ print(f"[NEXO] ⭐ New model recommendation for {client}:")
2221
2386
  print(
2222
2387
  f" {entry['current_model']}{effort_str} "
2223
- f"(antes: {entry['user_model']}{prev_effort})"
2388
+ f"(previous: {entry['user_model']}{prev_effort})"
2224
2389
  )
2225
- print(f" {entry['display_name']} — recomendado por NEXO.")
2226
- answer = input(" ¿Migrar tu configuración? [y/N/later]: ").strip().lower()
2390
+ print(f" {entry['display_name']} — recommended by NEXO.")
2391
+ answer = input(" Migrate your configuration? [y/N/later]: ").strip().lower()
2227
2392
  client_key = normalize_client_key(client) or client
2228
2393
  if answer in {"y", "yes", "s", "si", "sí"}:
2229
2394
  updated_profiles[client_key] = {
@@ -2231,15 +2396,15 @@ def _prompt_model_recommendations(*, interactive: bool) -> None:
2231
2396
  "reasoning_effort": entry["current_effort"],
2232
2397
  }
2233
2398
  updated_ack[client_key] = entry["current_version"]
2234
- print(f" ✅ Migrado a {entry['current_model']}.")
2399
+ print(f" ✅ Migrated to {entry['current_model']}.")
2235
2400
  changed = True
2236
2401
  elif answer in {"later", "l", "luego"}:
2237
2402
  # Do NOT acknowledge — will prompt again next interactive update.
2238
- print(" ↻ Te lo preguntaré en el próximo update.")
2403
+ print(" ↻ I will ask again during the next update.")
2239
2404
  else:
2240
2405
  # "N" / empty / anything else → record ack so we don't re-ask.
2241
2406
  updated_ack[client_key] = entry["current_version"]
2242
- print(f" Mantenido {entry['user_model']}. No te preguntaré de nuevo para esta versión.")
2407
+ print(f" Kept {entry['user_model']}. I will not ask again for this version.")
2243
2408
  changed = True
2244
2409
 
2245
2410
  if changed:
@@ -3523,6 +3688,12 @@ def main():
3523
3688
  pre_answer_route_p.add_argument("--current-context", default="", help="Current conversation/task context")
3524
3689
  pre_answer_route_p.add_argument("--json", action="store_true", help="JSON output")
3525
3690
 
3691
+ cognitive_control_parser = sub.add_parser("cognitive-control", help="Cognitive control observability")
3692
+ cognitive_control_sub = cognitive_control_parser.add_subparsers(dest="cognitive_control_command")
3693
+ cognitive_observatory_p = cognitive_control_sub.add_parser("observatory", help="Read-only cognitive quality metrics")
3694
+ cognitive_observatory_p.add_argument("--window-seconds", type=int, default=86400, help="Observation window")
3695
+ cognitive_observatory_p.add_argument("--json", action="store_true", help="JSON output")
3696
+
3526
3697
  memory_observations_parser = sub.add_parser("memory-observations", help="Process Memory Observations v2 queue")
3527
3698
  memory_observations_sub = memory_observations_parser.add_subparsers(dest="memory_observations_command")
3528
3699
  memory_observations_process_p = memory_observations_sub.add_parser("process", help="Run one bounded observation processor cycle")
@@ -3530,6 +3701,11 @@ def main():
3530
3701
  memory_observations_process_p.add_argument("--backfill-limit", type=int, default=100, help="Maximum legacy events to enqueue or repair")
3531
3702
  memory_observations_process_p.add_argument("--pending-sla-seconds", type=int, default=3600, help="Pending queue SLA threshold")
3532
3703
  memory_observations_process_p.add_argument("--json", action="store_true", help="JSON output")
3704
+ memory_observations_intraday_p = memory_observations_sub.add_parser("intraday", help="Run low-limit daytime observation cycle")
3705
+ memory_observations_intraday_p.add_argument("--limit", type=int, default=20, help="Maximum pending rows to process")
3706
+ memory_observations_intraday_p.add_argument("--backfill-limit", type=int, default=20, help="Maximum legacy events to enqueue or repair")
3707
+ memory_observations_intraday_p.add_argument("--pending-sla-seconds", type=int, default=3600, help="Pending queue SLA threshold")
3708
+ memory_observations_intraday_p.add_argument("--json", action="store_true", help="JSON output")
3533
3709
 
3534
3710
  local_context_parser = sub.add_parser("local-context", help="Manage the local memory index")
3535
3711
  local_context_sub = local_context_parser.add_subparsers(dest="local_context_command")
@@ -3632,6 +3808,50 @@ def main():
3632
3808
  local_context_asset_purge_p.add_argument("asset_id", help="Asset id")
3633
3809
  local_context_asset_purge_p.add_argument("--json", action="store_true", help="JSON output")
3634
3810
 
3811
+ agents_parser = sub.add_parser("agents", help="Manage Home-facing personal agents")
3812
+ agents_sub = agents_parser.add_subparsers(dest="agents_command")
3813
+
3814
+ agents_list_p = agents_sub.add_parser("list", help="List personal-script-backed agents")
3815
+ agents_list_p.add_argument("--all", action="store_true", help="Include archived agents")
3816
+ agents_list_p.add_argument("--json", action="store_true", help="JSON output")
3817
+
3818
+ agents_create_p = agents_sub.add_parser("create", help="Create a personal script marked as an agent")
3819
+ agents_create_p.add_argument("name", help="Human/agent name")
3820
+ agents_create_p.add_argument("--description", default="", help="One-line description")
3821
+ agents_create_p.add_argument("--runtime", default="python", choices=["python", "shell"], help="Script runtime")
3822
+ agents_create_p.add_argument("--force", action="store_true", help="Overwrite if the target file exists")
3823
+ agents_create_p.add_argument("--json", action="store_true", help="JSON output")
3824
+
3825
+ agents_status_p = agents_sub.add_parser("status", help="Read agent status")
3826
+ agents_status_p.add_argument("name", help="Agent name or path")
3827
+ agents_status_p.add_argument("--json", action="store_true", help="JSON output")
3828
+
3829
+ agents_enable_p = agents_sub.add_parser("enable", help="Enable an agent")
3830
+ agents_enable_p.add_argument("name", help="Agent name or path")
3831
+ agents_enable_p.add_argument("--json", action="store_true", help="JSON output")
3832
+
3833
+ agents_disable_p = agents_sub.add_parser("disable", help="Disable an agent")
3834
+ agents_disable_p.add_argument("name", help="Agent name or path")
3835
+ agents_disable_p.add_argument("--json", action="store_true", help="JSON output")
3836
+
3837
+ agents_archive_p = agents_sub.add_parser("archive", help="Archive an agent without deleting its script")
3838
+ agents_archive_p.add_argument("name", help="Agent name or path")
3839
+ agents_archive_p.add_argument("--restore", action="store_true", help="Restore instead of archive")
3840
+ agents_archive_p.add_argument("--json", action="store_true", help="JSON output")
3841
+
3842
+ agents_schedule_p = agents_sub.add_parser("schedule", help="Change an agent cadence")
3843
+ agents_schedule_p.add_argument("name", help="Agent name or path")
3844
+ agents_schedule_group = agents_schedule_p.add_mutually_exclusive_group(required=True)
3845
+ agents_schedule_group.add_argument("--every-minutes", type=int, help="Run the agent every N minutes")
3846
+ agents_schedule_group.add_argument("--every-seconds", type=int, help="Run the agent every N seconds")
3847
+ agents_schedule_group.add_argument("--daily-at", type=str, help="Run the agent every day at HH:MM or HH:MM:weekday")
3848
+ agents_schedule_group.add_argument("--reset", action="store_true", help="Clear the agent schedule")
3849
+ agents_schedule_p.add_argument("--json", action="store_true", help="JSON output")
3850
+
3851
+ agents_run_p = agents_sub.add_parser("run", help="Run an agent now")
3852
+ agents_run_p.add_argument("name", help="Agent name or path")
3853
+ agents_run_p.add_argument("script_args", nargs=argparse.REMAINDER, help="Arguments passed to the script")
3854
+
3635
3855
  automations_parser = sub.add_parser("automations", help="Manage Desktop-facing automations")
3636
3856
  automations_sub = automations_parser.add_subparsers(dest="automations_command")
3637
3857
 
@@ -4106,7 +4326,7 @@ def main():
4106
4326
  return 0
4107
4327
 
4108
4328
  if args.command == "email":
4109
- # Plan F1 — setup / list / test / remove cuentas email.
4329
+ # Plan F1 — setup / list / test / remove email accounts.
4110
4330
  fn = getattr(args, "func", None)
4111
4331
  if fn is None:
4112
4332
  print("usage: nexo email {setup,list,test,remove}")
@@ -4156,9 +4376,16 @@ def main():
4156
4376
  return _pre_answer_route(args)
4157
4377
  pre_answer_parser.print_help()
4158
4378
  return 0
4379
+ elif args.command == "cognitive-control":
4380
+ if args.cognitive_control_command == "observatory":
4381
+ return _cognitive_control_observatory(args)
4382
+ cognitive_control_parser.print_help()
4383
+ return 0
4159
4384
  elif args.command == "memory-observations":
4160
4385
  if args.memory_observations_command == "process":
4161
4386
  return _memory_observations_process(args)
4387
+ if args.memory_observations_command == "intraday":
4388
+ return _memory_observations_intraday(args)
4162
4389
  memory_observations_parser.print_help()
4163
4390
  return 0
4164
4391
  elif args.command == "local-context":
@@ -4201,6 +4428,25 @@ def main():
4201
4428
  return _local_context_asset(args)
4202
4429
  local_context_parser.print_help()
4203
4430
  return 0
4431
+ elif args.command == "agents":
4432
+ if args.agents_command == "list":
4433
+ return _agents_list(args)
4434
+ elif args.agents_command == "create":
4435
+ return _agents_create(args)
4436
+ elif args.agents_command == "status":
4437
+ return _agents_status(args)
4438
+ elif args.agents_command == "enable":
4439
+ return _agents_set_enabled(args, True)
4440
+ elif args.agents_command == "disable":
4441
+ return _agents_set_enabled(args, False)
4442
+ elif args.agents_command == "archive":
4443
+ return _agents_archive(args)
4444
+ elif args.agents_command == "schedule":
4445
+ return _agents_set_schedule(args)
4446
+ elif args.agents_command == "run":
4447
+ return _agents_run(args)
4448
+ agents_parser.print_help()
4449
+ return 0
4204
4450
  elif args.command == "automations":
4205
4451
  if args.automations_command == "list":
4206
4452
  return _automations_list(args)
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ """Read-only observatory for the cognitive quality-control phases."""
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import time
9
+ from typing import Any
10
+
11
+ import db
12
+ from local_context import usage_events
13
+
14
+
15
+ DEFAULT_WINDOW_SECONDS = 24 * 60 * 60
16
+
17
+
18
+ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
19
+ try:
20
+ row = conn.execute(
21
+ "SELECT 1 FROM sqlite_master WHERE type IN ('table', 'view') AND name = ? LIMIT 1",
22
+ (table_name,),
23
+ ).fetchone()
24
+ except sqlite3.OperationalError:
25
+ return False
26
+ return row is not None
27
+
28
+
29
+ def _count(conn: sqlite3.Connection, table: str, where: str = "", params: tuple[Any, ...] = ()) -> int:
30
+ if not _table_exists(conn, table):
31
+ return 0
32
+ sql = f"SELECT COUNT(*) FROM {table}"
33
+ if where:
34
+ sql += f" WHERE {where}"
35
+ try:
36
+ return int(conn.execute(sql, params).fetchone()[0] or 0)
37
+ except sqlite3.Error:
38
+ return 0
39
+
40
+
41
+ def _status_counts(conn: sqlite3.Connection, table: str) -> dict[str, int]:
42
+ if not _table_exists(conn, table):
43
+ return {}
44
+ try:
45
+ rows = conn.execute(
46
+ f"SELECT COALESCE(status, '') AS status, COUNT(*) AS cnt FROM {table} GROUP BY COALESCE(status, '')"
47
+ ).fetchall()
48
+ except sqlite3.Error:
49
+ return {}
50
+ return {str(row["status"] or ""): int(row["cnt"] or 0) for row in rows}
51
+
52
+
53
+ def _local_context_status() -> dict[str, Any]:
54
+ try:
55
+ from local_context import api as local_context_api
56
+
57
+ status = local_context_api.status()
58
+ if not isinstance(status, dict):
59
+ return {"ok": False, "error": "invalid_status_payload"}
60
+ return status
61
+ except Exception as exc:
62
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
63
+
64
+
65
+ def _learning_quality(conn: sqlite3.Connection, limit: int = 100) -> dict[str, Any]:
66
+ if not _table_exists(conn, "learnings"):
67
+ return {"available": False, "total": 0, "active": 0, "status_counts": {}, "quality": {}}
68
+ rows = conn.execute(
69
+ """
70
+ SELECT *
71
+ FROM learnings
72
+ WHERE COALESCE(status, 'active') = 'active'
73
+ ORDER BY updated_at DESC, id DESC
74
+ LIMIT ?
75
+ """,
76
+ (max(1, min(int(limit or 100), 500)),),
77
+ ).fetchall()
78
+ quality_counts = {"strong": 0, "usable": 0, "weak": 0, "fragile": 0}
79
+ scored = 0
80
+ try:
81
+ from tools_learnings import score_learning_quality
82
+
83
+ for row in rows:
84
+ score = score_learning_quality(dict(row), conn)
85
+ label = str(score.get("label") or "fragile")
86
+ quality_counts[label] = quality_counts.get(label, 0) + 1
87
+ scored += 1
88
+ except Exception:
89
+ pass
90
+ return {
91
+ "available": True,
92
+ "total": _count(conn, "learnings"),
93
+ "active": _count(conn, "learnings", "COALESCE(status, 'active') = 'active'"),
94
+ "status_counts": _status_counts(conn, "learnings"),
95
+ "quality_sample_size": scored,
96
+ "quality": quality_counts,
97
+ }
98
+
99
+
100
+ def _followups(conn: sqlite3.Connection) -> dict[str, Any]:
101
+ if not _table_exists(conn, "followups"):
102
+ return {
103
+ "ok": True,
104
+ "skipped": True,
105
+ "reason": "followups_table_unavailable",
106
+ "total": 0,
107
+ "counts": {},
108
+ "executable_now": 0,
109
+ "non_executable": 0,
110
+ }
111
+ try:
112
+ snapshot = db.followup_lifecycle_snapshot(limit=5000)
113
+ except Exception as exc:
114
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
115
+ counts = dict(snapshot.get("counts") or {})
116
+ executable = int(counts.get("active", 0) or 0)
117
+ non_executable = sum(
118
+ int(counts.get(lane, 0) or 0)
119
+ for lane in ("waiting_user", "waiting_external", "blocked", "parked", "stale_review", "expired")
120
+ )
121
+ return {
122
+ "ok": bool(snapshot.get("ok", True)),
123
+ "total": int(snapshot.get("total") or 0),
124
+ "counts": counts,
125
+ "executable_now": executable,
126
+ "non_executable": non_executable,
127
+ }
128
+
129
+
130
+ def _intraday_memory(conn: sqlite3.Connection, window_seconds: int, now_ts: float) -> dict[str, Any]:
131
+ cutoff = float(now_ts) - max(0, int(window_seconds or DEFAULT_WINDOW_SECONDS))
132
+ try:
133
+ health = db.memory_observation_health()
134
+ except Exception as exc:
135
+ health = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
136
+ try:
137
+ import memory_observation_processor
138
+
139
+ queue = memory_observation_processor.queue_health(now=now_ts)
140
+ except Exception as exc:
141
+ queue = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
142
+ facts = 0
143
+ latest_fact = None
144
+ if _table_exists(conn, "hot_context"):
145
+ try:
146
+ row = conn.execute(
147
+ """
148
+ SELECT COUNT(*) AS total, MAX(last_event_at) AS latest
149
+ FROM hot_context
150
+ WHERE context_type = 'intraday_fact'
151
+ AND last_event_at >= ?
152
+ """,
153
+ (cutoff,),
154
+ ).fetchone()
155
+ facts = int(row["total"] or 0)
156
+ latest_fact = row["latest"]
157
+ except sqlite3.Error:
158
+ pass
159
+ schema_ready = not bool(health.get("missing_required"))
160
+ return {
161
+ "ok": (bool(health.get("ok", True)) or not schema_ready) and bool(queue.get("ok", True)),
162
+ "schema_ready": schema_ready,
163
+ "health": health,
164
+ "queue": queue,
165
+ "intraday_facts_window": facts,
166
+ "latest_intraday_fact_at": latest_fact,
167
+ "event_stats": db.memory_event_stats(days=max(1, int(window_seconds / 86400) or 1)),
168
+ }
169
+
170
+
171
+ def build_cognitive_control_observatory(
172
+ *,
173
+ window_seconds: int = DEFAULT_WINDOW_SECONDS,
174
+ now_ts: float | None = None,
175
+ ) -> dict[str, Any]:
176
+ """Return read-only metrics for phases 0-4 without creating work."""
177
+
178
+ current = float(now_ts if now_ts is not None else time.time())
179
+ conn = db.get_db()
180
+ local_usage = usage_events.summarize_usage(window_seconds=window_seconds, now_ts=current)
181
+ local_status = _local_context_status()
182
+ learning_summary = _learning_quality(conn)
183
+ followup_summary = _followups(conn)
184
+ intraday_summary = _intraday_memory(conn, window_seconds, current)
185
+ local_mode = (
186
+ os.environ.get("NEXO_PRE_ANSWER_LOCAL_CONTEXT_MODE")
187
+ or os.environ.get("NEXO_LOCAL_CONTEXT_PRE_ANSWER_MODE")
188
+ or "inject"
189
+ )
190
+ payload = {
191
+ "ok": True,
192
+ "read_only": True,
193
+ "generated_at": current,
194
+ "window_seconds": max(0, int(window_seconds or DEFAULT_WINDOW_SECONDS)),
195
+ "phase_coverage": {
196
+ "phase_0_observatory": True,
197
+ "phase_1_local_context": True,
198
+ "phase_2_learning_resolver": True,
199
+ "phase_3_followup_lifecycle": True,
200
+ "phase_4_intraday_memory": True,
201
+ },
202
+ "local_context": {
203
+ "pre_answer_mode": str(local_mode).strip().lower(),
204
+ "usage": local_usage,
205
+ "status": local_status,
206
+ },
207
+ "learnings": learning_summary,
208
+ "followups": followup_summary,
209
+ "intraday_memory": intraday_summary,
210
+ }
211
+ payload["summary"] = {
212
+ "local_context_events": int(local_usage.get("total_events") or 0),
213
+ "active_learnings": int(learning_summary.get("active") or 0),
214
+ "active_followups": int((followup_summary.get("counts") or {}).get("active") or 0),
215
+ "intraday_facts": int(intraday_summary.get("intraday_facts_window") or 0),
216
+ }
217
+ return payload
218
+
219
+
220
+ def format_observatory(payload: dict[str, Any]) -> str:
221
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
222
+
223
+
224
+ __all__ = ["build_cognitive_control_observatory", "format_observatory"]
@@ -302,6 +302,19 @@
302
302
  "run_on_boot": true,
303
303
  "run_on_wake": true
304
304
  },
305
+ {
306
+ "id": "memory-fabric",
307
+ "script": "scripts/nexo-memory-fabric.py",
308
+ "schedule": {"hour": 2, "minute": 35},
309
+ "description": "Daily Memory Fabric maintenance — refresh transcript search, historical backup diaries, and graph links",
310
+ "core": true,
311
+ "recovery_policy": "catchup",
312
+ "idempotent": true,
313
+ "max_catchup_age": 172800,
314
+ "stuck_after_seconds": 3600,
315
+ "run_on_boot": true,
316
+ "run_on_wake": true
317
+ },
305
318
  {
306
319
  "id": "local-index",
307
320
  "script": "scripts/nexo-local-index.py",
@@ -227,7 +227,8 @@ def _normalize_item_status(status: object) -> str:
227
227
 
228
228
 
229
229
  def _dashboard_status_matches(status: object, requested: str | None) -> bool:
230
- normalized = _normalize_item_status(status)
230
+ db = _db()
231
+ normalized = db.normalize_followup_status(status)
231
232
  requested_key = str(requested or "").strip().lower()
232
233
  if not requested_key:
233
234
  return normalized != "DELETED"
@@ -239,6 +240,11 @@ def _dashboard_status_matches(status: object, requested: str | None) -> bool:
239
240
  return normalized.startswith("COMPLETED")
240
241
  if requested_key == "deleted":
241
242
  return normalized == "DELETED"
243
+ if requested_key in {
244
+ "active", "waiting_user", "waiting_external", "blocked",
245
+ "parked", "stale_review", "expired", "completed",
246
+ }:
247
+ return db.followup_lifecycle_lane({"status": normalized}) == requested_key
242
248
  return normalized == requested_key.upper()
243
249
 
244
250
 
@@ -1019,6 +1025,10 @@ async def api_followups_list(
1019
1025
  """List followups."""
1020
1026
  db = _db()
1021
1027
  followups = db.get_followups("history")
1028
+ for item in followups:
1029
+ item["status"] = db.normalize_followup_status(item.get("status"))
1030
+ item["lifecycle_lane"] = db.followup_lifecycle_lane(item)
1031
+ item["due_state"] = db.followup_due_state(item)
1022
1032
  followups = [r for r in followups if _dashboard_status_matches(r.get("status"), status)]
1023
1033
  followups = sorted(followups, key=lambda item: item.get("updated_at") or item.get("created_at") or 0, reverse=True)
1024
1034
  return {"count": len(followups), "followups": followups}
@@ -2064,12 +2074,11 @@ async def api_backups():
2064
2074
  @app.get("/api/followup-health")
2065
2075
  async def api_followup_health():
2066
2076
  db = _db()
2067
- conn = db.get_db()
2068
- all_f = [dict(r) for r in conn.execute("SELECT * FROM followups").fetchall()]
2069
- today = datetime.date.today().isoformat()
2070
- pending = [f for f in all_f if f.get("status") not in ("completed", "archived", "deleted")]
2071
- completed = [f for f in all_f if f.get("status") == "completed"]
2072
- overdue = [f for f in pending if f.get("date") and f["date"] < today]
2077
+ snapshot = db.followup_lifecycle_snapshot(limit=5000)
2078
+ all_f = [item for lane_items in (snapshot.get("lanes") or {}).values() for item in lane_items]
2079
+ active = list((snapshot.get("lanes") or {}).get("active", []))
2080
+ completed = list((snapshot.get("lanes") or {}).get("completed", []))
2081
+ overdue = [f for f in active if f.get("due_state") == "due"]
2073
2082
  rate = round(len(completed) / max(len(all_f), 1) * 100, 1)
2074
2083
  age_buckets = {"0-3d": 0, "4-7d": 0, "8-14d": 0, "15-30d": 0, "30d+": 0}
2075
2084
  for f in overdue:
@@ -2082,8 +2091,16 @@ async def api_followup_health():
2082
2091
  elif age <= 14: age_buckets["8-14d"] += 1
2083
2092
  elif age <= 30: age_buckets["15-30d"] += 1
2084
2093
  else: age_buckets["30d+"] += 1
2085
- return {"total": len(all_f), "pending": len(pending), "completed": len(completed),
2086
- "overdue": len(overdue), "completion_rate": rate, "age_buckets": age_buckets, "overdue_items": overdue[:20]}
2094
+ return {
2095
+ "total": len(all_f),
2096
+ "pending": len(active),
2097
+ "completed": len(completed),
2098
+ "overdue": len(overdue),
2099
+ "completion_rate": rate,
2100
+ "age_buckets": age_buckets,
2101
+ "overdue_items": overdue[:20],
2102
+ "lifecycle": snapshot,
2103
+ }
2087
2104
 
2088
2105
 
2089
2106
  # ---------------------------------------------------------------------------