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
|
@@ -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 |
|
|
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(["", "###
|
|
179
|
+
lines.extend(["", "### Alerts", ""])
|
|
180
180
|
if not findings:
|
|
181
|
-
lines.append("-
|
|
181
|
+
lines.append("- No saved_not_used alerts.")
|
|
182
182
|
for finding in findings or []:
|
|
183
183
|
lines.append(
|
|
184
|
-
"- {severity} `{alert_id}`
|
|
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 = "
|
|
200
|
-
test = "local_assets/chunks/entities > 0
|
|
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
|
-
"
|
|
268
|
-
"legacy local_*
|
|
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 = "
|
|
281
|
-
test = "memory_events
|
|
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 = "
|
|
312
|
-
test = "session_diary
|
|
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 = "
|
|
328
|
-
test = "followups/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 = "
|
|
348
|
-
test = "workflow_runs
|
|
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 = "
|
|
375
|
-
test = "change_log
|
|
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
|
|
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 = "
|
|
399
|
-
test = "continuity_queue
|
|
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 = "
|
|
438
|
-
test = "JSONL
|
|
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 = "
|
|
464
|
-
test = "sent_email_events
|
|
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
|
|
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 = "
|
|
488
|
-
test = "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, "", "
|
|
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
|
|
520
|
-
test = "cron-spool
|
|
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 = ""
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|