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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "3.1.4",
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.4",
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",
@@ -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("any")
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("any")
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
- !excludeStatus.includes(r.status) && r.date && r.date <= today);
333
+ !isInactiveItemStatus(r.status) && r.date && r.date <= today);
330
334
  const followups = (followupsData?.followups || []).filter(f =>
331
- !excludeStatus.includes(f.status) && f.date && f.date <= today);
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
- const excludeStatus = ['completed', 'COMPLETED', 'archived', 'deleted', 'DELETED'];
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
- const excludeStatus = ['completed', 'COMPLETED', 'archived', 'deleted', 'DELETED'];
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="COMPLETED">Completed</option>
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 !== 'all' ? '?status=' + 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('Deleted ' + id);
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 cannot be undone.';
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(existing: dict, *, description: str, date: str = "", reasoning_note: str = "") -> dict:
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
- matched = _find_similar_followup(description)
512
- if matched:
513
- return _touch_existing_followup(
514
- matched,
515
- description=description,
516
- date=date,
517
- reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
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 status NOT LIKE 'COMPLETED%'
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 status NOT IN ('COMPLETED', 'CANCELLED')
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 status NOT IN ('COMPLETED', 'CANCELLED')
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"])