nexo-brain 4.0.1 → 5.0.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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -10
  3. package/bin/nexo-brain.js +34 -9
  4. package/bin/nexo.js +29 -2
  5. package/package.json +1 -1
  6. package/src/auto_update.py +80 -15
  7. package/src/cli.py +110 -0
  8. package/src/crons/manifest.json +24 -0
  9. package/src/crons/sync.py +27 -11
  10. package/src/dashboard/app.py +48 -3
  11. package/src/dashboard/templates/cortex.html +86 -27
  12. package/src/db/__init__.py +33 -0
  13. package/src/db/_drive.py +318 -0
  14. package/src/db/_goal_profiles.py +376 -0
  15. package/src/db/_outcomes.py +800 -0
  16. package/src/db/_protocol.py +239 -2
  17. package/src/db/_reminders.py +148 -2
  18. package/src/db/_schema.py +141 -0
  19. package/src/db/_skills.py +264 -8
  20. package/src/doctor/providers/runtime.py +48 -1
  21. package/src/maintenance.py +3 -0
  22. package/src/plugins/cortex.py +702 -0
  23. package/src/plugins/episodic_memory.py +15 -2
  24. package/src/plugins/goal_engine.py +142 -0
  25. package/src/plugins/impact.py +29 -0
  26. package/src/plugins/outcomes.py +130 -0
  27. package/src/plugins/protocol.py +299 -0
  28. package/src/plugins/skills.py +19 -1
  29. package/src/plugins/update.py +39 -3
  30. package/src/scripts/deep-sleep/apply_findings.py +119 -3
  31. package/src/scripts/deep-sleep/synthesize-prompt.md +34 -0
  32. package/src/scripts/nexo-impact-scorer.py +117 -0
  33. package/src/scripts/nexo-outcome-checker.py +97 -0
  34. package/src/scripts/nexo-synthesis.py +81 -3
  35. package/src/server.py +62 -0
  36. package/src/skills_runtime.py +203 -0
  37. package/src/tools_drive.py +484 -0
  38. package/src/tools_reminders.py +3 -4
  39. package/src/tools_reminders_crud.py +15 -0
  40. package/src/tools_sessions.py +17 -0
@@ -31,20 +31,20 @@
31
31
  <!-- Stats row -->
32
32
  <div class="grid grid-cols-4 gap-4 mb-6">
33
33
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
34
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Total Decisions</p>
34
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Alt Evaluations</p>
35
35
  <p class="text-2xl font-display font-bold text-slate-100" id="stat-decisions">--</p>
36
36
  </div>
37
37
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
38
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Avg Confidence</p>
38
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Accept Rate</p>
39
39
  <p class="text-2xl font-display font-bold text-nexo-400" id="stat-confidence">--</p>
40
40
  </div>
41
41
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
42
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Cortex Logs</p>
42
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Linked Outcomes</p>
43
43
  <p class="text-2xl font-display font-bold text-slate-100" id="stat-logs">--</p>
44
44
  </div>
45
45
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
46
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Success Rate</p>
47
- <p class="text-2xl font-display font-bold text-emerald-400" id="stat-success">--</p>
46
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Override Rate</p>
47
+ <p class="text-2xl font-display font-bold text-amber-400" id="stat-success">--</p>
48
48
  </div>
49
49
  </div>
50
50
 
@@ -77,7 +77,7 @@
77
77
  <span class="text-[10px] text-slate-600 font-mono" id="decisions-count">--</span>
78
78
  </div>
79
79
  <div id="decisions-list" class="space-y-3 max-h-[600px] overflow-y-auto pr-1">
80
- <div class="py-12 text-center text-sm text-slate-500">Loading decisions...</div>
80
+ <div class="py-12 text-center text-sm text-slate-500">Loading decision support...</div>
81
81
  </div>
82
82
  </div>
83
83
  </div>
@@ -94,6 +94,7 @@
94
94
  <script>
95
95
  let allLogs = [];
96
96
  let allDecisions = [];
97
+ let allEvaluations = [];
97
98
 
98
99
  const modeColors = {
99
100
  conservative: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30' },
@@ -156,16 +157,69 @@
156
157
  }).join('');
157
158
  }
158
159
 
159
- function renderDecisions(decisions) {
160
+ function renderDecisions(evaluations, decisions) {
160
161
  const container = document.getElementById('decisions-list');
161
- document.getElementById('decisions-count').textContent = `${decisions.length} decisions`;
162
+ document.getElementById('decisions-count').textContent = `${evaluations.length} evaluations / ${decisions.length} decisions`;
162
163
 
163
- if (!decisions.length) {
164
- container.innerHTML = '<div class="py-12 text-center text-sm text-slate-500">No decisions recorded</div>';
164
+ if (!evaluations.length && !decisions.length) {
165
+ container.innerHTML = '<div class="py-12 text-center text-sm text-slate-500">No decision support recorded</div>';
165
166
  return;
166
167
  }
167
168
 
168
- container.innerHTML = decisions.slice(0, 50).map((d, i) => `
169
+ const evaluationCards = evaluations.slice(0, 25).map((item, i) => {
170
+ const scores = Array.isArray(item.scores) ? item.scores : JSON.parse(item.scores || '[]');
171
+ const top = scores[0] || null;
172
+ const selectedChanged = item.selection_source === 'override';
173
+ return `
174
+ <div class="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors border border-transparent hover:border-slate-700/50 animate-fade-in" style="animation-delay: ${i * 35}ms">
175
+ <div class="flex items-center justify-between mb-2 gap-2">
176
+ <div class="flex items-center gap-2 flex-wrap">
177
+ ${item.impact_level ? `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold bg-red-500/15 text-red-300 uppercase tracking-wider">${escapeHtml(item.impact_level)}</span>` : ''}
178
+ ${item.area ? `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold bg-nexo-500/15 text-nexo-400 uppercase tracking-wider">${escapeHtml(item.area)}</span>` : ''}
179
+ </div>
180
+ <span class="text-[10px] text-slate-600">${relativeTime(item.created_at)}</span>
181
+ </div>
182
+ <p class="text-sm text-slate-200 mb-2">${escapeHtml(item.goal || '--')}</p>
183
+ <div class="grid grid-cols-2 gap-2 mb-2">
184
+ <div class="bg-slate-900/40 rounded-md p-2">
185
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Recommended</p>
186
+ <p class="text-xs text-emerald-400 font-semibold">${escapeHtml(item.recommended_choice || '--')}</p>
187
+ </div>
188
+ <div class="bg-slate-900/40 rounded-md p-2">
189
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Selected</p>
190
+ <p class="text-xs ${selectedChanged ? 'text-amber-400' : 'text-slate-200'} font-semibold">${escapeHtml(item.selected_choice || '--')}</p>
191
+ </div>
192
+ </div>
193
+ ${item.recommended_reasoning ? `<p class="text-xs text-slate-400 mb-2">${escapeHtml(item.recommended_reasoning)}</p>` : ''}
194
+ ${item.goal_profile_id ? `
195
+ <div class="mt-2 bg-slate-900/40 rounded px-2 py-1.5">
196
+ <p class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold mb-0.5">Goal Profile</p>
197
+ <p class="text-xs text-nexo-300 font-semibold">${escapeHtml(item.goal_profile_id)}</p>
198
+ ${Array.isArray(item.goal_profile_labels) && item.goal_profile_labels.length ? `<p class="text-[10px] text-slate-400 mt-1">${escapeHtml(item.goal_profile_labels.join(' · '))}</p>` : ''}
199
+ </div>
200
+ ` : ''}
201
+ ${item.linked_outcome_id ? `
202
+ <div class="mt-2 text-[10px] text-slate-500">
203
+ outcome #${escapeHtml(String(item.linked_outcome_id))}
204
+ ${item.linked_outcome?.status ? `· <span class="text-slate-300">${escapeHtml(String(item.linked_outcome.status))}</span>` : ''}
205
+ </div>
206
+ ` : ''}
207
+ ${selectedChanged && item.selection_reason ? `
208
+ <div class="mt-2 bg-amber-500/10 rounded px-2 py-1.5">
209
+ <p class="text-[10px] text-amber-400 font-semibold uppercase tracking-wider mb-0.5">Override</p>
210
+ <p class="text-xs text-amber-200/90">${escapeHtml(item.selection_reason)}</p>
211
+ </div>
212
+ ` : ''}
213
+ ${top ? `
214
+ <div class="mt-2 text-[10px] text-slate-500 font-mono">
215
+ top=${Number(top.total_score || 0).toFixed(2)} impact=${Number(top.impact || 0).toFixed(1)} success=${Number(top.success_probability || 0).toFixed(1)} risk=${Number(top.risk_level || 0).toFixed(1)}
216
+ </div>
217
+ ` : ''}
218
+ </div>
219
+ `;
220
+ }).join('');
221
+
222
+ const decisionCards = decisions.slice(0, 20).map((d, i) => `
169
223
  <div class="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors border border-transparent hover:border-slate-700/50 animate-fade-in" style="animation-delay: ${i * 40}ms">
170
224
  <div class="flex items-center justify-between mb-2">
171
225
  <div class="flex items-center gap-2">
@@ -190,6 +244,15 @@
190
244
  </div>
191
245
  </div>
192
246
  `).join('');
247
+
248
+ const decisionSection = decisionCards ? `
249
+ <div class="pt-3 mt-3 border-t border-slate-800/80">
250
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 font-semibold mb-3">Decision Log</p>
251
+ <div class="space-y-3">${decisionCards}</div>
252
+ </div>
253
+ ` : '';
254
+
255
+ container.innerHTML = evaluationCards + decisionSection;
193
256
  }
194
257
 
195
258
  function renderConfidenceChart(decisions) {
@@ -217,9 +280,10 @@
217
280
 
218
281
  const filteredLogs = modeFilter ? allLogs.filter(l => l.mode === modeFilter) : allLogs;
219
282
  const filteredDecisions = domainFilter ? allDecisions.filter(d => d.domain === domainFilter) : allDecisions;
283
+ const filteredEvaluations = domainFilter ? allEvaluations.filter(e => e.area === domainFilter) : allEvaluations;
220
284
 
221
285
  renderLogs(filteredLogs);
222
- renderDecisions(filteredDecisions);
286
+ renderDecisions(filteredEvaluations, filteredDecisions);
223
287
  }
224
288
 
225
289
  async function loadData() {
@@ -228,29 +292,24 @@
228
292
 
229
293
  allLogs = data.cortex_logs || [];
230
294
  allDecisions = data.decisions || [];
295
+ allEvaluations = data.evaluations || [];
296
+ const summary = data.summary || {};
231
297
 
232
298
  // Populate domain filter
233
- const domains = [...new Set(allDecisions.map(d => d.domain).filter(Boolean))];
299
+ const domains = [...new Set([
300
+ ...allDecisions.map(d => d.domain).filter(Boolean),
301
+ ...allEvaluations.map(e => e.area).filter(Boolean),
302
+ ])];
234
303
  const domainSelect = document.getElementById('filter-domain');
235
304
  const currentDomain = domainSelect.value;
236
305
  domainSelect.innerHTML = '<option value="">All Domains</option>' +
237
306
  domains.map(d => `<option value="${escapeHtml(d)}" ${d === currentDomain ? 'selected' : ''}>${escapeHtml(d)}</option>`).join('');
238
307
 
239
308
  // Stats
240
- document.getElementById('stat-decisions').textContent = formatNumber(allDecisions.length);
241
- document.getElementById('stat-logs').textContent = formatNumber(allLogs.length);
242
-
243
- const confidences = allDecisions.filter(d => d.confidence != null).map(d => {
244
- const v = typeof d.confidence === 'number' ? (d.confidence > 1 ? d.confidence : d.confidence * 100) : parseFloat(d.confidence);
245
- return v;
246
- });
247
- const avgConf = confidences.length ? (confidences.reduce((a, b) => a + b, 0) / confidences.length) : 0;
248
- document.getElementById('stat-confidence').textContent = avgConf.toFixed(0) + '%';
249
-
250
- const outcomes = allDecisions.filter(d => d.outcome);
251
- const successes = outcomes.filter(d => d.outcome === 'success' || d.outcome === 'positive' || d.outcome === 'good');
252
- const successRate = outcomes.length ? ((successes.length / outcomes.length) * 100) : 0;
253
- document.getElementById('stat-success').textContent = successRate.toFixed(0) + '%';
309
+ document.getElementById('stat-decisions').textContent = formatNumber(allEvaluations.length);
310
+ document.getElementById('stat-confidence').textContent = `${Number(summary.recommendation_accept_rate || 0).toFixed(0)}%`;
311
+ document.getElementById('stat-logs').textContent = formatNumber(summary.linked_outcomes_total || 0);
312
+ document.getElementById('stat-success').textContent = `${Number(summary.override_rate || 0).toFixed(0)}%`;
254
313
 
255
314
  renderConfidenceChart(allDecisions);
256
315
  applyFilters();
@@ -51,6 +51,9 @@ _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")
55
+ _outcomes = _load_submodule("db._outcomes")
56
+ _goal_profiles = _load_submodule("db._goal_profiles")
54
57
 
55
58
  # Core: connection, constants, init, utils
56
59
  from db._core import (
@@ -87,6 +90,7 @@ from db._reminders import (
87
90
  create_followup, update_followup, complete_followup, delete_followup,
88
91
  restore_followup, add_followup_note, get_followups, get_followup, get_followup_history,
89
92
  find_similar_followups,
93
+ compute_followup_impact, score_followup, score_active_followups,
90
94
  add_item_history, get_item_history, validate_item_read_token,
91
95
  )
92
96
 
@@ -146,6 +150,10 @@ from db._protocol import (
146
150
  create_protocol_task, get_protocol_task, close_protocol_task,
147
151
  create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
148
152
  protocol_compliance_summary,
153
+ create_cortex_evaluation, get_cortex_evaluation, list_cortex_evaluations,
154
+ cortex_evaluation_summary,
155
+ latest_cortex_evaluation_for_task, task_has_cortex_evaluation,
156
+ override_cortex_evaluation,
149
157
  )
150
158
 
151
159
  # Durable workflow runtime
@@ -184,9 +192,17 @@ from db._skills import (
184
192
  validate_skill_params, render_command_template, sync_skill_directories,
185
193
  import_skill_from_directory, approve_skill, collect_scriptable_skill_candidates,
186
194
  collect_skill_improvement_candidates, materialize_personal_skill_definition,
195
+ get_skill_outcome_evidence, list_skill_outcome_reviews,
187
196
  get_skill_health_report,
188
197
  )
189
198
 
199
+ # Drive / Curiosity signals
200
+ from db._drive import (
201
+ create_drive_signal, reinforce_drive_signal, get_drive_signals,
202
+ get_drive_signal, update_drive_signal_status, decay_drive_signals,
203
+ find_similar_drive_signal, drive_signal_stats,
204
+ )
205
+
190
206
  # Hot context / recent continuity
191
207
  from db._hot_context import (
192
208
  DEFAULT_CONTEXT_TTL_HOURS,
@@ -198,6 +214,23 @@ from db._hot_context import (
198
214
  resolve_hot_context,
199
215
  )
200
216
 
217
+ # Outcomes
218
+ from db._outcomes import (
219
+ VALID_METRIC_SOURCES as OUTCOME_METRIC_SOURCES,
220
+ VALID_TARGET_OPERATORS as OUTCOME_TARGET_OPERATORS,
221
+ create_outcome, get_outcome, list_outcomes,
222
+ cancel_outcome, evaluate_outcome, pending_outcomes_due,
223
+ find_pending_outcomes_by_action, set_linked_outcomes_met,
224
+ list_outcome_pattern_candidates, capture_outcome_pattern,
225
+ )
226
+
227
+ # Goal Engine v1
228
+ from db._goal_profiles import (
229
+ DEFAULT_GOAL_PROFILES,
230
+ ensure_default_goal_profiles, get_goal_profile, list_goal_profiles,
231
+ upsert_goal_profile, resolve_goal_profile,
232
+ )
233
+
201
234
 
202
235
  def get_db():
203
236
  return _module("db._core").get_db()
@@ -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}