nexo-brain 3.1.3 → 3.1.5

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.3",
3
+ "version": "3.1.5",
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",
3
+ "version": "3.1.5",
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
 
@@ -14,6 +14,14 @@ ACTIVE_EXCLUDED_STATUSES = {"DELETED", "archived", "blocked", "waiting"}
14
14
  READ_TOKEN_TTL_SECONDS = 30 * 60
15
15
 
16
16
 
17
+ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
18
+ row = conn.execute(
19
+ "SELECT 1 FROM sqlite_master WHERE type IN ('table', 'view') AND name = ? LIMIT 1",
20
+ (table_name,),
21
+ ).fetchone()
22
+ return bool(row)
23
+
24
+
17
25
  def _serialize_metadata(metadata: dict[str, Any] | None) -> str:
18
26
  if not metadata:
19
27
  return "{}"
@@ -63,6 +71,8 @@ def _history_rules(item_type: str) -> list[str]:
63
71
 
64
72
 
65
73
  def _latest_history_seq(conn, item_type: str, item_id: str) -> int:
74
+ if not _table_exists(conn, "item_history"):
75
+ return 0
66
76
  row = conn.execute(
67
77
  "SELECT MAX(id) AS max_id FROM item_history WHERE item_type = ? AND item_id = ?",
68
78
  (item_type, item_id),
@@ -82,6 +92,17 @@ def add_item_history(
82
92
  ) -> dict:
83
93
  """Append an event to reminder/followup history."""
84
94
  conn = get_db()
95
+ if not _table_exists(conn, "item_history"):
96
+ return {
97
+ "item_type": item_type,
98
+ "item_id": item_id,
99
+ "event_type": event_type,
100
+ "note": note or "",
101
+ "actor": actor,
102
+ "metadata": _serialize_metadata(metadata),
103
+ "created_at": created_at if created_at is not None else now_epoch(),
104
+ "skipped": True,
105
+ }
85
106
  ts = created_at if created_at is not None else now_epoch()
86
107
  conn.execute(
87
108
  "INSERT INTO item_history (item_type, item_id, event_type, note, actor, metadata, created_at) "
@@ -99,6 +120,8 @@ def add_item_history(
99
120
  def get_item_history(item_type: str, item_id: str, limit: int = 20) -> list[dict]:
100
121
  """Return latest history events for a reminder/followup."""
101
122
  conn = get_db()
123
+ if not _table_exists(conn, "item_history"):
124
+ return []
102
125
  rows = conn.execute(
103
126
  "SELECT * FROM item_history WHERE item_type = ? AND item_id = ? ORDER BY id DESC LIMIT ?",
104
127
  (item_type, item_id, limit),
@@ -376,6 +399,7 @@ def create_followup(
376
399
  status: str = "PENDING",
377
400
  reasoning: str = "",
378
401
  recurrence: str = None,
402
+ priority: str = "medium",
379
403
  ) -> dict:
380
404
  """Create a new followup with optional reasoning and recurrence."""
381
405
  conn = get_db()
@@ -389,14 +413,32 @@ def create_followup(
389
413
  f"(scores: {', '.join(str(s['_similarity']) for s in similar[:3])}). Consider updating instead."
390
414
  )
391
415
 
416
+ columns = {str(row["name"]) for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
417
+ payload: dict[str, object] = {
418
+ "id": id,
419
+ "date": date,
420
+ "description": description,
421
+ "verification": verification,
422
+ "status": status,
423
+ "reasoning": reasoning,
424
+ "recurrence": recurrence,
425
+ "created_at": now,
426
+ "updated_at": now,
427
+ }
428
+ if "priority" in columns:
429
+ payload["priority"] = priority or "medium"
430
+
431
+ insert_columns = [column for column in payload if column in columns]
432
+ placeholders = ", ".join("?" for _ in insert_columns)
433
+
392
434
  try:
393
435
  conn.execute(
394
- "INSERT INTO followups (id, date, description, verification, status, reasoning, recurrence, created_at, updated_at) "
395
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
396
- (id, date, description, verification, status, reasoning, recurrence, now, now),
436
+ f"INSERT INTO followups ({', '.join(insert_columns)}) VALUES ({placeholders})",
437
+ [payload[column] for column in insert_columns],
397
438
  )
398
439
  conn.commit()
399
- fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
440
+ if _table_exists(conn, "unified_search"):
441
+ fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
400
442
  except sqlite3.IntegrityError:
401
443
  return {"error": f"Followup {id} already exists. Use update instead."}
402
444
 
@@ -441,7 +483,7 @@ def update_followup(
441
483
  conn.commit()
442
484
 
443
485
  new_row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
444
- if new_row:
486
+ if new_row and _table_exists(conn, "unified_search"):
445
487
  new_row_dict = dict(new_row)
446
488
  fts_upsert(
447
489
  "followup",
@@ -541,9 +583,10 @@ def complete_followup(id: str, result: str = "") -> dict:
541
583
  _reassign_item_identity(conn, "followup", id, archived_id)
542
584
  conn.commit()
543
585
 
544
- conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (id,))
586
+ if _table_exists(conn, "unified_search"):
587
+ conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (id,))
545
588
  archived_row = conn.execute("SELECT * FROM followups WHERE id = ?", (archived_id,)).fetchone()
546
- if archived_row:
589
+ if archived_row and _table_exists(conn, "unified_search"):
547
590
  fts_upsert(
548
591
  "followup",
549
592
  archived_id,
@@ -30,6 +30,8 @@ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent
30
30
  if str(NEXO_CODE) not in sys.path:
31
31
  sys.path.insert(0, str(NEXO_CODE))
32
32
 
33
+ import db as nexo_db
34
+
33
35
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
34
36
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
35
37
  COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
@@ -294,25 +296,31 @@ def _touch_existing_followup(existing: dict, *, description: str, date: str = ""
294
296
  preferred_date = _prefer_due_date(existing.get("date", ""), date)
295
297
  if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
296
298
  updates["date"] = preferred_date
297
- if "reasoning" in cols and reasoning_note:
298
- updates["reasoning"] = _append_note(existing.get("reasoning", ""), reasoning_note)
299
- if "updated_at" in cols:
300
- updates["updated_at"] = datetime.now().timestamp()
301
-
299
+ note = reasoning_note or "Deep Sleep matched this followup semantically."
300
+ changed = False
302
301
  if updates:
303
- conn = sqlite3.connect(str(NEXO_DB))
304
- set_clause = ", ".join(f"{column} = ?" for column in updates)
305
- params = list(updates.values()) + [existing["id"]]
306
- conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", params)
307
- conn.commit()
308
- conn.close()
302
+ result = nexo_db.update_followup(
303
+ str(existing["id"]),
304
+ history_actor="deep-sleep",
305
+ history_event="updated",
306
+ history_note=note,
307
+ **updates,
308
+ )
309
+ if result.get("error"):
310
+ return {"success": False, "error": result["error"]}
311
+ changed = True
312
+ elif note:
313
+ note_result = nexo_db.add_followup_note(str(existing["id"]), note, actor="deep-sleep")
314
+ if note_result.get("error"):
315
+ return {"success": False, "error": note_result["error"]}
316
+ changed = True
309
317
 
310
318
  return {
311
319
  "success": True,
312
320
  "id": existing["id"],
313
321
  "outcome": "matched_existing_followup",
314
322
  "similarity": existing.get("_similarity", 1.0),
315
- "updated_existing": bool(updates),
323
+ "updated_existing": changed,
316
324
  }
317
325
 
318
326
 
@@ -509,32 +517,27 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "")
509
517
  reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
510
518
  )
511
519
 
512
- now = datetime.now().timestamp()
513
520
  # Generate a deterministic ID
514
521
  fid = "NF-DS-" + hashlib.md5(description.encode()).hexdigest()[:8].upper()
515
- columns = _table_columns(NEXO_DB, "followups")
516
- payload = {
517
- "id": fid,
518
- "description": description,
519
- "date": date,
520
- "status": "PENDING",
521
- "created_at": now,
522
- "updated_at": now,
523
- }
524
- if "reasoning" in columns:
525
- payload["reasoning"] = reasoning_note or "Deep Sleep v2 overnight analysis"
526
- if "verification" in columns:
527
- payload["verification"] = ""
528
- insert_columns = [column for column in payload if column in columns]
529
- values = [payload[column] for column in insert_columns]
522
+ existing = nexo_db.get_followup(fid)
523
+ if existing:
524
+ return _touch_existing_followup(
525
+ existing,
526
+ description=description,
527
+ date=date,
528
+ reasoning_note=reasoning_note or "Deep Sleep revisited this deterministic followup.",
529
+ )
530
530
 
531
- conn = sqlite3.connect(str(NEXO_DB))
532
- conn.execute(
533
- f"INSERT OR IGNORE INTO followups ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
534
- values,
531
+ followup_result = nexo_db.create_followup(
532
+ id=fid,
533
+ description=description,
534
+ date=date or None,
535
+ verification="",
536
+ reasoning=reasoning_note or "Deep Sleep v2 overnight analysis",
537
+ recurrence=None,
535
538
  )
536
- conn.commit()
537
- conn.close()
539
+ if followup_result.get("error"):
540
+ return {"success": False, "error": followup_result["error"]}
538
541
  return {"success": True, "id": fid, "outcome": "new_followup"}
539
542
  except Exception as e:
540
543
  return {"success": False, "error": str(e)}
@@ -37,6 +37,7 @@ if str(NEXO_CODE) not in sys.path:
37
37
  sys.path.insert(0, str(NEXO_CODE))
38
38
 
39
39
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
40
+ import db as nexo_db
40
41
  from public_evolution_queue import queue_public_port_candidate
41
42
 
42
43
  LOG_DIR = NEXO_HOME / "logs"
@@ -251,42 +252,36 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
251
252
  "description": description,
252
253
  "verification": verification,
253
254
  "reasoning": reasoning,
254
- "updated_at": now_epoch,
255
255
  }
256
256
  if "priority" in columns:
257
257
  update_fields["priority"] = priority
258
258
  closed_status = str(existing_id_row["status"] or "").upper()
259
259
  if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
260
260
  update_fields["status"] = "PENDING"
261
- ordered_updates = [name for name in update_fields.keys() if name in columns]
262
- if ordered_updates:
263
- assignments = ", ".join(f"{name} = ?" for name in ordered_updates)
264
- conn.execute(
265
- f"UPDATE followups SET {assignments} WHERE id = ?",
266
- [update_fields[name] for name in ordered_updates] + [followup_id],
267
- )
261
+ conn.commit()
262
+ result = nexo_db.update_followup(
263
+ followup_id,
264
+ history_actor="self-audit",
265
+ history_event="updated",
266
+ history_note="Daily self-audit refreshed canonical followup coverage.",
267
+ **update_fields,
268
+ )
269
+ if result.get("error"):
270
+ return ""
268
271
  return followup_id
269
272
 
270
- values = {
271
- "id": followup_id,
272
- "date": "",
273
- "description": description,
274
- "verification": verification,
275
- "status": "PENDING",
276
- "reasoning": reasoning,
277
- "recurrence": None,
278
- "created_at": now_epoch,
279
- "updated_at": now_epoch,
280
- }
281
- if "priority" in columns:
282
- values["priority"] = priority
283
-
284
- ordered_columns = [name for name in values.keys() if name in columns]
285
- placeholders = ", ".join("?" for _ in ordered_columns)
286
- conn.execute(
287
- f"INSERT INTO followups ({', '.join(ordered_columns)}) VALUES ({placeholders})",
288
- [values[name] for name in ordered_columns],
273
+ conn.commit()
274
+ result = nexo_db.create_followup(
275
+ id=followup_id,
276
+ description=description,
277
+ date=None,
278
+ verification=verification,
279
+ reasoning=reasoning,
280
+ recurrence=None,
281
+ priority=priority,
289
282
  )
283
+ if result.get("error"):
284
+ return ""
290
285
  return followup_id
291
286
 
292
287
 
@@ -319,7 +314,6 @@ def _append_note(existing: str, note: str) -> str:
319
314
  def _complete_matching_followup(conn: sqlite3.Connection, description: str, note: str) -> int:
320
315
  if not _table_exists(conn, "followups"):
321
316
  return 0
322
- columns = _table_columns(conn, "followups")
323
317
  rows = conn.execute(
324
318
  """SELECT id, verification, reasoning
325
319
  FROM followups
@@ -329,21 +323,11 @@ def _complete_matching_followup(conn: sqlite3.Connection, description: str, note
329
323
  (description,),
330
324
  ).fetchall()
331
325
  completed = 0
332
- now_epoch = datetime.now().timestamp()
326
+ conn.commit()
333
327
  for row in rows:
334
- updates = {"status": "COMPLETED"}
335
- if "updated_at" in columns:
336
- updates["updated_at"] = now_epoch
337
- if "verification" in columns:
338
- updates["verification"] = _append_note(row["verification"], note)
339
- if "reasoning" in columns:
340
- updates["reasoning"] = _append_note(row["reasoning"], note)
341
- assignments = ", ".join(f"{column} = ?" for column in updates)
342
- conn.execute(
343
- f"UPDATE followups SET {assignments} WHERE id = ?",
344
- [updates[column] for column in updates] + [row["id"]],
345
- )
346
- completed += 1
328
+ result = nexo_db.complete_followup(str(row["id"]), note)
329
+ if not result.get("error"):
330
+ completed += 1
347
331
  return completed
348
332
 
349
333
 
@@ -18,6 +18,13 @@ from datetime import datetime, date, timedelta
18
18
  from pathlib import Path
19
19
 
20
20
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
21
+ _script_dir = Path(__file__).resolve().parent
22
+ _repo_src = _script_dir.parent
23
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
24
+ if str(NEXO_CODE) not in sys.path:
25
+ sys.path.insert(0, str(NEXO_CODE))
26
+
27
+ import db as nexo_db
21
28
 
22
29
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
23
30
  COORD_DIR = NEXO_HOME / "coordination"
@@ -50,11 +57,31 @@ def main():
50
57
  dirty_r = conn.execute("SELECT COUNT(*) FROM reminders WHERE status LIKE 'COMPLETED %'").fetchone()[0]
51
58
 
52
59
  if dirty_f > 0:
53
- conn.execute("UPDATE followups SET status='COMPLETED' WHERE status LIKE 'COMPLETED %'")
60
+ dirty_followups = conn.execute(
61
+ "SELECT id, status FROM followups WHERE status LIKE 'COMPLETED %'"
62
+ ).fetchall()
63
+ for row in dirty_followups:
64
+ nexo_db.update_followup(
65
+ str(row["id"]),
66
+ status="COMPLETED",
67
+ history_actor="followup-hygiene",
68
+ history_event="normalized",
69
+ history_note=f"Weekly hygiene normalized dirty status from {row['status']} to COMPLETED.",
70
+ )
54
71
  log(f"Normalized {dirty_f} dirty followup statuses")
55
72
 
56
73
  if dirty_r > 0:
57
- conn.execute("UPDATE reminders SET status='COMPLETED' WHERE status LIKE 'COMPLETED %'")
74
+ dirty_reminders = conn.execute(
75
+ "SELECT id, status FROM reminders WHERE status LIKE 'COMPLETED %'"
76
+ ).fetchall()
77
+ for row in dirty_reminders:
78
+ nexo_db.update_reminder(
79
+ str(row["id"]),
80
+ status="COMPLETED",
81
+ history_actor="followup-hygiene",
82
+ history_event="normalized",
83
+ history_note=f"Weekly hygiene normalized dirty status from {row['status']} to COMPLETED.",
84
+ )
58
85
  log(f"Normalized {dirty_r} dirty reminder statuses")
59
86
 
60
87
  # 2. Flag stale followups (PENDING >14 days, no updates)
@@ -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"])
package/src/server.py CHANGED
@@ -620,12 +620,7 @@ def nexo_followup_create(id: str, description: str, date: str = "", verification
620
620
  When completed, a new followup is auto-created with the next date. The completed one is archived with date suffix.
621
621
  priority: critical, high, medium, low (default: medium).
622
622
  """
623
- result = handle_followup_create(id, description, date, verification, reasoning, recurrence)
624
- if priority in ('critical', 'high', 'low') and 'created' in result:
625
- from db import get_db
626
- get_db().execute("UPDATE followups SET priority = ? WHERE id = ?", (priority, id))
627
- get_db().commit()
628
- return result
623
+ return handle_followup_create(id, description, date, verification, reasoning, recurrence, priority)
629
624
 
630
625
 
631
626
  @mcp.tool
@@ -307,16 +307,26 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
307
307
  return f"ERROR: {result['error']}"
308
308
  if prevention or applies_to or review_days > 0 or priority != 'medium':
309
309
  initial_weight = {'critical': 0.9, 'high': 0.7, 'medium': 0.5, 'low': 0.3}[priority]
310
+ updated_at = now_epoch()
311
+ review_due_at = now_epoch() + (max(1, int(review_days)) * 86400)
310
312
  conn = get_db()
311
313
  conn.execute(
312
314
  "UPDATE learnings SET prevention = ?, applies_to = ?, status = COALESCE(status, 'active'), "
313
315
  "review_due_at = ?, updated_at = ?, priority = ?, weight = ? WHERE id = ?",
314
- (prevention, applies_to, now_epoch() + (max(1, int(review_days)) * 86400), now_epoch(),
316
+ (prevention, applies_to, review_due_at, updated_at,
315
317
  priority, initial_weight, result["id"])
316
318
  )
317
319
  conn.commit()
318
- result = conn.execute("SELECT * FROM learnings WHERE id = ?", (result["id"],)).fetchone()
319
320
  result = dict(result)
321
+ result.update({
322
+ "prevention": prevention,
323
+ "applies_to": applies_to,
324
+ "status": result.get("status") or "active",
325
+ "review_due_at": review_due_at,
326
+ "updated_at": updated_at,
327
+ "priority": priority,
328
+ "weight": initial_weight,
329
+ })
320
330
 
321
331
  # Cognitive ingest — embed learning for semantic search
322
332
  new_id = result["id"]
@@ -181,7 +181,15 @@ def handle_reminder_delete(id: str, read_token: str = '') -> str:
181
181
 
182
182
  # ── Followups ──────────────────────────────────────────────────────────────────
183
183
 
184
- def handle_followup_create(id: str, description: str, date: str = '', verification: str = '', reasoning: str = '', recurrence: str = '') -> str:
184
+ def handle_followup_create(
185
+ id: str,
186
+ description: str,
187
+ date: str = '',
188
+ verification: str = '',
189
+ reasoning: str = '',
190
+ recurrence: str = '',
191
+ priority: str = 'medium',
192
+ ) -> str:
185
193
  """Create a new NEXO followup. id must start with 'NF'.
186
194
 
187
195
  Args:
@@ -196,16 +204,25 @@ def handle_followup_create(id: str, description: str, date: str = '', verificati
196
204
  if not id.startswith('NF'):
197
205
  return f"ERROR: Followup ID must start with 'NF' (received: '{id}')."
198
206
 
199
- result = create_followup(id=id, description=description, date=date or None, verification=verification, reasoning=reasoning, recurrence=recurrence or None)
207
+ result = create_followup(
208
+ id=id,
209
+ description=description,
210
+ date=date or None,
211
+ verification=verification,
212
+ reasoning=reasoning,
213
+ recurrence=recurrence or None,
214
+ priority=priority or "medium",
215
+ )
200
216
  if not result or "error" in result:
201
217
  error_msg = result.get("error", "unknown") if isinstance(result, dict) else "unknown"
202
218
  return f"ERROR: {error_msg}"
203
219
 
204
220
  date_str = date if date else 'no date'
205
221
  rec_str = f" Recurrence: {recurrence}." if recurrence else ""
222
+ priority_str = f" Priority: {priority or 'medium'}."
206
223
  warning = result.get("warning", "")
207
224
  warn_str = f"\n{warning}" if warning else ""
208
- return f"Followup created. Date: {date_str}.{rec_str}{warn_str}"
225
+ return f"Followup created. Date: {date_str}.{priority_str}{rec_str}{warn_str}"
209
226
 
210
227
 
211
228
  def handle_followup_get(id: str) -> str: