nexo-brain 4.0.1 → 4.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -51,6 +51,7 @@ _watchers = _load_submodule("db._watchers")
51
51
  _personal_scripts = _load_submodule("db._personal_scripts")
52
52
  _skills = _load_submodule("db._skills")
53
53
  _hot_context = _load_submodule("db._hot_context")
54
+ _drive = _load_submodule("db._drive")
54
55
 
55
56
  # Core: connection, constants, init, utils
56
57
  from db._core import (
@@ -187,6 +188,13 @@ from db._skills import (
187
188
  get_skill_health_report,
188
189
  )
189
190
 
191
+ # Drive / Curiosity signals
192
+ from db._drive import (
193
+ create_drive_signal, reinforce_drive_signal, get_drive_signals,
194
+ get_drive_signal, update_drive_signal_status, decay_drive_signals,
195
+ find_similar_drive_signal, drive_signal_stats,
196
+ )
197
+
190
198
  # Hot context / recent continuity
191
199
  from db._hot_context import (
192
200
  DEFAULT_CONTEXT_TTL_HOURS,
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+ """NEXO DB — Drive/Curiosity signals for autonomous investigation."""
3
+
4
+ import importlib
5
+ import json
6
+ import sys
7
+ from datetime import datetime, timezone
8
+
9
+
10
+ def _core():
11
+ module = sys.modules.get("db._core")
12
+ if module is None:
13
+ module = importlib.import_module("db._core")
14
+ return module
15
+
16
+
17
+ MAX_ACTIVE_SIGNALS = 30
18
+ REINFORCE_BOOST = 0.15
19
+ RISING_THRESHOLD = 0.4
20
+ READY_THRESHOLD = 0.7
21
+ RISING_DECAY_RATE = 0.03 # slower decay once rising
22
+
23
+ VALID_SIGNAL_TYPES = {"anomaly", "pattern", "connection", "gap", "opportunity"}
24
+ VALID_STATUSES = {"latent", "rising", "ready", "acted", "dismissed"}
25
+
26
+
27
+ def _table_exists(conn) -> bool:
28
+ row = conn.execute(
29
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='drive_signals' LIMIT 1"
30
+ ).fetchone()
31
+ return row is not None
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
36
+
37
+
38
+ def create_drive_signal(
39
+ signal_type: str,
40
+ source: str,
41
+ summary: str,
42
+ source_id: str = "",
43
+ area: str = "",
44
+ tension: float = 0.3,
45
+ decay_rate: float = 0.05,
46
+ evidence: list[str] | None = None,
47
+ ) -> dict:
48
+ """Create a new drive signal. Returns the created row as dict."""
49
+ conn = _core().get_db()
50
+ if not _table_exists(conn):
51
+ return {"ok": False, "error": "drive_signals table not yet created"}
52
+
53
+ if signal_type not in VALID_SIGNAL_TYPES:
54
+ return {"ok": False, "error": f"Invalid signal_type: {signal_type}. Must be one of {VALID_SIGNAL_TYPES}"}
55
+
56
+ tension = max(0.0, min(1.0, tension))
57
+ evidence_json = json.dumps(evidence or [summary[:200]], ensure_ascii=False)
58
+ now = _now_iso()
59
+
60
+ # Enforce max active signals — drop weakest latent if at limit
61
+ active_count = conn.execute(
62
+ "SELECT COUNT(*) FROM drive_signals WHERE status IN ('latent', 'rising', 'ready')"
63
+ ).fetchone()[0]
64
+ if active_count >= MAX_ACTIVE_SIGNALS:
65
+ conn.execute(
66
+ "DELETE FROM drive_signals WHERE id = ("
67
+ " SELECT id FROM drive_signals"
68
+ " WHERE status = 'latent'"
69
+ " ORDER BY tension ASC, first_seen ASC"
70
+ " LIMIT 1"
71
+ ")"
72
+ )
73
+ conn.commit()
74
+
75
+ cursor = conn.execute(
76
+ "INSERT INTO drive_signals "
77
+ "(signal_type, source, source_id, area, summary, tension, evidence, "
78
+ " status, first_seen, last_reinforced, decay_rate) "
79
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 'latent', ?, ?, ?)",
80
+ (signal_type, source, source_id, area, summary, tension,
81
+ evidence_json, now, now, decay_rate),
82
+ )
83
+ conn.commit()
84
+ signal_id = cursor.lastrowid
85
+ return {"ok": True, "id": signal_id, "tension": tension, "status": "latent"}
86
+
87
+
88
+ def reinforce_drive_signal(signal_id: int, observation: str) -> dict:
89
+ """Reinforce an existing signal: add evidence, boost tension, maybe promote status."""
90
+ conn = _core().get_db()
91
+ if not _table_exists(conn):
92
+ return {"ok": False, "error": "drive_signals table not yet created"}
93
+
94
+ row = conn.execute(
95
+ "SELECT * FROM drive_signals WHERE id = ?", (signal_id,)
96
+ ).fetchone()
97
+ if not row:
98
+ return {"ok": False, "error": f"Signal {signal_id} not found"}
99
+
100
+ status = row["status"]
101
+ if status in ("acted", "dismissed"):
102
+ return {"ok": False, "error": f"Signal {signal_id} is already {status}"}
103
+
104
+ # Update evidence
105
+ try:
106
+ evidence = json.loads(row["evidence"] or "[]")
107
+ except (json.JSONDecodeError, TypeError):
108
+ evidence = []
109
+ evidence.append(observation[:500])
110
+
111
+ # Boost tension
112
+ old_tension = float(row["tension"] or 0.3)
113
+ new_tension = min(1.0, old_tension + REINFORCE_BOOST)
114
+
115
+ # Status promotion
116
+ new_status = status
117
+ reinforce_count = len(evidence)
118
+ if new_tension >= READY_THRESHOLD or reinforce_count >= 3:
119
+ new_status = "ready"
120
+ elif new_tension >= RISING_THRESHOLD:
121
+ new_status = "rising"
122
+
123
+ # Rising signals decay slower
124
+ new_decay = RISING_DECAY_RATE if new_status in ("rising", "ready") else float(row["decay_rate"] or 0.05)
125
+
126
+ now = _now_iso()
127
+ conn.execute(
128
+ "UPDATE drive_signals SET tension = ?, evidence = ?, status = ?, "
129
+ "decay_rate = ?, last_reinforced = ? WHERE id = ?",
130
+ (new_tension, json.dumps(evidence, ensure_ascii=False),
131
+ new_status, new_decay, now, signal_id),
132
+ )
133
+ conn.commit()
134
+ return {
135
+ "ok": True, "id": signal_id,
136
+ "old_tension": old_tension, "new_tension": new_tension,
137
+ "old_status": status, "new_status": new_status,
138
+ "evidence_count": reinforce_count,
139
+ }
140
+
141
+
142
+ def get_drive_signals(
143
+ status: str | None = None,
144
+ area: str | None = None,
145
+ limit: int = 30,
146
+ ) -> list[dict]:
147
+ """List active drive signals, optionally filtered."""
148
+ conn = _core().get_db()
149
+ if not _table_exists(conn):
150
+ return []
151
+
152
+ clauses = []
153
+ params: list = []
154
+ if status:
155
+ clauses.append("status = ?")
156
+ params.append(status)
157
+ else:
158
+ # Default: only active signals
159
+ clauses.append("status IN ('latent', 'rising', 'ready')")
160
+ if area:
161
+ clauses.append("area = ?")
162
+ params.append(area)
163
+
164
+ where = " AND ".join(clauses) if clauses else "1=1"
165
+ params.append(min(limit, 100))
166
+
167
+ rows = conn.execute(
168
+ f"SELECT * FROM drive_signals WHERE {where} ORDER BY tension DESC, last_reinforced DESC LIMIT ?",
169
+ params,
170
+ ).fetchall()
171
+ return [dict(r) for r in rows]
172
+
173
+
174
+ def get_drive_signal(signal_id: int) -> dict | None:
175
+ """Get a single signal with full details."""
176
+ conn = _core().get_db()
177
+ if not _table_exists(conn):
178
+ return None
179
+
180
+ row = conn.execute(
181
+ "SELECT * FROM drive_signals WHERE id = ?", (signal_id,)
182
+ ).fetchone()
183
+ return dict(row) if row else None
184
+
185
+
186
+ def update_drive_signal_status(
187
+ signal_id: int,
188
+ status: str,
189
+ outcome: str = "",
190
+ ) -> dict:
191
+ """Transition a signal to a new status (acted/dismissed)."""
192
+ conn = _core().get_db()
193
+ if not _table_exists(conn):
194
+ return {"ok": False, "error": "drive_signals table not yet created"}
195
+
196
+ if status not in VALID_STATUSES:
197
+ return {"ok": False, "error": f"Invalid status: {status}"}
198
+
199
+ row = conn.execute(
200
+ "SELECT * FROM drive_signals WHERE id = ?", (signal_id,)
201
+ ).fetchone()
202
+ if not row:
203
+ return {"ok": False, "error": f"Signal {signal_id} not found"}
204
+
205
+ now = _now_iso()
206
+ updates = {"status": status}
207
+ if status == "acted":
208
+ updates["acted_at"] = now
209
+ if outcome:
210
+ updates["outcome"] = outcome
211
+
212
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
213
+ params = list(updates.values()) + [signal_id]
214
+ conn.execute(f"UPDATE drive_signals SET {set_clause} WHERE id = ?", params)
215
+ conn.commit()
216
+ return {"ok": True, "id": signal_id, "new_status": status}
217
+
218
+
219
+ def decay_drive_signals() -> dict:
220
+ """Apply daily decay to all active signals. Kill those at or below 0."""
221
+ conn = _core().get_db()
222
+ if not _table_exists(conn):
223
+ return {"decayed": 0, "killed": 0}
224
+
225
+ # Ready signals don't decay
226
+ rows = conn.execute(
227
+ "SELECT id, tension, decay_rate, status FROM drive_signals "
228
+ "WHERE status IN ('latent', 'rising')"
229
+ ).fetchall()
230
+
231
+ decayed = 0
232
+ killed = 0
233
+ for row in rows:
234
+ new_tension = float(row["tension"]) - float(row["decay_rate"] or 0.05)
235
+ if new_tension <= 0:
236
+ conn.execute("DELETE FROM drive_signals WHERE id = ?", (row["id"],))
237
+ killed += 1
238
+ else:
239
+ conn.execute(
240
+ "UPDATE drive_signals SET tension = ? WHERE id = ?",
241
+ (new_tension, row["id"]),
242
+ )
243
+ decayed += 1
244
+
245
+ conn.commit()
246
+ return {"decayed": decayed, "killed": killed}
247
+
248
+
249
+ def find_similar_drive_signal(summary: str, area: str = "") -> dict | None:
250
+ """Find an existing active signal similar to the given summary.
251
+
252
+ Uses keyword overlap heuristic to avoid duplicates.
253
+ """
254
+ conn = _core().get_db()
255
+ if not _table_exists(conn):
256
+ return None
257
+
258
+ # Extract meaningful words (4+ chars) from summary
259
+ words = {w.lower() for w in summary.split() if len(w) >= 4}
260
+ if not words:
261
+ return None
262
+
263
+ clauses = ["status IN ('latent', 'rising', 'ready')"]
264
+ params: list = []
265
+ if area:
266
+ clauses.append("area = ?")
267
+ params.append(area)
268
+
269
+ where = " AND ".join(clauses)
270
+ rows = conn.execute(
271
+ f"SELECT * FROM drive_signals WHERE {where} ORDER BY tension DESC",
272
+ params,
273
+ ).fetchall()
274
+
275
+ best_match = None
276
+ best_score = 0.0
277
+ for row in rows:
278
+ row_words = {w.lower() for w in (row["summary"] or "").split() if len(w) >= 4}
279
+ if not row_words:
280
+ continue
281
+ overlap = len(words & row_words)
282
+ score = overlap / max(len(words | row_words), 1)
283
+ if score > best_score and score >= 0.4: # 40% word overlap threshold
284
+ best_score = score
285
+ best_match = dict(row)
286
+
287
+ return best_match
288
+
289
+
290
+ def drive_signal_stats() -> dict:
291
+ """Return aggregate stats about drive signals."""
292
+ conn = _core().get_db()
293
+ if not _table_exists(conn):
294
+ return {"total": 0, "by_status": {}, "by_type": {}, "by_area": {}}
295
+
296
+ total = conn.execute("SELECT COUNT(*) FROM drive_signals").fetchone()[0]
297
+
298
+ by_status = {}
299
+ for row in conn.execute(
300
+ "SELECT status, COUNT(*) as cnt FROM drive_signals GROUP BY status"
301
+ ).fetchall():
302
+ by_status[row["status"]] = row["cnt"]
303
+
304
+ by_type = {}
305
+ for row in conn.execute(
306
+ "SELECT signal_type, COUNT(*) as cnt FROM drive_signals "
307
+ "WHERE status IN ('latent', 'rising', 'ready') GROUP BY signal_type"
308
+ ).fetchall():
309
+ by_type[row["signal_type"]] = row["cnt"]
310
+
311
+ by_area = {}
312
+ for row in conn.execute(
313
+ "SELECT area, COUNT(*) as cnt FROM drive_signals "
314
+ "WHERE status IN ('latent', 'rising', 'ready') AND area != '' GROUP BY area"
315
+ ).fetchall():
316
+ by_area[row["area"]] = row["cnt"]
317
+
318
+ return {"total": total, "by_status": by_status, "by_type": by_type, "by_area": by_area}
package/src/db/_schema.py CHANGED
@@ -752,6 +752,37 @@ def _m30_hot_context_memory(conn):
752
752
  _migrate_add_index(conn, "idx_recent_events_session", "recent_events", "session_id, created_at")
753
753
 
754
754
 
755
+ def _m31_drive_signals(conn):
756
+ """Drive/Curiosity layer — autonomous tension-based investigation signals."""
757
+ conn.execute("""
758
+ CREATE TABLE IF NOT EXISTS drive_signals (
759
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
760
+ signal_type TEXT NOT NULL,
761
+ source TEXT NOT NULL,
762
+ source_id TEXT DEFAULT '',
763
+ area TEXT DEFAULT '',
764
+ summary TEXT NOT NULL,
765
+ tension REAL DEFAULT 0.3,
766
+ evidence TEXT DEFAULT '[]',
767
+ status TEXT DEFAULT 'latent',
768
+ first_seen TEXT DEFAULT (datetime('now')),
769
+ last_reinforced TEXT,
770
+ acted_at TEXT,
771
+ outcome TEXT,
772
+ decay_rate REAL DEFAULT 0.05
773
+ )
774
+ """)
775
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_drive_status ON drive_signals(status)")
776
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_drive_area ON drive_signals(area)")
777
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_drive_tension ON drive_signals(tension)")
778
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_drive_first_seen ON drive_signals(first_seen)")
779
+ # Register drive_decay in maintenance_schedule
780
+ conn.execute(
781
+ "INSERT OR IGNORE INTO maintenance_schedule (task_name, interval_hours) VALUES (?, ?)",
782
+ ('drive_decay', 24),
783
+ )
784
+
785
+
755
786
  MIGRATIONS = [
756
787
  (1, "learnings_columns", _m1_learnings_columns),
757
788
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -783,6 +814,7 @@ MIGRATIONS = [
783
814
  (28, "automation_runs", _m28_automation_runs),
784
815
  (29, "item_history_and_soft_delete", _m29_item_history_and_soft_delete),
785
816
  (30, "hot_context_memory", _m30_hot_context_memory),
817
+ (31, "drive_signals", _m31_drive_signals),
786
818
  ]
787
819
 
788
820
 
@@ -55,5 +55,8 @@ def _run_task(task_name: str):
55
55
  prune_adaptive_log()
56
56
  except Exception:
57
57
  pass
58
+ elif task_name == "drive_decay":
59
+ from db import decay_drive_signals
60
+ decay_drive_signals()
58
61
  elif task_name == "graph_maintenance":
59
62
  pass # Future: orphan cleanup, consolidation
@@ -894,6 +894,22 @@ def handle_task_close(
894
894
  },
895
895
  ttl_hours=24,
896
896
  )
897
+ # ── Drive/Curiosity: detect signals from task evidence (best-effort) ──
898
+ try:
899
+ _drive_text = " ".join(filter(None, [
900
+ outcome_notes, clean_evidence, change_summary, change_why,
901
+ ]))
902
+ if _drive_text and len(_drive_text.strip()) >= 15:
903
+ from tools_drive import detect_drive_signal as _detect_drive
904
+ _detect_drive(
905
+ _drive_text[:600],
906
+ source="task_close",
907
+ source_id=task_id,
908
+ area=task.get("area", ""),
909
+ )
910
+ except Exception:
911
+ pass # Drive detection is best-effort
912
+
897
913
  open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
898
914
 
899
915
  response = {
@@ -2039,6 +2039,29 @@ def main():
2039
2039
  except Exception as e:
2040
2040
  print(f" Skill autopromotion error: {e}", file=sys.stderr)
2041
2041
 
2042
+ # Apply drive synthesis (investigate/dismiss/promote signals)
2043
+ drive_synthesis = synthesis.get("drive_synthesis", {})
2044
+ if drive_synthesis:
2045
+ print("[apply] Processing drive synthesis...")
2046
+ try:
2047
+ from db import update_drive_signal_status, reinforce_drive_signal
2048
+ for item in drive_synthesis.get("investigated", []):
2049
+ signal_id = item.get("signal_id")
2050
+ action_taken = item.get("action_taken", "acted")
2051
+ outcome = item.get("outcome", item.get("finding", ""))
2052
+ if signal_id and outcome:
2053
+ update_drive_signal_status(signal_id, action_taken, outcome[:500])
2054
+ stats["applied"] += 1
2055
+ print(f" Drive signal #{signal_id}: {action_taken}")
2056
+ for item in drive_synthesis.get("promoted", []):
2057
+ signal_id = item.get("signal_id")
2058
+ reason = item.get("reason", "promoted by Deep Sleep")
2059
+ if signal_id:
2060
+ reinforce_drive_signal(signal_id, f"Deep Sleep promotion: {reason}"[:500])
2061
+ print(f" Drive signal #{signal_id}: promoted")
2062
+ except Exception as e:
2063
+ print(f" Drive synthesis error: {e}", file=sys.stderr)
2064
+
2042
2065
  # Create followups for abandoned projects
2043
2066
  abandoned_results = create_abandoned_followups(synthesis)
2044
2067
  for r in abandoned_results:
@@ -143,6 +143,16 @@ When generating `followup_create`, prefer descriptions that start with a concret
143
143
 
144
144
  Avoid vague followups that merely restate the diagnosis.
145
145
 
146
+ ### 10. Drive/Curiosity Synthesis
147
+ Review the active drive signals (accessible via `nexo_drive_signals`). For each READY signal:
148
+ - Investigate silently: check metrics, recall memory, cross-reference learnings
149
+ - If the investigation yields an actionable finding, create an action item and mark the signal as `acted`
150
+ - If the signal is stale or no longer relevant, dismiss it with a reason
151
+ - Cross-reference RISING signals across areas — if two signals from different domains converge, promote to READY
152
+ - Apply decay to LATENT signals that have no recent reinforcement
153
+
154
+ Drive signals represent NEXO's autonomous curiosity. Treat them as leads worth investigating, not noise to dismiss.
155
+
146
156
  ## Output Format
147
157
 
148
158
  Return ONLY valid JSON. No markdown code fences. No explanation text.
@@ -273,6 +283,30 @@ Return ONLY valid JSON. No markdown code fences. No explanation text.
273
283
  }
274
284
  ],
275
285
 
286
+ "drive_synthesis": {
287
+ "investigated": [
288
+ {
289
+ "signal_id": 1,
290
+ "summary": "What the signal was about",
291
+ "finding": "What investigation revealed",
292
+ "action_taken": "acted|dismissed",
293
+ "outcome": "Concrete result or reason for dismissal"
294
+ }
295
+ ],
296
+ "promoted": [
297
+ {
298
+ "signal_id": 2,
299
+ "reason": "Why this signal was promoted from rising to ready"
300
+ }
301
+ ],
302
+ "cross_area_connections": [
303
+ {
304
+ "signal_ids": [3, 7],
305
+ "connection": "How these signals from different areas relate"
306
+ }
307
+ ]
308
+ },
309
+
276
310
  "trust_calibration": {
277
311
  "score": 72,
278
312
  "reasoning": "Why this score -- based on corrections, completions, autonomy, proactivity, and user satisfaction signals across ALL sessions",
package/src/server.py CHANGED
@@ -32,6 +32,12 @@ from tools_system_catalog import (
32
32
  handle_system_catalog,
33
33
  handle_tool_explain,
34
34
  )
35
+ from tools_drive import (
36
+ handle_drive_signals,
37
+ handle_drive_reinforce,
38
+ handle_drive_act,
39
+ handle_drive_dismiss,
40
+ )
35
41
  from user_context import get_context as _get_ctx
36
42
  from tools_coordination import (
37
43
  handle_track, handle_untrack, handle_files,
@@ -1143,6 +1149,62 @@ def nexo_plugin_remove(filename: str) -> str:
1143
1149
  return f"Error removing plugin {filename}: {e}"
1144
1150
 
1145
1151
 
1152
+ # ── Drive / Curiosity (4 tools) ──────────────────────────────────
1153
+
1154
+ @mcp.tool
1155
+ def nexo_drive_signals(status: str = "", area: str = "", limit: int = 20) -> str:
1156
+ """List autonomous drive/curiosity signals.
1157
+
1158
+ Drive signals are observations NEXO accumulates during normal work.
1159
+ When tension crosses threshold, NEXO investigates silently.
1160
+
1161
+ Args:
1162
+ status: Filter by status (latent, rising, ready, acted, dismissed). Default: active only.
1163
+ area: Filter by operational area (shopify, google-ads, wazion, nexo, etc.).
1164
+ limit: Max signals to return (default 20).
1165
+ """
1166
+ return handle_drive_signals(status, area, limit)
1167
+
1168
+
1169
+ @mcp.tool
1170
+ def nexo_drive_reinforce(signal_id: int, observation: str) -> str:
1171
+ """Reinforce a drive signal with a new observation.
1172
+
1173
+ Increases tension and may promote the signal status (latent → rising → ready).
1174
+
1175
+ Args:
1176
+ signal_id: Signal ID to reinforce.
1177
+ observation: New observation that supports this signal.
1178
+ """
1179
+ return handle_drive_reinforce(signal_id, observation)
1180
+
1181
+
1182
+ @mcp.tool
1183
+ def nexo_drive_act(signal_id: int, outcome: str) -> str:
1184
+ """Mark a drive signal as investigated with an outcome.
1185
+
1186
+ Call this after NEXO has autonomously investigated a READY signal.
1187
+
1188
+ Args:
1189
+ signal_id: Signal ID that was investigated.
1190
+ outcome: What was found during investigation.
1191
+ """
1192
+ return handle_drive_act(signal_id, outcome)
1193
+
1194
+
1195
+ @mcp.tool
1196
+ def nexo_drive_dismiss(signal_id: int, reason: str) -> str:
1197
+ """Dismiss a drive signal (archived, not deleted).
1198
+
1199
+ Call this when a signal is not worth investigating.
1200
+
1201
+ Args:
1202
+ signal_id: Signal ID to dismiss.
1203
+ reason: Why this signal was dismissed.
1204
+ """
1205
+ return handle_drive_dismiss(signal_id, reason)
1206
+
1207
+
1146
1208
  if __name__ == "__main__":
1147
1209
  _server_init()
1148
1210
  mcp.run(**_run_kwargs_from_env())
@@ -0,0 +1,187 @@
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 re
10
+ from db import (
11
+ create_drive_signal, reinforce_drive_signal, get_drive_signals,
12
+ get_drive_signal, update_drive_signal_status, decay_drive_signals,
13
+ find_similar_drive_signal, drive_signal_stats,
14
+ )
15
+
16
+
17
+ # ── Heuristic detection keywords ─────────────────────────────────────
18
+
19
+ _ANOMALY_PATTERNS = [
20
+ re.compile(r"\b(subió|bajó|cayó|subi[oó]|baj[oó]|dropped|spiked|jumped)\b.*\b\d+%", re.I),
21
+ re.compile(r"\b(inesperado|unexpected|anomal|raro|weird|strange)\b", re.I),
22
+ re.compile(r"\b(error rate|tasa de error|failure|fallo)\b.*\b(subi|increas|grew)\b", re.I),
23
+ ]
24
+
25
+ _PATTERN_INDICATORS = [
26
+ re.compile(r"\b(otra vez|again|de nuevo|siempre pasa|keeps happening|recurring)\b", re.I),
27
+ re.compile(r"\b(cada vez que|every time|whenever)\b", re.I),
28
+ re.compile(r"\b(mismo (problema|error|issue)|same (problem|error|issue))\b", re.I),
29
+ ]
30
+
31
+ _GAP_INDICATORS = [
32
+ re.compile(r"\b(no sé cómo|don'?t know how|no entiendo|unclear how)\b", re.I),
33
+ re.compile(r"\b(falta documentación|missing docs|undocumented)\b", re.I),
34
+ ]
35
+
36
+ _OPPORTUNITY_INDICATORS = [
37
+ re.compile(r"\b(benchmark|media del sector|industry average)\b.*\b(bajo|low|por debajo|below)\b", re.I),
38
+ re.compile(r"\b(podríamos|could|se podría|we could|opportunity)\b.*\b(automatiz|improve|mejorar|optimiz)\b", re.I),
39
+ ]
40
+
41
+
42
+ def _classify_signal(text: str) -> str | None:
43
+ """Classify text into a signal type, or None if nothing interesting."""
44
+ for pattern in _ANOMALY_PATTERNS:
45
+ if pattern.search(text):
46
+ return "anomaly"
47
+ for pattern in _PATTERN_INDICATORS:
48
+ if pattern.search(text):
49
+ return "pattern"
50
+ for pattern in _GAP_INDICATORS:
51
+ if pattern.search(text):
52
+ return "gap"
53
+ for pattern in _OPPORTUNITY_INDICATORS:
54
+ if pattern.search(text):
55
+ return "opportunity"
56
+ return None
57
+
58
+
59
+ def _infer_area(text: str) -> str:
60
+ """Infer operational area from text keywords."""
61
+ text_lower = text.lower()
62
+ area_keywords = {
63
+ "shopify": ["shopify", "tienda", "pedido", "producto", "sku"],
64
+ "google-ads": ["google ads", "campaña", "campaign", "cpc", "pmax", "roas", "gads"],
65
+ "meta-ads": ["meta ads", "facebook", "instagram", "pixel", "capi"],
66
+ "wazion": ["wazion", "whatsapp", "wa ", "baileys"],
67
+ "nexo": ["nexo", "brain", "mcp", "cognitive"],
68
+ "canaririural": ["canarirural", "canari", "reserva", "hospedaje", "alojamiento", "propietario"],
69
+ "seo": ["seo", "search console", "indexación", "ranking"],
70
+ "email": ["email", "correo", "inbox", "smtp"],
71
+ }
72
+ for area, keywords in area_keywords.items():
73
+ for kw in keywords:
74
+ if kw in text_lower:
75
+ return area
76
+ return ""
77
+
78
+
79
+ def detect_drive_signal(
80
+ context_hint: str,
81
+ source: str,
82
+ source_id: str = "",
83
+ area: str = "",
84
+ ) -> dict | None:
85
+ """Analyze text for interesting signals. Creates or reinforces.
86
+
87
+ Called internally from heartbeat and task_close. Not a public MCP tool.
88
+ Returns the signal dict if created/reinforced, None otherwise.
89
+ """
90
+ if not context_hint or len(context_hint.strip()) < 15:
91
+ return None
92
+
93
+ signal_type = _classify_signal(context_hint)
94
+ if not signal_type:
95
+ return None
96
+
97
+ inferred_area = area or _infer_area(context_hint)
98
+
99
+ # Check for similar existing signal
100
+ existing = find_similar_drive_signal(context_hint, inferred_area)
101
+ if existing:
102
+ result = reinforce_drive_signal(existing["id"], context_hint[:500])
103
+ return result if result.get("ok") else None
104
+
105
+ # Create new
106
+ result = create_drive_signal(
107
+ signal_type=signal_type,
108
+ source=source,
109
+ source_id=source_id,
110
+ area=inferred_area,
111
+ summary=context_hint[:300],
112
+ )
113
+ return result if result.get("ok") else None
114
+
115
+
116
+ # ── Public MCP tool handlers ─────────────────────────────────────────
117
+
118
+ def handle_drive_signals(
119
+ status: str = "",
120
+ area: str = "",
121
+ limit: int = 20,
122
+ ) -> str:
123
+ """List drive signals, optionally filtered by status and area."""
124
+ signals = get_drive_signals(
125
+ status=status or None,
126
+ area=area or None,
127
+ limit=limit,
128
+ )
129
+ if not signals:
130
+ return "No drive signals found."
131
+
132
+ stats = drive_signal_stats()
133
+ lines = [
134
+ f"DRIVE SIGNALS ({len(signals)} shown, {stats['total']} total):",
135
+ f" By status: {json.dumps(stats.get('by_status', {}), ensure_ascii=False)}",
136
+ "",
137
+ ]
138
+ for s in signals:
139
+ evidence_count = 0
140
+ try:
141
+ evidence_count = len(json.loads(s.get("evidence") or "[]"))
142
+ except (json.JSONDecodeError, TypeError):
143
+ pass
144
+ tension_bar = "█" * int(float(s.get("tension", 0)) * 10)
145
+ lines.append(
146
+ f" [{s['id']}] {s['status'].upper()} {tension_bar} "
147
+ f"t={s['tension']:.2f} ({s['signal_type']}) "
148
+ f"{'[' + s['area'] + '] ' if s.get('area') else ''}"
149
+ f"{s['summary'][:80]}"
150
+ f" ({evidence_count} obs, decay={s.get('decay_rate', 0.05):.2f})"
151
+ )
152
+ return "\n".join(lines)
153
+
154
+
155
+ def handle_drive_reinforce(signal_id: int, observation: str) -> str:
156
+ """Manually reinforce a drive signal with a new observation."""
157
+ if not observation.strip():
158
+ return "ERROR: observation cannot be empty"
159
+ result = reinforce_drive_signal(signal_id, observation)
160
+ if not result.get("ok"):
161
+ return f"ERROR: {result.get('error', 'unknown')}"
162
+ return (
163
+ f"Signal #{signal_id} reinforced: "
164
+ f"tension {result['old_tension']:.2f} → {result['new_tension']:.2f}, "
165
+ f"status {result['old_status']} → {result['new_status']}, "
166
+ f"{result['evidence_count']} observations total"
167
+ )
168
+
169
+
170
+ def handle_drive_act(signal_id: int, outcome: str) -> str:
171
+ """Mark a drive signal as investigated with an outcome."""
172
+ if not outcome.strip():
173
+ return "ERROR: outcome cannot be empty"
174
+ result = update_drive_signal_status(signal_id, "acted", outcome)
175
+ if not result.get("ok"):
176
+ return f"ERROR: {result.get('error', 'unknown')}"
177
+ return f"Signal #{signal_id} marked as ACTED. Outcome recorded."
178
+
179
+
180
+ def handle_drive_dismiss(signal_id: int, reason: str) -> str:
181
+ """Dismiss a drive signal with a reason (archived, not deleted)."""
182
+ if not reason.strip():
183
+ return "ERROR: reason cannot be empty"
184
+ result = update_drive_signal_status(signal_id, "dismissed", reason)
185
+ if not result.get("ok"):
186
+ return f"ERROR: {result.get('error', 'unknown')}"
187
+ return f"Signal #{signal_id} dismissed. Reason: {reason}"
@@ -518,6 +518,23 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
518
518
  except Exception:
519
519
  pass
520
520
 
521
+ # ── Drive/Curiosity: detect signals from context_hint (best-effort) ──
522
+ try:
523
+ if context_hint and len(context_hint.strip()) >= 15:
524
+ from tools_drive import detect_drive_signal as _detect_drive
525
+ _drive_result = _detect_drive(context_hint, source="heartbeat", source_id=sid)
526
+ if _drive_result:
527
+ # Check for READY signals relevant to current area
528
+ from db import get_drive_signals as _get_drive
529
+ _ready = _get_drive(status="ready", limit=3)
530
+ if _ready:
531
+ parts.append("")
532
+ parts.append(f"DRIVE: {len(_ready)} mature signal(s) ready for investigation")
533
+ for _ds in _ready[:2]:
534
+ parts.append(f" [{_ds['id']}] {_ds['signal_type']}: {_ds['summary'][:80]}")
535
+ except Exception:
536
+ pass # Drive detection is best-effort, never block heartbeat
537
+
521
538
  # ── Layer 3: DIARY_OVERDUE signal based on heartbeat count + time ──
522
539
  conn = get_db()
523
540
  row = conn.execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone()