nexo-brain 3.1.1 → 3.1.3
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/auto_update.py +2 -0
- package/src/cron_recovery.py +52 -1
- package/src/crons/manifest.json +1 -0
- package/src/crons/sync.py +3 -3
- package/src/dashboard/app.py +131 -106
- package/src/db/__init__.py +3 -2
- package/src/db/_core.py +20 -1
- package/src/db/_reminders.py +451 -124
- package/src/db/_schema.py +30 -0
- package/src/doctor/providers/runtime.py +2 -2
- package/src/server.py +120 -22
- package/src/tools_learnings.py +13 -1
- package/src/tools_reminders.py +8 -5
- package/src/tools_reminders_crud.py +180 -15
- package/templates/launchagents/README.md +1 -1
- package/templates/launchagents/com.nexo.evolution.plist +5 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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.3",
|
|
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/auto_update.py
CHANGED
|
@@ -1198,6 +1198,7 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
|
|
|
1198
1198
|
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1199
1199
|
"client_sync.py",
|
|
1200
1200
|
"client_preferences.py", "agent_runner.py", "bootstrap_docs.py",
|
|
1201
|
+
"hook_guardrails.py", "protocol_settings.py", "public_evolution_queue.py",
|
|
1201
1202
|
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1202
1203
|
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1203
1204
|
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
@@ -1248,6 +1249,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
|
|
|
1248
1249
|
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1249
1250
|
"client_sync.py",
|
|
1250
1251
|
"client_preferences.py", "agent_runner.py", "bootstrap_docs.py",
|
|
1252
|
+
"hook_guardrails.py", "protocol_settings.py", "public_evolution_queue.py",
|
|
1251
1253
|
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1252
1254
|
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1253
1255
|
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
package/src/cron_recovery.py
CHANGED
|
@@ -6,6 +6,8 @@ import os
|
|
|
6
6
|
import plistlib
|
|
7
7
|
import sqlite3
|
|
8
8
|
import contextlib
|
|
9
|
+
import hashlib
|
|
10
|
+
import socket
|
|
9
11
|
from datetime import datetime, timedelta, timezone
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
|
|
@@ -31,6 +33,55 @@ def _load_json(path: Path, default):
|
|
|
31
33
|
return default
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
def _schedule_machine_id() -> str:
|
|
37
|
+
schedule = _load_json(SCHEDULE_FILE, {})
|
|
38
|
+
if isinstance(schedule, dict):
|
|
39
|
+
public = schedule.get("public_contribution")
|
|
40
|
+
if isinstance(public, dict):
|
|
41
|
+
candidate = str(public.get("machine_id") or "").strip().lower()
|
|
42
|
+
if candidate:
|
|
43
|
+
return candidate
|
|
44
|
+
candidate = socket.gethostname().strip().lower()
|
|
45
|
+
return candidate or "nexo-machine"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _stable_schedule_bucket(key: str, modulo: int) -> int:
|
|
49
|
+
if modulo <= 0:
|
|
50
|
+
return 0
|
|
51
|
+
digest = hashlib.sha256(f"{_schedule_machine_id()}::{key}".encode("utf-8")).digest()
|
|
52
|
+
return int.from_bytes(digest[:8], "big") % modulo
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_declared_schedule(cron: dict) -> dict:
|
|
56
|
+
schedule = cron.get("schedule")
|
|
57
|
+
if not isinstance(schedule, dict):
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
resolved = dict(schedule)
|
|
61
|
+
strategy = str(cron.get("schedule_strategy") or resolved.pop("strategy", "")).strip().lower()
|
|
62
|
+
if strategy != "machine_weekly_spread":
|
|
63
|
+
return resolved
|
|
64
|
+
|
|
65
|
+
if not {"hour", "minute", "weekday"} <= resolved.keys():
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
total_week_minutes = 7 * 24 * 60
|
|
69
|
+
base_total = (
|
|
70
|
+
(int(resolved.get("weekday", 0)) % 7) * 1440
|
|
71
|
+
+ (int(resolved.get("hour", 0)) % 24) * 60
|
|
72
|
+
+ (int(resolved.get("minute", 0)) % 60)
|
|
73
|
+
)
|
|
74
|
+
offset = _stable_schedule_bucket(str(cron.get("id") or "cron"), total_week_minutes)
|
|
75
|
+
slot_total = (base_total + offset) % total_week_minutes
|
|
76
|
+
weekday, minute_of_day = divmod(slot_total, 1440)
|
|
77
|
+
hour, minute = divmod(minute_of_day, 60)
|
|
78
|
+
return {
|
|
79
|
+
"weekday": weekday,
|
|
80
|
+
"hour": hour,
|
|
81
|
+
"minute": minute,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
34
85
|
def load_enabled_crons() -> list[dict]:
|
|
35
86
|
manifest_candidates = [
|
|
36
87
|
NEXO_HOME / "crons" / "manifest.json",
|
|
@@ -215,7 +266,7 @@ def effective_schedule(cron: dict) -> dict:
|
|
|
215
266
|
return {
|
|
216
267
|
"source": "manifest",
|
|
217
268
|
"schedule_type": "calendar",
|
|
218
|
-
"calendar": cron
|
|
269
|
+
"calendar": resolve_declared_schedule(cron),
|
|
219
270
|
"run_at_load": should_run_at_load(cron),
|
|
220
271
|
}
|
|
221
272
|
return {
|
package/src/crons/manifest.json
CHANGED
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
{
|
|
108
108
|
"id": "evolution",
|
|
109
109
|
"script": "scripts/nexo-evolution-run.py",
|
|
110
|
+
"schedule_strategy": "machine_weekly_spread",
|
|
110
111
|
"schedule": {"hour": 5, "minute": 0, "weekday": 0},
|
|
111
112
|
"description": "Weekly self-improvement cycle — propose and evaluate changes",
|
|
112
113
|
"core": true,
|
package/src/crons/sync.py
CHANGED
|
@@ -31,7 +31,7 @@ _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
|
|
|
31
31
|
if str(_runtime_root) not in sys.path:
|
|
32
32
|
sys.path.insert(0, str(_runtime_root))
|
|
33
33
|
|
|
34
|
-
from cron_recovery import should_run_at_load
|
|
34
|
+
from cron_recovery import resolve_declared_schedule, should_run_at_load
|
|
35
35
|
|
|
36
36
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
37
37
|
SOURCE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
|
|
@@ -242,7 +242,7 @@ def build_plist(cron: dict) -> dict:
|
|
|
242
242
|
plist["StartInterval"] = cron["interval_seconds"]
|
|
243
243
|
elif "schedule" in cron and not cron.get("keep_alive"):
|
|
244
244
|
cal = {}
|
|
245
|
-
s = cron
|
|
245
|
+
s = resolve_declared_schedule(cron)
|
|
246
246
|
if "hour" in s:
|
|
247
247
|
cal["Hour"] = s["hour"]
|
|
248
248
|
if "minute" in s:
|
|
@@ -443,7 +443,7 @@ StandardError=append:{stderr_log}
|
|
|
443
443
|
elif "interval_seconds" in cron:
|
|
444
444
|
timer_spec = f"OnUnitActiveSec={cron['interval_seconds']}s\nOnBootSec=60s"
|
|
445
445
|
elif "schedule" in cron:
|
|
446
|
-
s = cron
|
|
446
|
+
s = resolve_declared_schedule(cron)
|
|
447
447
|
h, m = s.get("hour", 0), s.get("minute", 0)
|
|
448
448
|
if "weekday" in s:
|
|
449
449
|
# Manifest weekday uses launchd convention: 0=Sunday … 6=Saturday (7=Sunday alias)
|
package/src/dashboard/app.py
CHANGED
|
@@ -745,18 +745,21 @@ async def api_reminders_list(
|
|
|
745
745
|
):
|
|
746
746
|
"""List reminders."""
|
|
747
747
|
db = _db()
|
|
748
|
-
|
|
749
|
-
query = "SELECT * FROM reminders WHERE 1=1"
|
|
750
|
-
params = []
|
|
748
|
+
reminders = db.get_reminders("any")
|
|
751
749
|
if status:
|
|
752
|
-
|
|
753
|
-
|
|
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"]
|
|
754
760
|
if category:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
query += " ORDER BY created_at DESC"
|
|
758
|
-
rows = conn.execute(query, params).fetchall()
|
|
759
|
-
reminders = [dict(r) for r in rows]
|
|
761
|
+
reminders = [r for r in reminders if r.get("category") == category]
|
|
762
|
+
reminders = sorted(reminders, key=lambda item: item.get("updated_at") or item.get("created_at") or 0, reverse=True)
|
|
760
763
|
return {"count": len(reminders), "reminders": reminders}
|
|
761
764
|
|
|
762
765
|
|
|
@@ -766,59 +769,60 @@ async def api_reminders_create(body: ReminderCreate):
|
|
|
766
769
|
db = _db()
|
|
767
770
|
conn = db.get_db()
|
|
768
771
|
rid = _next_reminder_id(conn)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
772
|
+
result = db.create_reminder(
|
|
773
|
+
rid,
|
|
774
|
+
body.description,
|
|
775
|
+
date=body.date,
|
|
776
|
+
category=body.category or "general",
|
|
773
777
|
)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
return {"success": True, "reminder":
|
|
778
|
+
if not result or "error" in result:
|
|
779
|
+
return JSONResponse({"error": result.get("error", "Failed to create reminder")}, status_code=400)
|
|
780
|
+
return {"success": True, "reminder": result}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@app.get("/api/reminders/{rid}")
|
|
784
|
+
async def api_reminders_get(rid: str):
|
|
785
|
+
"""Get a reminder with history."""
|
|
786
|
+
db = _db()
|
|
787
|
+
row = db.get_reminder(rid, include_history=True)
|
|
788
|
+
if not row:
|
|
789
|
+
return JSONResponse({"error": f"Reminder {rid} not found"}, status_code=404)
|
|
790
|
+
return {"success": True, "reminder": row}
|
|
777
791
|
|
|
778
792
|
|
|
779
793
|
@app.put("/api/reminders/{rid}")
|
|
780
794
|
async def api_reminders_update(rid: str, body: ReminderUpdate):
|
|
781
795
|
"""Update a reminder."""
|
|
782
796
|
db = _db()
|
|
783
|
-
|
|
784
|
-
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (rid,)).fetchone()
|
|
797
|
+
row = db.get_reminder(rid)
|
|
785
798
|
if not row:
|
|
786
799
|
return JSONResponse({"error": f"Reminder {rid} not found"}, status_code=404)
|
|
787
|
-
fields =
|
|
788
|
-
params = []
|
|
800
|
+
fields = {}
|
|
789
801
|
if body.description is not None:
|
|
790
|
-
fields
|
|
791
|
-
params.append(body.description)
|
|
802
|
+
fields["description"] = body.description
|
|
792
803
|
if body.date is not None:
|
|
793
|
-
fields
|
|
794
|
-
params.append(body.date)
|
|
804
|
+
fields["date"] = body.date
|
|
795
805
|
if body.status is not None:
|
|
796
|
-
fields
|
|
797
|
-
params.append(body.status)
|
|
806
|
+
fields["status"] = body.status
|
|
798
807
|
if body.category is not None:
|
|
799
|
-
fields
|
|
800
|
-
params.append(body.category)
|
|
808
|
+
fields["category"] = body.category
|
|
801
809
|
if not fields:
|
|
802
|
-
return {"success": True, "reminder":
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
conn.commit()
|
|
808
|
-
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (rid,)).fetchone()
|
|
809
|
-
return {"success": True, "reminder": dict(row)}
|
|
810
|
+
return {"success": True, "reminder": row}
|
|
811
|
+
result = db.update_reminder(rid, history_actor="dashboard", **fields)
|
|
812
|
+
if not result or "error" in result:
|
|
813
|
+
return JSONResponse({"error": result.get("error", f"Reminder {rid} not found")}, status_code=400)
|
|
814
|
+
return {"success": True, "reminder": result}
|
|
810
815
|
|
|
811
816
|
|
|
812
817
|
@app.delete("/api/reminders/{rid}")
|
|
813
818
|
async def api_reminders_delete(rid: str):
|
|
814
|
-
"""
|
|
819
|
+
"""Soft-delete a reminder."""
|
|
815
820
|
db = _db()
|
|
816
|
-
|
|
817
|
-
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (rid,)).fetchone()
|
|
821
|
+
row = db.get_reminder(rid)
|
|
818
822
|
if not row:
|
|
819
823
|
return JSONResponse({"error": f"Reminder {rid} not found"}, status_code=404)
|
|
820
|
-
|
|
821
|
-
|
|
824
|
+
db.add_reminder_note(rid, "Soft-deleted from dashboard.", actor="dashboard")
|
|
825
|
+
db.delete_reminder(rid)
|
|
822
826
|
return {"success": True, "deleted_id": rid}
|
|
823
827
|
|
|
824
828
|
|
|
@@ -847,15 +851,19 @@ async def api_followups_list(
|
|
|
847
851
|
):
|
|
848
852
|
"""List followups."""
|
|
849
853
|
db = _db()
|
|
850
|
-
|
|
851
|
-
query = "SELECT * FROM followups WHERE 1=1"
|
|
852
|
-
params = []
|
|
854
|
+
followups = db.get_followups("any")
|
|
853
855
|
if status:
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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"]
|
|
866
|
+
followups = sorted(followups, key=lambda item: item.get("updated_at") or item.get("created_at") or 0, reverse=True)
|
|
859
867
|
return {"count": len(followups), "followups": followups}
|
|
860
868
|
|
|
861
869
|
|
|
@@ -865,62 +873,63 @@ async def api_followups_create(body: FollowupCreate):
|
|
|
865
873
|
db = _db()
|
|
866
874
|
conn = db.get_db()
|
|
867
875
|
fid = _next_followup_id(conn)
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
876
|
+
result = db.create_followup(
|
|
877
|
+
fid,
|
|
878
|
+
body.description,
|
|
879
|
+
date=body.date,
|
|
880
|
+
verification=body.verification or "",
|
|
881
|
+
reasoning=body.reasoning or "",
|
|
872
882
|
)
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
return {"success": True, "followup":
|
|
883
|
+
if not result or "error" in result:
|
|
884
|
+
return JSONResponse({"error": result.get("error", "Failed to create followup")}, status_code=400)
|
|
885
|
+
return {"success": True, "followup": result}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@app.get("/api/followups/{fid}")
|
|
889
|
+
async def api_followups_get(fid: str):
|
|
890
|
+
"""Get a followup with history."""
|
|
891
|
+
db = _db()
|
|
892
|
+
row = db.get_followup(fid, include_history=True)
|
|
893
|
+
if not row:
|
|
894
|
+
return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
|
|
895
|
+
return {"success": True, "followup": row}
|
|
876
896
|
|
|
877
897
|
|
|
878
898
|
@app.put("/api/followups/{fid}")
|
|
879
899
|
async def api_followups_update(fid: str, body: FollowupUpdate):
|
|
880
900
|
"""Update a followup."""
|
|
881
901
|
db = _db()
|
|
882
|
-
|
|
883
|
-
row = conn.execute("SELECT * FROM followups WHERE id = ?", (fid,)).fetchone()
|
|
902
|
+
row = db.get_followup(fid)
|
|
884
903
|
if not row:
|
|
885
904
|
return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
|
|
886
|
-
fields =
|
|
887
|
-
params = []
|
|
905
|
+
fields = {}
|
|
888
906
|
if body.description is not None:
|
|
889
|
-
fields
|
|
890
|
-
params.append(body.description)
|
|
907
|
+
fields["description"] = body.description
|
|
891
908
|
if body.date is not None:
|
|
892
|
-
fields
|
|
893
|
-
params.append(body.date)
|
|
909
|
+
fields["date"] = body.date
|
|
894
910
|
if body.status is not None:
|
|
895
|
-
fields
|
|
896
|
-
params.append(body.status)
|
|
911
|
+
fields["status"] = body.status
|
|
897
912
|
if body.verification is not None:
|
|
898
|
-
fields
|
|
899
|
-
params.append(body.verification)
|
|
913
|
+
fields["verification"] = body.verification
|
|
900
914
|
if body.reasoning is not None:
|
|
901
|
-
fields
|
|
902
|
-
params.append(body.reasoning)
|
|
915
|
+
fields["reasoning"] = body.reasoning
|
|
903
916
|
if not fields:
|
|
904
|
-
return {"success": True, "followup":
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
conn.commit()
|
|
910
|
-
row = conn.execute("SELECT * FROM followups WHERE id = ?", (fid,)).fetchone()
|
|
911
|
-
return {"success": True, "followup": dict(row)}
|
|
917
|
+
return {"success": True, "followup": row}
|
|
918
|
+
result = db.update_followup(fid, history_actor="dashboard", **fields)
|
|
919
|
+
if not result or "error" in result:
|
|
920
|
+
return JSONResponse({"error": result.get("error", f"Followup {fid} not found")}, status_code=400)
|
|
921
|
+
return {"success": True, "followup": result}
|
|
912
922
|
|
|
913
923
|
|
|
914
924
|
@app.delete("/api/followups/{fid}")
|
|
915
925
|
async def api_followups_delete(fid: str):
|
|
916
|
-
"""
|
|
926
|
+
"""Soft-delete a followup."""
|
|
917
927
|
db = _db()
|
|
918
|
-
|
|
919
|
-
row = conn.execute("SELECT * FROM followups WHERE id = ?", (fid,)).fetchone()
|
|
928
|
+
row = db.get_followup(fid)
|
|
920
929
|
if not row:
|
|
921
930
|
return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
|
|
922
|
-
|
|
923
|
-
|
|
931
|
+
db.add_followup_note(fid, "Soft-deleted from dashboard.", actor="dashboard")
|
|
932
|
+
db.delete_followup(fid)
|
|
924
933
|
return {"success": True, "deleted_id": fid}
|
|
925
934
|
|
|
926
935
|
|
|
@@ -933,36 +942,49 @@ async def api_ops_move(body: MoveRequest):
|
|
|
933
942
|
"""Move an item between reminders and followups."""
|
|
934
943
|
db = _db()
|
|
935
944
|
conn = db.get_db()
|
|
936
|
-
now = time.time()
|
|
937
945
|
|
|
938
946
|
if body.direction == "to_followup":
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if not row:
|
|
947
|
+
item = db.get_reminder(body.id)
|
|
948
|
+
if not item:
|
|
942
949
|
return JSONResponse({"error": f"Reminder {body.id} not found"}, status_code=404)
|
|
943
|
-
item = dict(row)
|
|
944
950
|
fid = _next_followup_id(conn)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
951
|
+
created = db.create_followup(
|
|
952
|
+
fid,
|
|
953
|
+
item["description"],
|
|
954
|
+
date=item.get("date"),
|
|
955
|
+
reasoning=f"Moved from reminder {body.id} via dashboard.",
|
|
948
956
|
)
|
|
949
|
-
|
|
950
|
-
|
|
957
|
+
if not created or "error" in created:
|
|
958
|
+
return JSONResponse({"error": created.get("error", "Failed to create followup")}, status_code=400)
|
|
959
|
+
db.add_followup_note(fid, f"Created from reminder {body.id} via dashboard move.", actor="dashboard")
|
|
960
|
+
db.add_reminder_note(body.id, f"Moved to followup {fid} via dashboard.", actor="dashboard")
|
|
961
|
+
db.delete_reminder(body.id)
|
|
951
962
|
return {"success": True, "new_id": fid, "direction": "to_followup"}
|
|
952
963
|
|
|
953
964
|
elif body.direction == "to_reminder":
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if not row:
|
|
965
|
+
item = db.get_followup(body.id)
|
|
966
|
+
if not item:
|
|
957
967
|
return JSONResponse({"error": f"Followup {body.id} not found"}, status_code=404)
|
|
958
|
-
item = dict(row)
|
|
959
968
|
rid = _next_reminder_id(conn)
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
969
|
+
created = db.create_reminder(
|
|
970
|
+
rid,
|
|
971
|
+
item["description"],
|
|
972
|
+
date=item.get("date"),
|
|
973
|
+
category="general",
|
|
963
974
|
)
|
|
964
|
-
|
|
965
|
-
|
|
975
|
+
if not created or "error" in created:
|
|
976
|
+
return JSONResponse({"error": created.get("error", "Failed to create reminder")}, status_code=400)
|
|
977
|
+
migration_note = f"Created from followup {body.id} via dashboard move."
|
|
978
|
+
extra = []
|
|
979
|
+
if item.get("verification"):
|
|
980
|
+
extra.append(f"Previous verification: {item['verification']}")
|
|
981
|
+
if item.get("reasoning"):
|
|
982
|
+
extra.append(f"Previous reasoning: {item['reasoning']}")
|
|
983
|
+
if extra:
|
|
984
|
+
migration_note += " " + " ".join(extra)
|
|
985
|
+
db.add_reminder_note(rid, migration_note, actor="dashboard")
|
|
986
|
+
db.add_followup_note(body.id, f"Moved to reminder {rid} via dashboard.", actor="dashboard")
|
|
987
|
+
db.delete_followup(body.id)
|
|
966
988
|
return {"success": True, "new_id": rid, "direction": "to_reminder"}
|
|
967
989
|
|
|
968
990
|
else:
|
|
@@ -977,7 +999,10 @@ async def api_ops_execute(fid: str):
|
|
|
977
999
|
"""Execute a followup by opening Terminal with the configured NEXO client."""
|
|
978
1000
|
db = _db()
|
|
979
1001
|
conn = db.get_db()
|
|
980
|
-
row = conn.execute(
|
|
1002
|
+
row = conn.execute(
|
|
1003
|
+
"SELECT * FROM followups WHERE id = ? AND status != 'DELETED'",
|
|
1004
|
+
(fid,),
|
|
1005
|
+
).fetchone()
|
|
981
1006
|
if not row:
|
|
982
1007
|
return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
|
|
983
1008
|
item = dict(row)
|
|
@@ -1092,12 +1117,12 @@ async def api_calendar(
|
|
|
1092
1117
|
month_prefix = f"{year}-{month:02d}%"
|
|
1093
1118
|
|
|
1094
1119
|
reminder_rows = conn.execute(
|
|
1095
|
-
"SELECT *, 'reminder' as item_type FROM reminders WHERE date LIKE ? ORDER BY date ASC",
|
|
1120
|
+
"SELECT *, 'reminder' as item_type FROM reminders WHERE date LIKE ? AND status != 'DELETED' ORDER BY date ASC",
|
|
1096
1121
|
(month_prefix,),
|
|
1097
1122
|
).fetchall()
|
|
1098
1123
|
|
|
1099
1124
|
followup_rows = conn.execute(
|
|
1100
|
-
"SELECT *, 'followup' as item_type FROM followups WHERE date LIKE ? ORDER BY date ASC",
|
|
1125
|
+
"SELECT *, 'followup' as item_type FROM followups WHERE date LIKE ? AND status != 'DELETED' ORDER BY date ASC",
|
|
1101
1126
|
(month_prefix,),
|
|
1102
1127
|
).fetchall()
|
|
1103
1128
|
|
package/src/db/__init__.py
CHANGED
|
@@ -36,10 +36,11 @@ from db._sessions import (
|
|
|
36
36
|
# Reminders and followups
|
|
37
37
|
from db._reminders import (
|
|
38
38
|
create_reminder, update_reminder, complete_reminder, delete_reminder,
|
|
39
|
-
get_reminders, get_reminder,
|
|
39
|
+
restore_reminder, add_reminder_note, get_reminders, get_reminder, get_reminder_history,
|
|
40
40
|
create_followup, update_followup, complete_followup, delete_followup,
|
|
41
|
-
get_followups, get_followup,
|
|
41
|
+
restore_followup, add_followup_note, get_followups, get_followup, get_followup_history,
|
|
42
42
|
find_similar_followups,
|
|
43
|
+
add_item_history, get_item_history, validate_item_read_token,
|
|
43
44
|
)
|
|
44
45
|
|
|
45
46
|
# Learnings
|
package/src/db/_core.py
CHANGED
|
@@ -190,6 +190,26 @@ def init_db():
|
|
|
190
190
|
updated_at REAL NOT NULL
|
|
191
191
|
);
|
|
192
192
|
|
|
193
|
+
CREATE TABLE IF NOT EXISTS item_history (
|
|
194
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
195
|
+
item_type TEXT NOT NULL,
|
|
196
|
+
item_id TEXT NOT NULL,
|
|
197
|
+
event_type TEXT NOT NULL,
|
|
198
|
+
note TEXT DEFAULT '',
|
|
199
|
+
actor TEXT DEFAULT '',
|
|
200
|
+
metadata TEXT DEFAULT '{}',
|
|
201
|
+
created_at REAL NOT NULL
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
CREATE TABLE IF NOT EXISTS item_read_tokens (
|
|
205
|
+
token TEXT PRIMARY KEY,
|
|
206
|
+
item_type TEXT NOT NULL,
|
|
207
|
+
item_id TEXT NOT NULL,
|
|
208
|
+
history_seq INTEGER DEFAULT 0,
|
|
209
|
+
issued_at REAL NOT NULL,
|
|
210
|
+
expires_at REAL NOT NULL
|
|
211
|
+
);
|
|
212
|
+
|
|
193
213
|
CREATE TABLE IF NOT EXISTS learnings (
|
|
194
214
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
195
215
|
category TEXT NOT NULL,
|
|
@@ -415,4 +435,3 @@ def _multi_word_like(query: str, columns: list[str]) -> tuple[str, list]:
|
|
|
415
435
|
word_conditions.append(f"({col_or})")
|
|
416
436
|
params.extend([pattern] * len(columns))
|
|
417
437
|
return " AND ".join(word_conditions), params
|
|
418
|
-
|