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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/auto_update.py +30 -0
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/crons/manifest.json +13 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_fts.py +38 -8
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +48 -2
- package/src/doctor/providers/runtime.py +69 -0
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_fabric.py +536 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +120 -3
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-backup.sh +30 -0
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/scripts/nexo-memory-fabric.py +45 -0
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/src/tools_transcripts.py +50 -8
- package/src/transcript_index.py +105 -2
- package/src/transcript_utils.py +65 -13
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- 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
|
|
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] ⭐
|
|
2385
|
+
print(f"[NEXO] ⭐ New model recommendation for {client}:")
|
|
2221
2386
|
print(
|
|
2222
2387
|
f" {entry['current_model']}{effort_str} "
|
|
2223
|
-
f"(
|
|
2388
|
+
f"(previous: {entry['user_model']}{prev_effort})"
|
|
2224
2389
|
)
|
|
2225
|
-
print(f" {entry['display_name']} —
|
|
2226
|
-
answer = input("
|
|
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" ✅
|
|
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(" ↻
|
|
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"
|
|
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
|
|
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"]
|
package/src/crons/manifest.json
CHANGED
|
@@ -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",
|
package/src/dashboard/app.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2068
|
-
all_f = [
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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 {
|
|
2086
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|