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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/db/__init__.py +8 -0
- package/src/db/_drive.py +318 -0
- package/src/db/_schema.py +32 -0
- package/src/maintenance.py +3 -0
- package/src/plugins/protocol.py +16 -0
- package/src/scripts/deep-sleep/apply_findings.py +23 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +34 -0
- package/src/server.py +62 -0
- package/src/tools_drive.py +187 -0
- package/src/tools_sessions.py +17 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "4.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "4.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",
|
package/src/db/__init__.py
CHANGED
|
@@ -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,
|
package/src/db/_drive.py
ADDED
|
@@ -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
|
|
package/src/maintenance.py
CHANGED
|
@@ -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
|
package/src/plugins/protocol.py
CHANGED
|
@@ -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}"
|
package/src/tools_sessions.py
CHANGED
|
@@ -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()
|