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.
- 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/db/_reminders.py +50 -7
- package/src/scripts/deep-sleep/apply_findings.py +37 -34
- package/src/scripts/nexo-daily-self-audit.py +26 -42
- package/src/scripts/nexo-followup-hygiene.py +29 -2
- package/src/scripts/nexo-proactive-dashboard.py +19 -10
- package/src/server.py +1 -6
- package/src/tools_learnings.py +12 -2
- package/src/tools_reminders_crud.py +20 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "3.1.
|
|
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
|
+
"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",
|
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
|
|
package/src/db/_reminders.py
CHANGED
|
@@ -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 (
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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":
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
326
|
+
conn.commit()
|
|
333
327
|
for row in rows:
|
|
334
|
-
|
|
335
|
-
if "
|
|
336
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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"])
|
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
|
-
|
|
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
|
package/src/tools_learnings.py
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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:
|