nexo-brain 3.1.4 → 3.1.6
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/dashboard/app.py +24 -24
- package/src/dashboard/templates/dashboard.html +9 -7
- package/src/dashboard/templates/operations.html +6 -4
- package/src/scripts/deep-sleep/apply_findings.py +35 -11
- package/src/scripts/nexo-proactive-dashboard.py +19 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.6",
|
|
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": "3.1.
|
|
3
|
+
"version": "3.1.6",
|
|
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/dashboard/app.py
CHANGED
|
@@ -161,6 +161,26 @@ def _deep_sleep_dir() -> Path:
|
|
|
161
161
|
return nexo_home / "operations" / "deep-sleep"
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
def _normalize_item_status(status: object) -> str:
|
|
165
|
+
return str(status or "").strip().upper()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dashboard_status_matches(status: object, requested: str | None) -> bool:
|
|
169
|
+
normalized = _normalize_item_status(status)
|
|
170
|
+
requested_key = str(requested or "").strip().lower()
|
|
171
|
+
if not requested_key:
|
|
172
|
+
return normalized != "DELETED"
|
|
173
|
+
if requested_key in {"any", "history"}:
|
|
174
|
+
return True
|
|
175
|
+
if requested_key == "all":
|
|
176
|
+
return normalized != "DELETED"
|
|
177
|
+
if requested_key == "completed":
|
|
178
|
+
return normalized.startswith("COMPLETED")
|
|
179
|
+
if requested_key == "deleted":
|
|
180
|
+
return normalized == "DELETED"
|
|
181
|
+
return normalized == requested_key.upper()
|
|
182
|
+
|
|
183
|
+
|
|
164
184
|
def _latest_periodic_summary(kind: str) -> dict:
|
|
165
185
|
root = _deep_sleep_dir()
|
|
166
186
|
pattern = f"*-{kind}-summary.json"
|
|
@@ -745,18 +765,8 @@ async def api_reminders_list(
|
|
|
745
765
|
):
|
|
746
766
|
"""List reminders."""
|
|
747
767
|
db = _db()
|
|
748
|
-
reminders = db.get_reminders("
|
|
749
|
-
if status
|
|
750
|
-
if status in {"any", "all", "history"}:
|
|
751
|
-
pass
|
|
752
|
-
elif status == "completed":
|
|
753
|
-
reminders = [r for r in reminders if str(r.get("status") or "").startswith("COMPLETED")]
|
|
754
|
-
elif status == "deleted":
|
|
755
|
-
reminders = [r for r in reminders if r.get("status") == "DELETED"]
|
|
756
|
-
else:
|
|
757
|
-
reminders = [r for r in reminders if r.get("status") == status]
|
|
758
|
-
else:
|
|
759
|
-
reminders = [r for r in reminders if r.get("status") != "DELETED"]
|
|
768
|
+
reminders = db.get_reminders("history")
|
|
769
|
+
reminders = [r for r in reminders if _dashboard_status_matches(r.get("status"), status)]
|
|
760
770
|
if category:
|
|
761
771
|
reminders = [r for r in reminders if r.get("category") == category]
|
|
762
772
|
reminders = sorted(reminders, key=lambda item: item.get("updated_at") or item.get("created_at") or 0, reverse=True)
|
|
@@ -851,18 +861,8 @@ async def api_followups_list(
|
|
|
851
861
|
):
|
|
852
862
|
"""List followups."""
|
|
853
863
|
db = _db()
|
|
854
|
-
followups = db.get_followups("
|
|
855
|
-
if status
|
|
856
|
-
if status in {"any", "all", "history"}:
|
|
857
|
-
pass
|
|
858
|
-
elif status == "completed":
|
|
859
|
-
followups = [r for r in followups if str(r.get("status") or "").startswith("COMPLETED")]
|
|
860
|
-
elif status == "deleted":
|
|
861
|
-
followups = [r for r in followups if r.get("status") == "DELETED"]
|
|
862
|
-
else:
|
|
863
|
-
followups = [r for r in followups if r.get("status") == status]
|
|
864
|
-
else:
|
|
865
|
-
followups = [r for r in followups if r.get("status") != "DELETED"]
|
|
864
|
+
followups = db.get_followups("history")
|
|
865
|
+
followups = [r for r in followups if _dashboard_status_matches(r.get("status"), status)]
|
|
866
866
|
followups = sorted(followups, key=lambda item: item.get("updated_at") or item.get("created_at") or 0, reverse=True)
|
|
867
867
|
return {"count": len(followups), "followups": followups}
|
|
868
868
|
|
|
@@ -211,6 +211,11 @@
|
|
|
211
211
|
<script>
|
|
212
212
|
function getToday() { return new Date().toISOString().split('T')[0]; }
|
|
213
213
|
|
|
214
|
+
function isInactiveItemStatus(status) {
|
|
215
|
+
const normalized = String(status || '').trim().toUpperCase();
|
|
216
|
+
return normalized.startsWith('COMPLETED') || ['ARCHIVED', 'DELETED', 'BLOCKED', 'WAITING', 'CANCELLED'].includes(normalized);
|
|
217
|
+
}
|
|
218
|
+
|
|
214
219
|
// -----------------------------------------------------------------------
|
|
215
220
|
// Modal
|
|
216
221
|
// -----------------------------------------------------------------------
|
|
@@ -324,11 +329,10 @@ async function loadDashboardData() {
|
|
|
324
329
|
|
|
325
330
|
// --- Overdue Items ---
|
|
326
331
|
if (remindersData || followupsData) {
|
|
327
|
-
const excludeStatus = ['completed', 'COMPLETED', 'archived', 'deleted', 'DELETED', 'blocked', 'waiting'];
|
|
328
332
|
const reminders = (remindersData?.reminders || []).filter(r =>
|
|
329
|
-
!
|
|
333
|
+
!isInactiveItemStatus(r.status) && r.date && r.date <= today);
|
|
330
334
|
const followups = (followupsData?.followups || []).filter(f =>
|
|
331
|
-
!
|
|
335
|
+
!isInactiveItemStatus(f.status) && f.date && f.date <= today);
|
|
332
336
|
const total = reminders.length + followups.length;
|
|
333
337
|
const el = document.getElementById('overdue-count');
|
|
334
338
|
el.textContent = total;
|
|
@@ -375,13 +379,11 @@ async function loadDashboardData() {
|
|
|
375
379
|
const agendaList = document.getElementById('agenda-list');
|
|
376
380
|
const agendaItems = [];
|
|
377
381
|
if (remindersData?.reminders) {
|
|
378
|
-
|
|
379
|
-
remindersData.reminders.filter(r => !excludeStatus.includes(r.status) && r.date && r.date <= today)
|
|
382
|
+
remindersData.reminders.filter(r => !isInactiveItemStatus(r.status) && r.date && r.date <= today)
|
|
380
383
|
.forEach(r => agendaItems.push({ text: r.description, type: 'reminder', date: r.date }));
|
|
381
384
|
}
|
|
382
385
|
if (followupsData?.followups) {
|
|
383
|
-
|
|
384
|
-
followupsData.followups.filter(f => !excludeStatus.includes(f.status) && f.date && f.date <= today)
|
|
386
|
+
followupsData.followups.filter(f => !isInactiveItemStatus(f.status) && f.date && f.date <= today)
|
|
385
387
|
.forEach(f => agendaItems.push({ text: f.description, type: 'followup', date: f.date }));
|
|
386
388
|
}
|
|
387
389
|
if (agendaItems.length > 0) {
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
>
|
|
28
28
|
<option value="PENDING">Pending</option>
|
|
29
29
|
<option value="all">All</option>
|
|
30
|
-
<option value="
|
|
30
|
+
<option value="completed">Completed</option>
|
|
31
|
+
<option value="deleted">Deleted</option>
|
|
32
|
+
<option value="history">History</option>
|
|
31
33
|
</select>
|
|
32
34
|
{% endblock %}
|
|
33
35
|
|
|
@@ -328,7 +330,7 @@ function renderGroupedItems(items, type, containerId) {
|
|
|
328
330
|
// -----------------------------------------------------------------------
|
|
329
331
|
async function loadOpsData() {
|
|
330
332
|
const status = document.getElementById('ops-status').value;
|
|
331
|
-
const statusParam = status
|
|
333
|
+
const statusParam = status ? '?status=' + encodeURIComponent(status) : '';
|
|
332
334
|
|
|
333
335
|
const [remData, fupData] = await Promise.all([
|
|
334
336
|
fetchJSON('/api/reminders' + statusParam),
|
|
@@ -429,7 +431,7 @@ function deleteItem(id, type) {
|
|
|
429
431
|
const res = await fetch(url, { method: 'DELETE' });
|
|
430
432
|
const data = await res.json();
|
|
431
433
|
if (data.success) {
|
|
432
|
-
opsToast('
|
|
434
|
+
opsToast('Marked ' + id + ' as deleted');
|
|
433
435
|
loadOpsData();
|
|
434
436
|
} else {
|
|
435
437
|
opsToast(data.error || data.detail || 'Delete failed (HTTP ' + res.status + ')', 'error');
|
|
@@ -438,7 +440,7 @@ function deleteItem(id, type) {
|
|
|
438
440
|
opsToast('Delete error: ' + err.message, 'error');
|
|
439
441
|
}
|
|
440
442
|
};
|
|
441
|
-
document.getElementById('confirm-message').textContent = 'Delete ' + id + '? This
|
|
443
|
+
document.getElementById('confirm-message').textContent = 'Delete ' + id + '? This marks it as DELETED and keeps its history.';
|
|
442
444
|
document.getElementById('confirm-modal').classList.remove('hidden');
|
|
443
445
|
}
|
|
444
446
|
|
|
@@ -284,7 +284,14 @@ def _find_similar_followup(description: str, threshold: float = 0.58) -> dict |
|
|
|
284
284
|
return candidates[0]
|
|
285
285
|
|
|
286
286
|
|
|
287
|
-
def _touch_existing_followup(
|
|
287
|
+
def _touch_existing_followup(
|
|
288
|
+
existing: dict,
|
|
289
|
+
*,
|
|
290
|
+
description: str,
|
|
291
|
+
date: str = "",
|
|
292
|
+
reasoning_note: str = "",
|
|
293
|
+
status: str = "",
|
|
294
|
+
) -> dict:
|
|
288
295
|
cols = _table_columns(NEXO_DB, "followups")
|
|
289
296
|
if not cols:
|
|
290
297
|
return {"success": False, "error": "followups table not found"}
|
|
@@ -296,6 +303,9 @@ def _touch_existing_followup(existing: dict, *, description: str, date: str = ""
|
|
|
296
303
|
preferred_date = _prefer_due_date(existing.get("date", ""), date)
|
|
297
304
|
if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
|
|
298
305
|
updates["date"] = preferred_date
|
|
306
|
+
desired_status = (status or "").strip()
|
|
307
|
+
if desired_status and "status" in cols and desired_status != str(existing.get("status", "") or ""):
|
|
308
|
+
updates["status"] = desired_status
|
|
299
309
|
note = reasoning_note or "Deep Sleep matched this followup semantically."
|
|
300
310
|
changed = False
|
|
301
311
|
if updates:
|
|
@@ -503,19 +513,23 @@ def add_learning(category: str, title: str, content: str) -> dict:
|
|
|
503
513
|
return {"success": False, "error": str(e)}
|
|
504
514
|
|
|
505
515
|
|
|
506
|
-
def create_followup(description: str, date: str = "", reasoning_note: str = "") -> dict:
|
|
516
|
+
def create_followup(description: str, date: str = "", reasoning_note: str = "", status: str = "PENDING") -> dict:
|
|
507
517
|
"""Create a followup in nexo.db. Returns result dict."""
|
|
508
518
|
if not NEXO_DB.exists():
|
|
509
519
|
return {"success": False, "error": "nexo.db not found"}
|
|
510
520
|
try:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
521
|
+
desired_status = (status or "PENDING").strip() or "PENDING"
|
|
522
|
+
is_abandoned = description.strip().startswith("[Abandoned]")
|
|
523
|
+
if not is_abandoned:
|
|
524
|
+
matched = _find_similar_followup(description)
|
|
525
|
+
if matched:
|
|
526
|
+
return _touch_existing_followup(
|
|
527
|
+
matched,
|
|
528
|
+
description=description,
|
|
529
|
+
date=date,
|
|
530
|
+
reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
|
|
531
|
+
status=desired_status,
|
|
532
|
+
)
|
|
519
533
|
|
|
520
534
|
# Generate a deterministic ID
|
|
521
535
|
fid = "NF-DS-" + hashlib.md5(description.encode()).hexdigest()[:8].upper()
|
|
@@ -526,6 +540,7 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "")
|
|
|
526
540
|
description=description,
|
|
527
541
|
date=date,
|
|
528
542
|
reasoning_note=reasoning_note or "Deep Sleep revisited this deterministic followup.",
|
|
543
|
+
status=desired_status,
|
|
529
544
|
)
|
|
530
545
|
|
|
531
546
|
followup_result = nexo_db.create_followup(
|
|
@@ -533,11 +548,18 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "")
|
|
|
533
548
|
description=description,
|
|
534
549
|
date=date or None,
|
|
535
550
|
verification="",
|
|
551
|
+
status=desired_status,
|
|
536
552
|
reasoning=reasoning_note or "Deep Sleep v2 overnight analysis",
|
|
537
553
|
recurrence=None,
|
|
538
554
|
)
|
|
539
555
|
if followup_result.get("error"):
|
|
540
556
|
return {"success": False, "error": followup_result["error"]}
|
|
557
|
+
if desired_status != "PENDING":
|
|
558
|
+
nexo_db.add_followup_note(
|
|
559
|
+
fid,
|
|
560
|
+
f"Deep Sleep created this followup directly as {desired_status}.",
|
|
561
|
+
actor="deep-sleep",
|
|
562
|
+
)
|
|
541
563
|
return {"success": True, "id": fid, "outcome": "new_followup"}
|
|
542
564
|
except Exception as e:
|
|
543
565
|
return {"success": False, "error": str(e)}
|
|
@@ -733,7 +755,9 @@ def create_abandoned_followups(synthesis: dict) -> list[dict]:
|
|
|
733
755
|
continue
|
|
734
756
|
result = create_followup(
|
|
735
757
|
description=f"[Abandoned] {proj.get('description', '')}",
|
|
736
|
-
date="" # No date — it's a discovered gap
|
|
758
|
+
date="", # No date — it's a discovered gap
|
|
759
|
+
reasoning_note="Deep Sleep marked this as abandoned. Keep it as archived history, not active work.",
|
|
760
|
+
status="archived",
|
|
737
761
|
)
|
|
738
762
|
results.append(result)
|
|
739
763
|
return results
|
|
@@ -22,6 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
23
23
|
|
|
24
24
|
NEXO_DB = NEXO_HOME / "data" / "nexo.db"
|
|
25
|
+
INACTIVE_STATUSES = {"DELETED", "ARCHIVED", "BLOCKED", "WAITING", "CANCELLED"}
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def get_db():
|
|
@@ -30,21 +31,27 @@ def get_db():
|
|
|
30
31
|
return conn
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _is_open_status(status: object) -> bool:
|
|
35
|
+
normalized = str(status or "").strip().upper()
|
|
36
|
+
if normalized.startswith("COMPLETED"):
|
|
37
|
+
return False
|
|
38
|
+
return normalized not in INACTIVE_STATUSES
|
|
39
|
+
|
|
40
|
+
|
|
33
41
|
def check_overdue_followups() -> list[dict]:
|
|
34
42
|
"""Find followups that are overdue and not completed."""
|
|
35
43
|
conn = get_db()
|
|
36
|
-
now_epoch = datetime.now().timestamp()
|
|
37
44
|
rows = conn.execute("""
|
|
38
|
-
SELECT id, description, date, created_at, reasoning
|
|
45
|
+
SELECT id, description, date, created_at, reasoning, status
|
|
39
46
|
FROM followups
|
|
40
|
-
WHERE
|
|
41
|
-
AND status NOT IN ('DELETED','archived','blocked','waiting')
|
|
42
|
-
AND date IS NOT NULL AND date != ''
|
|
47
|
+
WHERE date IS NOT NULL AND date != ''
|
|
43
48
|
ORDER BY date ASC
|
|
44
49
|
""").fetchall()
|
|
45
50
|
conn.close()
|
|
46
51
|
alerts = []
|
|
47
52
|
for r in rows:
|
|
53
|
+
if not _is_open_status(r["status"]):
|
|
54
|
+
continue
|
|
48
55
|
due_str = r["date"]
|
|
49
56
|
try:
|
|
50
57
|
due = datetime.fromisoformat(due_str) if due_str else None
|
|
@@ -68,13 +75,14 @@ def check_overdue_reminders() -> list[dict]:
|
|
|
68
75
|
rows = conn.execute("""
|
|
69
76
|
SELECT id, description, date, status
|
|
70
77
|
FROM reminders
|
|
71
|
-
WHERE
|
|
72
|
-
AND date IS NOT NULL AND date != ''
|
|
78
|
+
WHERE date IS NOT NULL AND date != ''
|
|
73
79
|
ORDER BY date ASC
|
|
74
80
|
""").fetchall()
|
|
75
81
|
conn.close()
|
|
76
82
|
alerts = []
|
|
77
83
|
for r in rows:
|
|
84
|
+
if not _is_open_status(r["status"]):
|
|
85
|
+
continue
|
|
78
86
|
due_str = r["date"]
|
|
79
87
|
try:
|
|
80
88
|
due = datetime.fromisoformat(due_str) if due_str else None
|
|
@@ -96,16 +104,17 @@ def check_stale_ideas() -> list[dict]:
|
|
|
96
104
|
"""Find reminders/ideas without due dates that have been sitting for too long."""
|
|
97
105
|
conn = get_db()
|
|
98
106
|
rows = conn.execute("""
|
|
99
|
-
SELECT id, description, created_at
|
|
107
|
+
SELECT id, description, created_at, status
|
|
100
108
|
FROM reminders
|
|
101
|
-
WHERE
|
|
102
|
-
AND (date IS NULL OR date = '')
|
|
109
|
+
WHERE date IS NULL OR date = ''
|
|
103
110
|
ORDER BY created_at ASC
|
|
104
111
|
""").fetchall()
|
|
105
112
|
conn.close()
|
|
106
113
|
alerts = []
|
|
107
114
|
stale_count = 0
|
|
108
115
|
for r in rows:
|
|
116
|
+
if not _is_open_status(r["status"]):
|
|
117
|
+
continue
|
|
109
118
|
try:
|
|
110
119
|
# created_at is epoch float
|
|
111
120
|
created = datetime.fromtimestamp(r["created_at"])
|