nexo-brain 5.3.19 → 5.3.21
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/bin/nexo-brain.js +52 -10
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""Coordination tools: file tracking, messaging, Q&A."""
|
|
3
|
+
|
|
4
|
+
from db import (
|
|
5
|
+
track_files, untrack_files, get_all_tracked_files,
|
|
6
|
+
send_message, get_inbox,
|
|
7
|
+
ask_question, answer_question, get_pending_questions, check_answer,
|
|
8
|
+
now_epoch,
|
|
9
|
+
)
|
|
10
|
+
from tools_sessions import _format_age
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_track(sid: str, paths: list[str]) -> str:
|
|
14
|
+
"""Track files being edited. Reports conflicts immediately."""
|
|
15
|
+
result = track_files(sid, paths)
|
|
16
|
+
if "error" in result:
|
|
17
|
+
return f"ERROR: {result['error']}"
|
|
18
|
+
|
|
19
|
+
lines = [f"Tracked: {', '.join(result['tracked'])}"]
|
|
20
|
+
|
|
21
|
+
if result["conflicts"]:
|
|
22
|
+
lines.append("")
|
|
23
|
+
lines.append("FILE CONFLICTS:")
|
|
24
|
+
for c in result["conflicts"]:
|
|
25
|
+
lines.append(f" {c['sid']} ({c['task']}):")
|
|
26
|
+
for f in c["files"]:
|
|
27
|
+
lines.append(f" {f}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.append("STOP and inform the user before editing.")
|
|
30
|
+
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_untrack(sid: str, paths: list[str] | None = None) -> str:
|
|
35
|
+
"""Untrack files. If no paths given, untrack all."""
|
|
36
|
+
untrack_files(sid, paths)
|
|
37
|
+
if paths:
|
|
38
|
+
return f"Untracked: {', '.join(paths)}"
|
|
39
|
+
return "All files released."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle_files() -> str:
|
|
43
|
+
"""Show all tracked files across sessions."""
|
|
44
|
+
data = get_all_tracked_files()
|
|
45
|
+
if not data:
|
|
46
|
+
return "No tracked files."
|
|
47
|
+
|
|
48
|
+
lines = ["TRACKED FILES:"]
|
|
49
|
+
all_paths = {}
|
|
50
|
+
for sid, info in data.items():
|
|
51
|
+
for path in info["files"]:
|
|
52
|
+
all_paths.setdefault(path, []).append(sid)
|
|
53
|
+
lines.append(f" {sid} ({info['task']}):")
|
|
54
|
+
for path in info["files"]:
|
|
55
|
+
lines.append(f" {path}")
|
|
56
|
+
|
|
57
|
+
conflicts = {p: sids for p, sids in all_paths.items() if len(sids) > 1}
|
|
58
|
+
if conflicts:
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.append("CONFLICTS:")
|
|
61
|
+
for path, sids in conflicts.items():
|
|
62
|
+
lines.append(f" {path} -> {', '.join(sids)}")
|
|
63
|
+
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def handle_send(from_sid: str, to_sid: str, text: str) -> str:
|
|
68
|
+
"""Send a message. to_sid='all' for broadcast."""
|
|
69
|
+
msg_id = send_message(from_sid, to_sid, text)
|
|
70
|
+
target = "all sessions" if to_sid == "all" else to_sid
|
|
71
|
+
return f"Message {msg_id} sent to {target}."
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def handle_ask(from_sid: str, to_sid: str, question: str) -> str:
|
|
75
|
+
"""Create a question to another session (non-blocking)."""
|
|
76
|
+
qid = ask_question(from_sid, to_sid, question)
|
|
77
|
+
return (
|
|
78
|
+
f"Question sent: {qid}\n"
|
|
79
|
+
f"To: {to_sid}\n"
|
|
80
|
+
f"Question: {question}\n\n"
|
|
81
|
+
f"The other session will see this question on its next nexo_heartbeat.\n"
|
|
82
|
+
f"Use nexo_check_answer(qid='{qid}') to check for a response."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def handle_answer(qid: str, answer_text: str) -> str:
|
|
87
|
+
"""Answer a pending question."""
|
|
88
|
+
result = answer_question(qid, answer_text)
|
|
89
|
+
if "error" in result:
|
|
90
|
+
return f"ERROR: {result['error']}"
|
|
91
|
+
return f"Answered {qid}: {answer_text}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def handle_check_answer(qid: str) -> str:
|
|
95
|
+
"""Check if a question has been answered."""
|
|
96
|
+
result = check_answer(qid)
|
|
97
|
+
if not result:
|
|
98
|
+
return f"Question {qid} not found."
|
|
99
|
+
if result["status"] == "answered":
|
|
100
|
+
return f"ANSWER for {qid}: {result['answer']}"
|
|
101
|
+
elif result["status"] == "expired":
|
|
102
|
+
return f"Question {qid} expired without answer."
|
|
103
|
+
return f"Question still pending. Retry in a few seconds."
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Credentials CRUD tools: get, create, update, delete, list."""
|
|
2
|
+
|
|
3
|
+
from db import create_credential, update_credential, delete_credential, get_credential, list_credentials
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def handle_credential_get(service: str, key: str = '') -> str:
|
|
7
|
+
"""Retrieve credential(s) including their values. Use for reading secrets."""
|
|
8
|
+
results = get_credential(service, key if key else None)
|
|
9
|
+
if not results:
|
|
10
|
+
target = f"{service}/{key}" if key else service
|
|
11
|
+
return f"ERROR: No credentials found for '{target}'."
|
|
12
|
+
is_fuzzy = any(r.get("_fuzzy") for r in results)
|
|
13
|
+
lines = []
|
|
14
|
+
if is_fuzzy:
|
|
15
|
+
lines.append(f"⚠ No exact match for '{service}'. Similar results ({len(results)}):")
|
|
16
|
+
lines.append("")
|
|
17
|
+
for r in results:
|
|
18
|
+
lines.append(f"CREDENTIAL {r['service']}/{r['key']}:")
|
|
19
|
+
lines.append(f" Value: {r['value']}")
|
|
20
|
+
notes = r.get("notes") or ""
|
|
21
|
+
lines.append(f" Notes: {notes if notes else '—'}")
|
|
22
|
+
return "\n".join(lines)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle_credential_create(service: str, key: str, value: str, notes: str = '') -> str:
|
|
26
|
+
"""Create a new credential entry."""
|
|
27
|
+
result = create_credential(service, key, value, notes)
|
|
28
|
+
if "error" in result:
|
|
29
|
+
return f"ERROR: {result['error']}"
|
|
30
|
+
return f"Credential {service}/{key} created."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def handle_credential_update(service: str, key: str, value: str = '', notes: str = '') -> str:
|
|
34
|
+
"""Update the value and/or notes of an existing credential."""
|
|
35
|
+
result = update_credential(
|
|
36
|
+
service,
|
|
37
|
+
key,
|
|
38
|
+
value if value else None,
|
|
39
|
+
notes if notes else None,
|
|
40
|
+
)
|
|
41
|
+
if "error" in result:
|
|
42
|
+
return f"ERROR: {result['error']}"
|
|
43
|
+
return f"Credential {service}/{key} updated."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def handle_credential_delete(service: str, key: str = '') -> str:
|
|
47
|
+
"""Delete a credential or all credentials for a service."""
|
|
48
|
+
deleted = delete_credential(service, key if key else None)
|
|
49
|
+
if not deleted:
|
|
50
|
+
target = f"{service}/{key}" if key else service
|
|
51
|
+
return f"ERROR: No credentials found for '{target}'."
|
|
52
|
+
if key:
|
|
53
|
+
return f"Credential deleted."
|
|
54
|
+
return f"All credentials for service deleted."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def handle_credential_list(service: str = '') -> str:
|
|
58
|
+
"""List credential service/key names and notes — values are never shown."""
|
|
59
|
+
results = list_credentials(service if service else None)
|
|
60
|
+
label = service if service else "ALL"
|
|
61
|
+
if not results:
|
|
62
|
+
return f"CREDENTIALS {label.upper()}: No entries."
|
|
63
|
+
lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
|
|
64
|
+
for r in results:
|
|
65
|
+
notes = r.get("notes") or ""
|
|
66
|
+
suffix = f" — {notes}" if notes else ""
|
|
67
|
+
lines.append(f" {r['service']}/{r['key']}{suffix}")
|
|
68
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO Drive/Curiosity — autonomous investigation signals.
|
|
3
|
+
|
|
4
|
+
Public MCP tool handlers + internal detection logic that feeds from
|
|
5
|
+
heartbeat, task_close, and diary consolidation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
import unicodedata
|
|
14
|
+
|
|
15
|
+
from db import (
|
|
16
|
+
create_drive_signal, reinforce_drive_signal, get_drive_signals,
|
|
17
|
+
get_drive_signal, update_drive_signal_status, decay_drive_signals,
|
|
18
|
+
find_similar_drive_signal, drive_signal_stats,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Semantic signal detection ────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Primary path: concept-level semantic scoring with multilingual cue families.
|
|
25
|
+
# Regex remains as explicit fallback only when the semantic scorer cannot
|
|
26
|
+
# separate the classes with enough confidence.
|
|
27
|
+
|
|
28
|
+
_SEMANTIC_THRESHOLD = 0.75
|
|
29
|
+
_SEMANTIC_MARGIN = 0.15
|
|
30
|
+
_LLM_MIN_TEXT_CHARS = int(os.environ.get("NEXO_DRIVE_LLM_MIN_CHARS", "24"))
|
|
31
|
+
_LLM_TIMEOUT_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_TIMEOUT", "20"))
|
|
32
|
+
_LLM_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LLM_CONFIDENCE", "0.62"))
|
|
33
|
+
_LLM_CACHE_TTL_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_CACHE_TTL", "21600"))
|
|
34
|
+
_LLM_ALLOWED_LABELS = {"anomaly", "pattern", "gap", "opportunity", "none"}
|
|
35
|
+
_LLM_CLASSIFICATION_CACHE: dict[str, dict] = {}
|
|
36
|
+
|
|
37
|
+
_SIGNAL_CUES = {
|
|
38
|
+
"anomaly": {
|
|
39
|
+
"metric": (
|
|
40
|
+
"cpc", "ctr", "roas", "conversion", "conversiones", "revenue",
|
|
41
|
+
"ingresos", "traffic", "trafico", "latency", "latencia",
|
|
42
|
+
"error", "erro", "fehler", "failure", "fallo", "falla",
|
|
43
|
+
"incident", "incidente", "kpi", "metric", "metrica",
|
|
44
|
+
),
|
|
45
|
+
"change": (
|
|
46
|
+
"subio*", "bajo*", "cayo*", "aumento*", "disminu*", "crecio*",
|
|
47
|
+
"drop*", "spik*", "jump*", "rose", "fell", "grew", "surg*",
|
|
48
|
+
"subiu*", "caiu*", "baixou*", "aumentou*", "stieg*", "fiel*",
|
|
49
|
+
"gesunk*", "anstieg*", "einbruch*", "regression*",
|
|
50
|
+
),
|
|
51
|
+
"unexpected": (
|
|
52
|
+
"inesperad*", "unexpected*", "anom*", "raro*", "weird",
|
|
53
|
+
"strange", "estranh*", "seltsam*", "ungewohn*", "anomalia*",
|
|
54
|
+
"outlier*", "desviacion*", "abweich*",
|
|
55
|
+
),
|
|
56
|
+
"degradation": (
|
|
57
|
+
"degrad*", "timeout*", "slow*", "lento*", "caida*", "degraded",
|
|
58
|
+
"down", "outage", "rot*", "broken", "rompio*", "broke",
|
|
59
|
+
"schlecht*", "falha*", "incidencia*",
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
"pattern": {
|
|
63
|
+
"recurrence": (
|
|
64
|
+
"otra vez", "de nuevo", "again", "again and again", "recurr*",
|
|
65
|
+
"repe*", "keeps happ*", "siempre pasa", "vuelve a pasar",
|
|
66
|
+
"sempre", "sempre que", "de novo", "wieder", "immer wieder",
|
|
67
|
+
"wiederholt*", "stuck in a loop", "reincid*",
|
|
68
|
+
),
|
|
69
|
+
"cadence": (
|
|
70
|
+
"cada vez que", "every time", "whenever", "cada semana",
|
|
71
|
+
"cada mes", "once more", "toda vez que", "jedes mal",
|
|
72
|
+
"wann immer", "all the time", "constantemente",
|
|
73
|
+
),
|
|
74
|
+
"same_issue": (
|
|
75
|
+
"mismo problema", "mismo error", "same problem", "same issue",
|
|
76
|
+
"same error", "lo mismo", "same thing", "same blocker",
|
|
77
|
+
"mesmo problema", "gleiches problem", "gleicher fehler",
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
"gap": {
|
|
81
|
+
"uncertainty": (
|
|
82
|
+
"no se como", "no entiendo", "no tengo claro", "unclear how",
|
|
83
|
+
"dont know how", "not sure how", "i do not know how",
|
|
84
|
+
"sem saber como", "nao sei como", "ich weiss nicht wie",
|
|
85
|
+
"ich weiß nicht wie", "unklar wie", "blocked by not knowing",
|
|
86
|
+
),
|
|
87
|
+
"missing_knowledge": (
|
|
88
|
+
"falta documentacion", "missing docs", "missing documentation",
|
|
89
|
+
"undocumented", "not documented", "sin documentar", "sin guia",
|
|
90
|
+
"no hay runbook", "no hay playbook", "sem documentacao",
|
|
91
|
+
"fehlt dokumentation", "kein runbook", "unknown process",
|
|
92
|
+
),
|
|
93
|
+
"blocked_execution": (
|
|
94
|
+
"bloqueado porque", "blocked because", "cannot proceed",
|
|
95
|
+
"no puedo seguir", "cant continue", "nao consigo avanzar",
|
|
96
|
+
"komme nicht weiter", "stuck because",
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
"opportunity": {
|
|
100
|
+
"benchmark_gap": (
|
|
101
|
+
"media del sector", "industry average", "below peers",
|
|
102
|
+
"por debajo", "underperform*", "lagging", "low compared",
|
|
103
|
+
"abaixo do benchmark", "unter benchmark", "unter dem schnitt",
|
|
104
|
+
),
|
|
105
|
+
"improvement": (
|
|
106
|
+
"automatiz*", "optimiz*", "mejor*", "improv*", "streamlin*",
|
|
107
|
+
"simplif*", "scale*", "accelerat*", "reduce manual",
|
|
108
|
+
"automat*", "melhor*", "verbesser*", "effizien*",
|
|
109
|
+
),
|
|
110
|
+
"potential": (
|
|
111
|
+
"podriamos", "se podria", "could", "we could", "opportunity",
|
|
112
|
+
"worth exploring", "room to", "potencial", "oportunidade",
|
|
113
|
+
"chance to", "could unlock", "konnten", "man koennte",
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_SIGNAL_FAMILY_WEIGHTS = {
|
|
119
|
+
"anomaly": {"metric": 0.28, "change": 0.38, "unexpected": 0.30, "degradation": 0.28},
|
|
120
|
+
"pattern": {"recurrence": 0.36, "cadence": 0.34, "same_issue": 0.34},
|
|
121
|
+
"gap": {"uncertainty": 0.78, "missing_knowledge": 0.52, "blocked_execution": 0.36},
|
|
122
|
+
"opportunity": {"benchmark_gap": 0.78, "improvement": 0.38, "potential": 0.32},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_FALLBACK_PATTERNS = {
|
|
126
|
+
"anomaly": (
|
|
127
|
+
re.compile(r"\b(subió|bajó|cayó|dropped|spiked|jumped)\b.*\b\d+%", re.I),
|
|
128
|
+
re.compile(r"\b(inesperado|unexpected|anomal|raro|weird|strange)\b", re.I),
|
|
129
|
+
),
|
|
130
|
+
"pattern": (
|
|
131
|
+
re.compile(r"\b(otra vez|again|de nuevo|siempre pasa|keeps happening|recurring)\b", re.I),
|
|
132
|
+
re.compile(r"\b(cada vez que|every time|whenever)\b", re.I),
|
|
133
|
+
),
|
|
134
|
+
"gap": (
|
|
135
|
+
re.compile(r"\b(no sé cómo|don'?t know how|no entiendo|unclear how)\b", re.I),
|
|
136
|
+
re.compile(r"\b(falta documentación|missing docs|undocumented)\b", re.I),
|
|
137
|
+
),
|
|
138
|
+
"opportunity": (
|
|
139
|
+
re.compile(r"\b(benchmark|media del sector|industry average)\b.*\b(bajo|low|por debajo|below)\b", re.I),
|
|
140
|
+
re.compile(r"\b(podríamos|could|se podría|we could|opportunity)\b.*\b(automatiz|improve|mejorar|optimiz)\b", re.I),
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _normalize_text(text: str) -> str:
|
|
146
|
+
lowered = (text or "").lower().replace("ß", "ss").replace("'", "")
|
|
147
|
+
lowered = unicodedata.normalize("NFKD", lowered)
|
|
148
|
+
lowered = "".join(ch for ch in lowered if not unicodedata.combining(ch))
|
|
149
|
+
lowered = re.sub(r"[^a-z0-9%+\s]", " ", lowered)
|
|
150
|
+
return re.sub(r"\s+", " ", lowered).strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_json_object(raw: str) -> dict | None:
|
|
154
|
+
text = (raw or "").strip()
|
|
155
|
+
if not text:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
payload = json.loads(text)
|
|
159
|
+
return payload if isinstance(payload, dict) else None
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
start = text.find("{")
|
|
164
|
+
end = text.rfind("}")
|
|
165
|
+
if start == -1 or end == -1 or end <= start:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
payload = json.loads(text[start : end + 1])
|
|
169
|
+
return payload if isinstance(payload, dict) else None
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _tokenize(text: str) -> list[str]:
|
|
175
|
+
return [token for token in text.split() if token]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _matches_cue(cue: str, text_norm: str, tokens: list[str]) -> bool:
|
|
179
|
+
cue_norm = _normalize_text(cue)
|
|
180
|
+
if not cue_norm:
|
|
181
|
+
return False
|
|
182
|
+
if cue_norm.endswith("*"):
|
|
183
|
+
stem = cue_norm[:-1]
|
|
184
|
+
return bool(stem) and any(token.startswith(stem) for token in tokens)
|
|
185
|
+
if " " in cue_norm:
|
|
186
|
+
return cue_norm in text_norm
|
|
187
|
+
return cue_norm in tokens or any(token.startswith(cue_norm) for token in tokens if len(cue_norm) >= 5)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _has_numeric_signal(tokens: list[str]) -> bool:
|
|
191
|
+
for token in tokens:
|
|
192
|
+
raw = token.rstrip("%")
|
|
193
|
+
try:
|
|
194
|
+
float(raw)
|
|
195
|
+
return True
|
|
196
|
+
except ValueError:
|
|
197
|
+
continue
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _semantic_signal_scores(text: str) -> dict[str, float]:
|
|
202
|
+
text_norm = _normalize_text(text)
|
|
203
|
+
tokens = _tokenize(text_norm)
|
|
204
|
+
if not tokens:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
numeric_signal = _has_numeric_signal(tokens)
|
|
208
|
+
scores = {signal_type: 0.0 for signal_type in _SIGNAL_CUES}
|
|
209
|
+
family_hits: dict[str, set[str]] = {signal_type: set() for signal_type in _SIGNAL_CUES}
|
|
210
|
+
|
|
211
|
+
for signal_type, families in _SIGNAL_CUES.items():
|
|
212
|
+
weights = _SIGNAL_FAMILY_WEIGHTS[signal_type]
|
|
213
|
+
for family_name, cues in families.items():
|
|
214
|
+
matches = [cue for cue in cues if _matches_cue(cue, text_norm, tokens)]
|
|
215
|
+
if not matches:
|
|
216
|
+
continue
|
|
217
|
+
family_hits[signal_type].add(family_name)
|
|
218
|
+
bonus = min(0.12, 0.04 * max(0, len(matches) - 1))
|
|
219
|
+
scores[signal_type] += weights[family_name] + bonus
|
|
220
|
+
|
|
221
|
+
anomaly_hits = family_hits["anomaly"]
|
|
222
|
+
if "metric" in anomaly_hits and "change" in anomaly_hits:
|
|
223
|
+
scores["anomaly"] += 0.22
|
|
224
|
+
if numeric_signal and ("change" in anomaly_hits or "metric" in anomaly_hits):
|
|
225
|
+
scores["anomaly"] += 0.14
|
|
226
|
+
if "unexpected" in anomaly_hits and ("change" in anomaly_hits or "degradation" in anomaly_hits):
|
|
227
|
+
scores["anomaly"] += 0.12
|
|
228
|
+
if "unexpected" in anomaly_hits and "metric" in anomaly_hits:
|
|
229
|
+
scores["anomaly"] += 0.10
|
|
230
|
+
|
|
231
|
+
pattern_hits = family_hits["pattern"]
|
|
232
|
+
if "recurrence" in pattern_hits and ("cadence" in pattern_hits or "same_issue" in pattern_hits):
|
|
233
|
+
scores["pattern"] += 0.18
|
|
234
|
+
|
|
235
|
+
gap_hits = family_hits["gap"]
|
|
236
|
+
if "uncertainty" in gap_hits and ("missing_knowledge" in gap_hits or "blocked_execution" in gap_hits):
|
|
237
|
+
scores["gap"] += 0.18
|
|
238
|
+
|
|
239
|
+
opportunity_hits = family_hits["opportunity"]
|
|
240
|
+
if "benchmark_gap" in opportunity_hits:
|
|
241
|
+
scores["opportunity"] += 0.16
|
|
242
|
+
if "improvement" in opportunity_hits and "potential" in opportunity_hits:
|
|
243
|
+
scores["opportunity"] += 0.18
|
|
244
|
+
|
|
245
|
+
return scores
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _llm_cache_key(text: str) -> str:
|
|
249
|
+
return _normalize_text(text)[:1200]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _llm_classify_signal(text: str) -> dict:
|
|
253
|
+
text_norm = _normalize_text(text)
|
|
254
|
+
if len(text_norm) < _LLM_MIN_TEXT_CHARS:
|
|
255
|
+
return {"available": False, "label": None, "reason": "text_too_short"}
|
|
256
|
+
|
|
257
|
+
cache_key = _llm_cache_key(text)
|
|
258
|
+
now = time.time()
|
|
259
|
+
cached = _LLM_CLASSIFICATION_CACHE.get(cache_key)
|
|
260
|
+
if cached and cached.get("expires_at", 0) > now:
|
|
261
|
+
return {k: v for k, v in cached.items() if k != "expires_at"}
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
|
|
267
|
+
|
|
268
|
+
json_system_prompt = (
|
|
269
|
+
"You classify operational text into one of exactly five labels: "
|
|
270
|
+
"anomaly, pattern, gap, opportunity, none. "
|
|
271
|
+
"Return ONLY a valid JSON object with keys: label, confidence, reason. "
|
|
272
|
+
"confidence must be a number from 0 to 1. "
|
|
273
|
+
"Use anomaly for unexpected changes/degradation, pattern for recurrence, "
|
|
274
|
+
"gap for missing knowledge/documentation/blocker, opportunity for improvement/automation/benchmark gaps, "
|
|
275
|
+
"none when the text is normal progress without a useful signal."
|
|
276
|
+
)
|
|
277
|
+
prompt = (
|
|
278
|
+
"Classify this NEXO Drive signal candidate.\n\n"
|
|
279
|
+
f"TEXT:\n{text.strip()[:3000]}\n\n"
|
|
280
|
+
"Return JSON only."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
result = run_automation_prompt(
|
|
285
|
+
prompt,
|
|
286
|
+
task_profile="fast",
|
|
287
|
+
timeout=_LLM_TIMEOUT_SECONDS,
|
|
288
|
+
output_format="text",
|
|
289
|
+
append_system_prompt=json_system_prompt,
|
|
290
|
+
)
|
|
291
|
+
except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
|
|
292
|
+
return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
|
|
295
|
+
|
|
296
|
+
if result.returncode != 0:
|
|
297
|
+
return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
|
|
298
|
+
|
|
299
|
+
parsed = _extract_json_object(result.stdout)
|
|
300
|
+
if not parsed:
|
|
301
|
+
return {"available": False, "label": None, "reason": "invalid_json"}
|
|
302
|
+
|
|
303
|
+
label = str(parsed.get("label", "") or "").strip().lower()
|
|
304
|
+
if label not in _LLM_ALLOWED_LABELS:
|
|
305
|
+
return {"available": False, "label": None, "reason": "invalid_label"}
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
confidence = float(parsed.get("confidence", 0.0) or 0.0)
|
|
309
|
+
except (TypeError, ValueError):
|
|
310
|
+
confidence = 0.0
|
|
311
|
+
|
|
312
|
+
classification = {
|
|
313
|
+
"available": True,
|
|
314
|
+
"label": None if label == "none" else label,
|
|
315
|
+
"confidence": confidence,
|
|
316
|
+
"reason": str(parsed.get("reason", "") or ""),
|
|
317
|
+
"source": "llm",
|
|
318
|
+
}
|
|
319
|
+
_LLM_CLASSIFICATION_CACHE[cache_key] = {
|
|
320
|
+
**classification,
|
|
321
|
+
"expires_at": now + _LLM_CACHE_TTL_SECONDS,
|
|
322
|
+
}
|
|
323
|
+
return classification
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _regex_fallback_classify(text: str) -> str | None:
|
|
327
|
+
for signal_type, patterns in _FALLBACK_PATTERNS.items():
|
|
328
|
+
if any(pattern.search(text) for pattern in patterns):
|
|
329
|
+
return signal_type
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _classify_signal(text: str, *, allow_llm: bool = True) -> str | None:
|
|
334
|
+
"""Classify text into a signal type, or None if nothing interesting."""
|
|
335
|
+
if allow_llm:
|
|
336
|
+
llm_result = _llm_classify_signal(text)
|
|
337
|
+
if llm_result.get("available"):
|
|
338
|
+
confidence = float(llm_result.get("confidence", 0.0) or 0.0)
|
|
339
|
+
label = llm_result.get("label")
|
|
340
|
+
if label is None and confidence >= _LLM_CONFIDENCE_THRESHOLD:
|
|
341
|
+
return None
|
|
342
|
+
if isinstance(label, str) and confidence >= _LLM_CONFIDENCE_THRESHOLD:
|
|
343
|
+
return label
|
|
344
|
+
|
|
345
|
+
scores = _semantic_signal_scores(text)
|
|
346
|
+
if scores:
|
|
347
|
+
ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
|
|
348
|
+
winner, winner_score = ordered[0]
|
|
349
|
+
runner_up = ordered[1][1] if len(ordered) > 1 else 0.0
|
|
350
|
+
if winner_score >= _SEMANTIC_THRESHOLD and (winner_score - runner_up) >= _SEMANTIC_MARGIN:
|
|
351
|
+
return winner
|
|
352
|
+
if winner_score >= 0.35:
|
|
353
|
+
return None
|
|
354
|
+
return _regex_fallback_classify(text)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _infer_area(text: str) -> str:
|
|
358
|
+
"""Infer operational area from text keywords."""
|
|
359
|
+
text_lower = text.lower()
|
|
360
|
+
area_keywords = {
|
|
361
|
+
"shopify": ["shopify", "tienda", "pedido", "producto", "sku"],
|
|
362
|
+
"google-ads": ["google ads", "campaña", "campaign", "cpc", "pmax", "roas", "gads"],
|
|
363
|
+
"meta-ads": ["meta ads", "facebook", "instagram", "pixel", "capi"],
|
|
364
|
+
"wazion": ["wazion", "whatsapp", "wa ", "baileys"],
|
|
365
|
+
"nexo": ["nexo", "brain", "mcp", "cognitive"],
|
|
366
|
+
"canaririural": ["canarirural", "canari", "reserva", "hospedaje", "alojamiento", "propietario"],
|
|
367
|
+
"seo": ["seo", "search console", "indexación", "ranking"],
|
|
368
|
+
"email": ["email", "correo", "inbox", "smtp"],
|
|
369
|
+
}
|
|
370
|
+
for area, keywords in area_keywords.items():
|
|
371
|
+
for kw in keywords:
|
|
372
|
+
if kw in text_lower:
|
|
373
|
+
return area
|
|
374
|
+
return ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def detect_drive_signal(
|
|
378
|
+
context_hint: str,
|
|
379
|
+
source: str,
|
|
380
|
+
source_id: str = "",
|
|
381
|
+
area: str = "",
|
|
382
|
+
*,
|
|
383
|
+
allow_llm: bool = False,
|
|
384
|
+
) -> dict | None:
|
|
385
|
+
"""Analyze text for interesting signals. Creates or reinforces.
|
|
386
|
+
|
|
387
|
+
Called internally from heartbeat and task_close. Not a public MCP tool.
|
|
388
|
+
Returns the signal dict if created/reinforced, None otherwise.
|
|
389
|
+
"""
|
|
390
|
+
if not context_hint or len(context_hint.strip()) < 15:
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
signal_type = _classify_signal(context_hint, allow_llm=allow_llm)
|
|
394
|
+
if not signal_type:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
inferred_area = area or _infer_area(context_hint)
|
|
398
|
+
|
|
399
|
+
# Check for similar existing signal
|
|
400
|
+
existing = find_similar_drive_signal(context_hint, inferred_area)
|
|
401
|
+
if existing:
|
|
402
|
+
result = reinforce_drive_signal(existing["id"], context_hint[:500])
|
|
403
|
+
return result if result.get("ok") else None
|
|
404
|
+
|
|
405
|
+
# Create new
|
|
406
|
+
result = create_drive_signal(
|
|
407
|
+
signal_type=signal_type,
|
|
408
|
+
source=source,
|
|
409
|
+
source_id=source_id,
|
|
410
|
+
area=inferred_area,
|
|
411
|
+
summary=context_hint[:300],
|
|
412
|
+
)
|
|
413
|
+
return result if result.get("ok") else None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── Public MCP tool handlers ─────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
def handle_drive_signals(
|
|
419
|
+
status: str = "",
|
|
420
|
+
area: str = "",
|
|
421
|
+
limit: int = 20,
|
|
422
|
+
) -> str:
|
|
423
|
+
"""List drive signals, optionally filtered by status and area."""
|
|
424
|
+
signals = get_drive_signals(
|
|
425
|
+
status=status or None,
|
|
426
|
+
area=area or None,
|
|
427
|
+
limit=limit,
|
|
428
|
+
)
|
|
429
|
+
if not signals:
|
|
430
|
+
return "No drive signals found."
|
|
431
|
+
|
|
432
|
+
stats = drive_signal_stats()
|
|
433
|
+
lines = [
|
|
434
|
+
f"DRIVE SIGNALS ({len(signals)} shown, {stats['total']} total):",
|
|
435
|
+
f" By status: {json.dumps(stats.get('by_status', {}), ensure_ascii=False)}",
|
|
436
|
+
"",
|
|
437
|
+
]
|
|
438
|
+
for s in signals:
|
|
439
|
+
evidence_count = 0
|
|
440
|
+
try:
|
|
441
|
+
evidence_count = len(json.loads(s.get("evidence") or "[]"))
|
|
442
|
+
except (json.JSONDecodeError, TypeError):
|
|
443
|
+
pass
|
|
444
|
+
tension_bar = "█" * int(float(s.get("tension", 0)) * 10)
|
|
445
|
+
lines.append(
|
|
446
|
+
f" [{s['id']}] {s['status'].upper()} {tension_bar} "
|
|
447
|
+
f"t={s['tension']:.2f} ({s['signal_type']}) "
|
|
448
|
+
f"{'[' + s['area'] + '] ' if s.get('area') else ''}"
|
|
449
|
+
f"{s['summary'][:80]}"
|
|
450
|
+
f" ({evidence_count} obs, decay={s.get('decay_rate', 0.05):.2f})"
|
|
451
|
+
)
|
|
452
|
+
return "\n".join(lines)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def handle_drive_reinforce(signal_id: int, observation: str) -> str:
|
|
456
|
+
"""Manually reinforce a drive signal with a new observation."""
|
|
457
|
+
if not observation.strip():
|
|
458
|
+
return "ERROR: observation cannot be empty"
|
|
459
|
+
result = reinforce_drive_signal(signal_id, observation)
|
|
460
|
+
if not result.get("ok"):
|
|
461
|
+
return f"ERROR: {result.get('error', 'unknown')}"
|
|
462
|
+
return (
|
|
463
|
+
f"Signal #{signal_id} reinforced: "
|
|
464
|
+
f"tension {result['old_tension']:.2f} → {result['new_tension']:.2f}, "
|
|
465
|
+
f"status {result['old_status']} → {result['new_status']}, "
|
|
466
|
+
f"{result['evidence_count']} observations total"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def handle_drive_act(signal_id: int, outcome: str) -> str:
|
|
471
|
+
"""Mark a drive signal as investigated with an outcome."""
|
|
472
|
+
if not outcome.strip():
|
|
473
|
+
return "ERROR: outcome cannot be empty"
|
|
474
|
+
result = update_drive_signal_status(signal_id, "acted", outcome)
|
|
475
|
+
if not result.get("ok"):
|
|
476
|
+
return f"ERROR: {result.get('error', 'unknown')}"
|
|
477
|
+
return f"Signal #{signal_id} marked as ACTED. Outcome recorded."
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def handle_drive_dismiss(signal_id: int, reason: str) -> str:
|
|
481
|
+
"""Dismiss a drive signal with a reason (archived, not deleted)."""
|
|
482
|
+
if not reason.strip():
|
|
483
|
+
return "ERROR: reason cannot be empty"
|
|
484
|
+
result = update_drive_signal_status(signal_id, "dismissed", reason)
|
|
485
|
+
if not result.get("ok"):
|
|
486
|
+
return f"ERROR: {result.get('error', 'unknown')}"
|
|
487
|
+
return f"Signal #{signal_id} dismissed. Reason: {reason}"
|