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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -10
- package/bin/nexo-brain.js +34 -9
- package/bin/nexo.js +29 -2
- package/package.json +1 -1
- package/src/auto_update.py +80 -15
- package/src/cli.py +110 -0
- package/src/crons/manifest.json +24 -0
- package/src/crons/sync.py +27 -11
- package/src/dashboard/app.py +48 -3
- package/src/dashboard/templates/cortex.html +86 -27
- package/src/db/__init__.py +33 -0
- package/src/db/_drive.py +318 -0
- package/src/db/_goal_profiles.py +376 -0
- package/src/db/_outcomes.py +800 -0
- package/src/db/_protocol.py +239 -2
- package/src/db/_reminders.py +148 -2
- package/src/db/_schema.py +141 -0
- package/src/db/_skills.py +264 -8
- package/src/doctor/providers/runtime.py +48 -1
- package/src/maintenance.py +3 -0
- package/src/plugins/cortex.py +702 -0
- package/src/plugins/episodic_memory.py +15 -2
- package/src/plugins/goal_engine.py +142 -0
- package/src/plugins/impact.py +29 -0
- package/src/plugins/outcomes.py +130 -0
- package/src/plugins/protocol.py +299 -0
- package/src/plugins/skills.py +19 -1
- package/src/plugins/update.py +39 -3
- package/src/scripts/deep-sleep/apply_findings.py +119 -3
- package/src/scripts/deep-sleep/synthesize-prompt.md +34 -0
- package/src/scripts/nexo-impact-scorer.py +117 -0
- package/src/scripts/nexo-outcome-checker.py +97 -0
- package/src/scripts/nexo-synthesis.py +81 -3
- package/src/server.py +62 -0
- package/src/skills_runtime.py +203 -0
- package/src/tools_drive.py +484 -0
- package/src/tools_reminders.py +3 -4
- package/src/tools_reminders_crud.py +15 -0
- 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">
|
|
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">
|
|
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">
|
|
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">
|
|
47
|
-
<p class="text-2xl font-display font-bold text-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
241
|
-
document.getElementById('stat-
|
|
242
|
-
|
|
243
|
-
|
|
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();
|
package/src/db/__init__.py
CHANGED
|
@@ -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()
|
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}
|