nexo-brain 4.0.0 → 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.0",
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/README.md CHANGED
@@ -87,7 +87,7 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
- Version `4.0.0` closes the next memory-surface gap:
90
+ Version `4.0.1` keeps the 4.0 release aligned across channels while preserving the next memory-surface gap closure:
91
91
 
92
92
  - non-text artifacts now have a first-class multimodal reference layer instead of living outside the memory model
93
93
  - pre-compaction auto-flush now persists actionable session state before context compression can erase it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "4.0.0",
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",
@@ -1,7 +1,7 @@
1
1
  """NEXO Cognitive — Trust scoring, sentiment, dissonance."""
2
2
  import re
3
3
  import numpy as np
4
- from datetime import UTC, datetime, timedelta
4
+ from datetime import datetime, timedelta, timezone
5
5
  from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array
6
6
  from cognitive._core import POSITIVE_SIGNALS, NEGATIVE_SIGNALS, URGENCY_SIGNALS
7
7
 
@@ -412,7 +412,7 @@ def adjust_trust(event: str, context: str = "", custom_delta: float = None) -> d
412
412
  def get_trust_history(days: int = 7) -> dict:
413
413
  """Get trust score history and sentiment summary."""
414
414
  db = _get_db()
415
- cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
415
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
416
416
 
417
417
  # Trust events
418
418
  events = db.execute(
@@ -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
 
@@ -16,6 +16,28 @@ WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
16
16
  DELETE_LIKE_TOOLS = {"Delete"}
17
17
  NEXO_CODE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent))).expanduser().resolve()
18
18
  LIVE_REPO_ROOT = NEXO_CODE_ROOT.parent if NEXO_CODE_ROOT.name == "src" else NEXO_CODE_ROOT
19
+ PUBLIC_REPO_DIRS = {
20
+ ".claude-plugin",
21
+ ".github",
22
+ "bin",
23
+ "clawhub-skill",
24
+ "community",
25
+ "docs",
26
+ "hooks",
27
+ "openclaw-plugin",
28
+ "src",
29
+ "templates",
30
+ "tests",
31
+ }
32
+ PUBLIC_REPO_FILES = {
33
+ ".mcp.json",
34
+ "CHANGELOG.md",
35
+ "LICENSE",
36
+ "README.md",
37
+ "docker-compose.yml",
38
+ "package-lock.json",
39
+ "package.json",
40
+ }
19
41
 
20
42
 
21
43
  def _operation_kind(tool_name: str) -> str:
@@ -54,11 +76,31 @@ def _automation_live_repo_guard_enabled() -> bool:
54
76
  )
55
77
 
56
78
 
79
+ def _has_git_marker(root: Path) -> bool:
80
+ return (root / ".git").exists()
81
+
82
+
83
+ def _is_public_repo_surface(candidate: Path) -> bool:
84
+ try:
85
+ relative = candidate.relative_to(LIVE_REPO_ROOT)
86
+ except ValueError:
87
+ return False
88
+
89
+ parts = relative.parts
90
+ if not parts:
91
+ return False
92
+ if parts[0] in PUBLIC_REPO_DIRS:
93
+ return True
94
+ return len(parts) == 1 and parts[0] in PUBLIC_REPO_FILES
95
+
96
+
57
97
  def _is_live_repo_path(path: str) -> bool:
58
98
  if not str(path or "").strip():
59
99
  return False
60
100
  try:
61
- return _is_relative_to(_resolve_runtime_path(path), LIVE_REPO_ROOT)
101
+ if not _has_git_marker(LIVE_REPO_ROOT):
102
+ return False
103
+ return _is_public_repo_surface(_resolve_runtime_path(path))
62
104
  except Exception:
63
105
  return False
64
106
 
@@ -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()
@@ -542,6 +559,17 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
542
559
  except Exception:
543
560
  pass # guard_log table may not exist in older installs
544
561
 
562
+ if context_hint and _hint_suggests_correction(context_hint):
563
+ try:
564
+ if not _recent_learning_capture_exists(conn, sid, window_seconds=300):
565
+ parts.append("")
566
+ parts.append(
567
+ "⚠ LEARNING REMINDER: This looks like a user correction and no recent learning was captured. "
568
+ "If it revealed a reusable pattern, write `nexo_learning_add` NOW."
569
+ )
570
+ except Exception:
571
+ pass # Best-effort reminder only
572
+
545
573
  return "\n".join(parts)
546
574
 
547
575
 
@@ -816,6 +844,64 @@ def _hint_suggests_code_edit(hint: str) -> bool:
816
844
  return any(signal in hint_lower for signal in edit_signals)
817
845
 
818
846
 
847
+ def _hint_suggests_correction(hint: str) -> bool:
848
+ """Detect explicit user correction signals in a heartbeat context hint."""
849
+ hint_lower = hint.lower()
850
+ correction_signals = [
851
+ "that's wrong",
852
+ "that is wrong",
853
+ "wrong approach",
854
+ "not like that",
855
+ "fix this",
856
+ "fix it",
857
+ "está mal",
858
+ "esta mal",
859
+ "mal hecho",
860
+ "incorrecto",
861
+ "te equivocas",
862
+ "te has equivocado",
863
+ "lo hiciste mal",
864
+ "no era eso",
865
+ "corrige esto",
866
+ "corrígelo",
867
+ "corrigelo",
868
+ "ya te dije",
869
+ "otra vez el mismo",
870
+ "de nuevo el mismo",
871
+ "no deberías",
872
+ "no deberias",
873
+ "shouldn't have",
874
+ "should not have",
875
+ ]
876
+ return any(signal in hint_lower for signal in correction_signals)
877
+
878
+
879
+ def _recent_learning_capture_exists(conn, sid: str, window_seconds: int = 300) -> bool:
880
+ """Check whether a recent learning was captured manually or via protocol task close."""
881
+ cutoff_epoch = time.time() - window_seconds
882
+
883
+ row = conn.execute(
884
+ "SELECT 1 FROM learnings WHERE created_at >= ? LIMIT 1",
885
+ (cutoff_epoch,),
886
+ ).fetchone()
887
+ if row:
888
+ return True
889
+
890
+ row = conn.execute(
891
+ """
892
+ SELECT 1
893
+ FROM protocol_tasks
894
+ WHERE session_id = ?
895
+ AND learning_id IS NOT NULL
896
+ AND closed_at IS NOT NULL
897
+ AND CAST(strftime('%s', closed_at) AS INTEGER) >= ?
898
+ LIMIT 1
899
+ """,
900
+ (sid, int(cutoff_epoch)),
901
+ ).fetchone()
902
+ return bool(row)
903
+
904
+
819
905
  def _toolbox_summary(conn) -> str:
820
906
  """Quick count of available skills and behavioral learnings for startup reminder."""
821
907
  try:
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  """Inspectable user-state model built from multiple NEXO signals."""
4
4
 
5
5
  import json
6
- from datetime import UTC, datetime, timedelta
6
+ from datetime import datetime, timedelta, timezone
7
7
 
8
8
  import cognitive
9
9
  from db import get_db
@@ -32,7 +32,7 @@ def init_tables() -> None:
32
32
 
33
33
 
34
34
  def _recent_correction_count(days: int) -> int:
35
- cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
35
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
36
36
  row = cognitive._get_db().execute(
37
37
  "SELECT COUNT(*) FROM memory_corrections WHERE created_at >= ?",
38
38
  (cutoff,),
@@ -41,7 +41,7 @@ def _recent_correction_count(days: int) -> int:
41
41
 
42
42
 
43
43
  def _recent_trust_event_count(days: int, event_name: str) -> int:
44
- cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
44
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
45
45
  row = cognitive._get_db().execute(
46
46
  "SELECT COUNT(*) FROM trust_score WHERE created_at >= ? AND event = ?",
47
47
  (cutoff, event_name),
@@ -50,7 +50,7 @@ def _recent_trust_event_count(days: int, event_name: str) -> int:
50
50
 
51
51
 
52
52
  def _recent_diary_signal_count(days: int) -> int:
53
- cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat(timespec="seconds")
53
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(timespec="seconds")
54
54
  row = get_db().execute(
55
55
  "SELECT COUNT(*) FROM session_diary WHERE created_at >= ? AND user_signals != ''",
56
56
  (cutoff,),
@@ -157,7 +157,7 @@ def list_user_state_snapshots(limit: int = 20) -> list[dict]:
157
157
 
158
158
  def user_state_stats(days: int = 30) -> dict:
159
159
  init_tables()
160
- cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat(timespec="seconds")
160
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(timespec="seconds")
161
161
  rows = get_db().execute(
162
162
  "SELECT state_label, COUNT(*) AS cnt FROM user_state_snapshots WHERE created_at >= ? GROUP BY state_label",
163
163
  (cutoff,),