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
package/src/db/_reminders.py
CHANGED
|
@@ -1,14 +1,182 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
"""NEXO DB — Reminders
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"""NEXO DB — Reminders and followups with history + soft delete."""
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
5
10
|
from db._core import get_db, now_epoch
|
|
6
11
|
from db._fts import fts_upsert
|
|
7
12
|
|
|
13
|
+
ACTIVE_EXCLUDED_STATUSES = {"DELETED", "archived", "blocked", "waiting"}
|
|
14
|
+
READ_TOKEN_TTL_SECONDS = 30 * 60
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _serialize_metadata(metadata: dict[str, Any] | None) -> str:
|
|
18
|
+
if not metadata:
|
|
19
|
+
return "{}"
|
|
20
|
+
try:
|
|
21
|
+
return json.dumps(metadata, ensure_ascii=True, sort_keys=True)
|
|
22
|
+
except Exception:
|
|
23
|
+
return "{}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate(text: str | None, limit: int = 240) -> str:
|
|
27
|
+
if not text:
|
|
28
|
+
return ""
|
|
29
|
+
text = str(text).strip()
|
|
30
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_changes(before: sqlite3.Row | dict | None, after: sqlite3.Row | dict | None, fields: list[str]) -> str:
|
|
34
|
+
if before is None or after is None:
|
|
35
|
+
return ""
|
|
36
|
+
changes: list[str] = []
|
|
37
|
+
before_d = dict(before)
|
|
38
|
+
after_d = dict(after)
|
|
39
|
+
for field in fields:
|
|
40
|
+
old = before_d.get(field)
|
|
41
|
+
new = after_d.get(field)
|
|
42
|
+
if old == new:
|
|
43
|
+
continue
|
|
44
|
+
changes.append(f"{field}: {_truncate(old, 60) or '∅'} -> {_truncate(new, 60) or '∅'}")
|
|
45
|
+
return "; ".join(changes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _item_table(item_type: str) -> str:
|
|
49
|
+
if item_type == "reminder":
|
|
50
|
+
return "reminders"
|
|
51
|
+
if item_type == "followup":
|
|
52
|
+
return "followups"
|
|
53
|
+
raise ValueError(f"Unsupported item_type: {item_type}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _history_rules(item_type: str) -> list[str]:
|
|
57
|
+
label = "followup" if item_type == "followup" else "reminder"
|
|
58
|
+
return [
|
|
59
|
+
f"Read this {label} and its history before update/delete/restore via MCP.",
|
|
60
|
+
f"Delete is soft: the {label} stays in the DB with status DELETED.",
|
|
61
|
+
f"Use notes to append operational context instead of overwriting history.",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _latest_history_seq(conn, item_type: str, item_id: str) -> int:
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT MAX(id) AS max_id FROM item_history WHERE item_type = ? AND item_id = ?",
|
|
68
|
+
(item_type, item_id),
|
|
69
|
+
).fetchone()
|
|
70
|
+
return int(row["max_id"] or 0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_item_history(
|
|
74
|
+
item_type: str,
|
|
75
|
+
item_id: str,
|
|
76
|
+
event_type: str,
|
|
77
|
+
note: str = "",
|
|
78
|
+
*,
|
|
79
|
+
actor: str = "system",
|
|
80
|
+
metadata: dict[str, Any] | None = None,
|
|
81
|
+
created_at: float | None = None,
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Append an event to reminder/followup history."""
|
|
84
|
+
conn = get_db()
|
|
85
|
+
ts = created_at if created_at is not None else now_epoch()
|
|
86
|
+
conn.execute(
|
|
87
|
+
"INSERT INTO item_history (item_type, item_id, event_type, note, actor, metadata, created_at) "
|
|
88
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
89
|
+
(item_type, item_id, event_type, note or "", actor, _serialize_metadata(metadata), ts),
|
|
90
|
+
)
|
|
91
|
+
conn.commit()
|
|
92
|
+
row = conn.execute(
|
|
93
|
+
"SELECT * FROM item_history WHERE item_type = ? AND item_id = ? ORDER BY id DESC LIMIT 1",
|
|
94
|
+
(item_type, item_id),
|
|
95
|
+
).fetchone()
|
|
96
|
+
return dict(row)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_item_history(item_type: str, item_id: str, limit: int = 20) -> list[dict]:
|
|
100
|
+
"""Return latest history events for a reminder/followup."""
|
|
101
|
+
conn = get_db()
|
|
102
|
+
rows = conn.execute(
|
|
103
|
+
"SELECT * FROM item_history WHERE item_type = ? AND item_id = ? ORDER BY id DESC LIMIT ?",
|
|
104
|
+
(item_type, item_id, limit),
|
|
105
|
+
).fetchall()
|
|
106
|
+
return [dict(r) for r in rows]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _issue_item_read_token(item_type: str, item_id: str, ttl_seconds: int = READ_TOKEN_TTL_SECONDS) -> str:
|
|
110
|
+
conn = get_db()
|
|
111
|
+
now = now_epoch()
|
|
112
|
+
token = "IRT-" + secrets.token_hex(12)
|
|
113
|
+
history_seq = _latest_history_seq(conn, item_type, item_id)
|
|
114
|
+
conn.execute(
|
|
115
|
+
"INSERT INTO item_read_tokens (token, item_type, item_id, history_seq, issued_at, expires_at) "
|
|
116
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
117
|
+
(token, item_type, item_id, history_seq, now, now + ttl_seconds),
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
return token
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_item_read_token(token: str, item_type: str, item_id: str) -> tuple[bool, str]:
|
|
124
|
+
"""Validate that an item was read recently enough before mutation."""
|
|
125
|
+
if not token:
|
|
126
|
+
return False, "Missing read_token. Call the corresponding *_get tool first and use its READ_TOKEN."
|
|
127
|
+
|
|
128
|
+
conn = get_db()
|
|
129
|
+
row = conn.execute(
|
|
130
|
+
"SELECT * FROM item_read_tokens WHERE token = ? AND item_type = ? AND item_id = ?",
|
|
131
|
+
(token, item_type, item_id),
|
|
132
|
+
).fetchone()
|
|
133
|
+
if not row:
|
|
134
|
+
return False, "Invalid read_token. Call the corresponding *_get tool again."
|
|
135
|
+
|
|
136
|
+
now = now_epoch()
|
|
137
|
+
if float(row["expires_at"] or 0) < now:
|
|
138
|
+
conn.execute("DELETE FROM item_read_tokens WHERE token = ?", (token,))
|
|
139
|
+
conn.commit()
|
|
140
|
+
return False, "Expired read_token. Read the item again to refresh its history context."
|
|
141
|
+
|
|
142
|
+
current_seq = _latest_history_seq(conn, item_type, item_id)
|
|
143
|
+
if current_seq != int(row["history_seq"] or 0):
|
|
144
|
+
return False, "History changed since that read. Read the item again before mutating it."
|
|
145
|
+
|
|
146
|
+
return True, ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _reassign_item_identity(conn, item_type: str, old_id: str, new_id: str):
|
|
150
|
+
if old_id == new_id:
|
|
151
|
+
return
|
|
152
|
+
conn.execute(
|
|
153
|
+
"UPDATE item_history SET item_id = ? WHERE item_type = ? AND item_id = ?",
|
|
154
|
+
(new_id, item_type, old_id),
|
|
155
|
+
)
|
|
156
|
+
conn.execute(
|
|
157
|
+
"UPDATE item_read_tokens SET item_id = ? WHERE item_type = ? AND item_id = ?",
|
|
158
|
+
(new_id, item_type, old_id),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _active_status_where(column_name: str = "status") -> str:
|
|
163
|
+
excluded = ", ".join(f"'{value}'" for value in sorted(ACTIVE_EXCLUDED_STATUSES))
|
|
164
|
+
return (
|
|
165
|
+
f"{column_name} NOT LIKE 'COMPLETED%' "
|
|
166
|
+
f"AND {column_name} NOT IN ({excluded})"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
8
170
|
# ── Reminders ──────────────────────────────────────────────────────
|
|
9
171
|
|
|
10
|
-
|
|
11
|
-
|
|
172
|
+
|
|
173
|
+
def create_reminder(
|
|
174
|
+
id: str,
|
|
175
|
+
description: str,
|
|
176
|
+
date: str = None,
|
|
177
|
+
status: str = "PENDING",
|
|
178
|
+
category: str = "general",
|
|
179
|
+
) -> dict:
|
|
12
180
|
"""Create a new reminder."""
|
|
13
181
|
conn = get_db()
|
|
14
182
|
now = now_epoch()
|
|
@@ -16,97 +184,164 @@ def create_reminder(id: str, description: str, date: str = None,
|
|
|
16
184
|
conn.execute(
|
|
17
185
|
"INSERT INTO reminders (id, date, description, status, category, created_at, updated_at) "
|
|
18
186
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
19
|
-
(id, date, description, status, category, now, now)
|
|
187
|
+
(id, date, description, status, category, now, now),
|
|
20
188
|
)
|
|
21
189
|
conn.commit()
|
|
22
190
|
except sqlite3.IntegrityError:
|
|
23
191
|
return {"error": f"Reminder {id} already exists. Use update instead."}
|
|
192
|
+
|
|
24
193
|
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
|
|
194
|
+
add_item_history(
|
|
195
|
+
"reminder",
|
|
196
|
+
id,
|
|
197
|
+
"created",
|
|
198
|
+
note=f"Reminder created. Category={category}. Date={date or '—'}.",
|
|
199
|
+
actor="db",
|
|
200
|
+
)
|
|
25
201
|
return dict(row)
|
|
26
202
|
|
|
27
203
|
|
|
28
|
-
def update_reminder(
|
|
204
|
+
def update_reminder(
|
|
205
|
+
id: str,
|
|
206
|
+
*,
|
|
207
|
+
log_history: bool = True,
|
|
208
|
+
history_event: str = "updated",
|
|
209
|
+
history_actor: str = "db",
|
|
210
|
+
history_note: str = "",
|
|
211
|
+
**kwargs,
|
|
212
|
+
) -> dict:
|
|
29
213
|
"""Update any fields of a reminder: description, date, status, category."""
|
|
30
214
|
conn = get_db()
|
|
31
215
|
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
|
|
32
216
|
if not row:
|
|
33
|
-
|
|
217
|
+
return {"error": f"Reminder {id} not found"}
|
|
218
|
+
|
|
34
219
|
allowed = {"description", "date", "status", "category"}
|
|
35
220
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
|
36
221
|
if not updates:
|
|
37
|
-
|
|
222
|
+
return {"error": "No valid fields to update"}
|
|
223
|
+
|
|
38
224
|
updates["updated_at"] = now_epoch()
|
|
39
225
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
40
226
|
values = list(updates.values()) + [id]
|
|
41
227
|
conn.execute(f"UPDATE reminders SET {set_clause} WHERE id = ?", values)
|
|
42
228
|
conn.commit()
|
|
43
|
-
|
|
44
|
-
|
|
229
|
+
|
|
230
|
+
new_row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
|
|
231
|
+
if log_history:
|
|
232
|
+
note = history_note or _format_changes(row, new_row, ["description", "date", "status", "category"])
|
|
233
|
+
add_item_history("reminder", id, history_event, note=note or "Reminder updated.", actor=history_actor)
|
|
234
|
+
return dict(new_row)
|
|
45
235
|
|
|
46
236
|
|
|
47
237
|
def complete_reminder(id: str) -> dict:
|
|
48
|
-
"""Mark a reminder as completed
|
|
49
|
-
|
|
50
|
-
|
|
238
|
+
"""Mark a reminder as completed."""
|
|
239
|
+
result = update_reminder(
|
|
240
|
+
id,
|
|
241
|
+
status="COMPLETED",
|
|
242
|
+
log_history=False,
|
|
243
|
+
)
|
|
244
|
+
if "error" in result:
|
|
245
|
+
return result
|
|
246
|
+
add_item_history("reminder", id, "completed", note="Reminder marked COMPLETED.", actor="db")
|
|
247
|
+
return result
|
|
51
248
|
|
|
52
249
|
|
|
53
250
|
def delete_reminder(id: str) -> bool:
|
|
54
|
-
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
251
|
+
"""Soft-delete a reminder by setting status to DELETED."""
|
|
252
|
+
result = update_reminder(
|
|
253
|
+
id,
|
|
254
|
+
status="DELETED",
|
|
255
|
+
log_history=False,
|
|
256
|
+
)
|
|
257
|
+
if "error" in result:
|
|
258
|
+
return False
|
|
259
|
+
add_item_history("reminder", id, "deleted", note="Reminder soft-deleted (status=DELETED).", actor="db")
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def restore_reminder(id: str) -> dict:
|
|
264
|
+
"""Restore a soft-deleted reminder back to PENDING."""
|
|
265
|
+
row = get_reminder(id)
|
|
266
|
+
if not row:
|
|
267
|
+
return {"error": f"Reminder {id} not found"}
|
|
268
|
+
result = update_reminder(
|
|
269
|
+
id,
|
|
270
|
+
status="PENDING",
|
|
271
|
+
log_history=False,
|
|
272
|
+
)
|
|
273
|
+
if "error" in result:
|
|
274
|
+
return result
|
|
275
|
+
previous = row.get("status") or "unknown"
|
|
276
|
+
add_item_history("reminder", id, "restored", note=f"Reminder restored from {previous} to PENDING.", actor="db")
|
|
277
|
+
return result
|
|
60
278
|
|
|
61
279
|
|
|
62
|
-
def
|
|
63
|
-
"""
|
|
280
|
+
def add_reminder_note(id: str, note: str, actor: str = "nexo") -> dict:
|
|
281
|
+
"""Append an operational note to a reminder history."""
|
|
282
|
+
row = get_reminder(id)
|
|
283
|
+
if not row:
|
|
284
|
+
return {"error": f"Reminder {id} not found"}
|
|
285
|
+
return add_item_history("reminder", id, "note", note=note, actor=actor)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_reminders(filter_type: str = "all") -> list[dict]:
|
|
289
|
+
"""Get reminders by filter: active, due, completed, deleted, history."""
|
|
64
290
|
conn = get_db()
|
|
65
291
|
today = datetime.date.today().isoformat()
|
|
66
|
-
if filter_type ==
|
|
292
|
+
if filter_type == "completed":
|
|
67
293
|
rows = conn.execute(
|
|
68
294
|
"SELECT * FROM reminders WHERE status LIKE 'COMPLETED%' ORDER BY updated_at DESC"
|
|
69
295
|
).fetchall()
|
|
70
|
-
elif filter_type ==
|
|
296
|
+
elif filter_type == "deleted":
|
|
297
|
+
rows = conn.execute(
|
|
298
|
+
"SELECT * FROM reminders WHERE status = 'DELETED' ORDER BY updated_at DESC"
|
|
299
|
+
).fetchall()
|
|
300
|
+
elif filter_type in {"history", "any"}:
|
|
301
|
+
rows = conn.execute(
|
|
302
|
+
"SELECT * FROM reminders ORDER BY updated_at DESC"
|
|
303
|
+
).fetchall()
|
|
304
|
+
elif filter_type == "due":
|
|
71
305
|
rows = conn.execute(
|
|
72
|
-
"SELECT * FROM reminders WHERE
|
|
73
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting') "
|
|
306
|
+
f"SELECT * FROM reminders WHERE {_active_status_where()} "
|
|
74
307
|
"AND date IS NOT NULL AND date <= ? "
|
|
75
308
|
"ORDER BY date ASC",
|
|
76
|
-
(today,)
|
|
309
|
+
(today,),
|
|
77
310
|
).fetchall()
|
|
78
|
-
else:
|
|
311
|
+
else:
|
|
79
312
|
rows = conn.execute(
|
|
80
|
-
"SELECT * FROM reminders WHERE
|
|
81
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting') "
|
|
313
|
+
f"SELECT * FROM reminders WHERE {_active_status_where()} "
|
|
82
314
|
"ORDER BY date ASC NULLS LAST"
|
|
83
315
|
).fetchall()
|
|
84
316
|
return [dict(r) for r in rows]
|
|
85
317
|
|
|
86
318
|
|
|
87
|
-
def get_reminder(id: str) -> dict | None:
|
|
88
|
-
"""Get a single reminder by id."""
|
|
319
|
+
def get_reminder(id: str, include_history: bool = False) -> dict | None:
|
|
320
|
+
"""Get a single reminder by id, optionally with history and read token."""
|
|
89
321
|
conn = get_db()
|
|
90
322
|
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
|
|
91
|
-
|
|
323
|
+
if not row:
|
|
324
|
+
return None
|
|
325
|
+
result = dict(row)
|
|
326
|
+
if include_history:
|
|
327
|
+
result["history"] = get_item_history("reminder", id)
|
|
328
|
+
result["history_rules"] = _history_rules("reminder")
|
|
329
|
+
result["read_token"] = _issue_item_read_token("reminder", id)
|
|
330
|
+
return result
|
|
92
331
|
|
|
93
332
|
|
|
94
|
-
def
|
|
95
|
-
""
|
|
333
|
+
def get_reminder_history(id: str, limit: int = 20) -> list[dict]:
|
|
334
|
+
return get_item_history("reminder", id, limit=limit)
|
|
96
335
|
|
|
97
|
-
Uses asymmetric scoring: what fraction of the SMALLER token set overlaps
|
|
98
|
-
with the larger. This handles different-length texts better than Jaccard.
|
|
99
336
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"""
|
|
337
|
+
def find_similar_followups(description: str, threshold: float = 0.3) -> list[dict]:
|
|
338
|
+
"""Find open followups similar to a description using keyword overlap."""
|
|
103
339
|
conn = get_db()
|
|
104
340
|
rows = conn.execute(
|
|
105
|
-
"SELECT * FROM followups WHERE
|
|
106
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting')"
|
|
341
|
+
f"SELECT * FROM followups WHERE {_active_status_where()}"
|
|
107
342
|
).fetchall()
|
|
108
343
|
|
|
109
|
-
def tokenize(text: str) -> set:
|
|
344
|
+
def tokenize(text: str) -> set[str]:
|
|
110
345
|
return {w.lower() for w in text.split() if len(w) > 3}
|
|
111
346
|
|
|
112
347
|
query_tokens = tokenize(description)
|
|
@@ -132,158 +367,215 @@ def find_similar_followups(description: str, threshold: float = 0.3) -> list[dic
|
|
|
132
367
|
|
|
133
368
|
# ── Followups ──────────────────────────────────────────────────────
|
|
134
369
|
|
|
135
|
-
def create_followup(id: str, description: str, date: str = None,
|
|
136
|
-
verification: str = '', status: str = 'PENDING',
|
|
137
|
-
reasoning: str = '', recurrence: str = None) -> dict:
|
|
138
|
-
"""Create a new followup with optional reasoning and recurrence.
|
|
139
370
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
""
|
|
371
|
+
def create_followup(
|
|
372
|
+
id: str,
|
|
373
|
+
description: str,
|
|
374
|
+
date: str = None,
|
|
375
|
+
verification: str = "",
|
|
376
|
+
status: str = "PENDING",
|
|
377
|
+
reasoning: str = "",
|
|
378
|
+
recurrence: str = None,
|
|
379
|
+
) -> dict:
|
|
380
|
+
"""Create a new followup with optional reasoning and recurrence."""
|
|
146
381
|
conn = get_db()
|
|
147
382
|
now = now_epoch()
|
|
148
|
-
|
|
149
|
-
# Anti-duplicate check
|
|
150
383
|
similar = find_similar_followups(description)
|
|
151
384
|
warning = ""
|
|
152
385
|
if similar:
|
|
153
386
|
ids = ", ".join(s["id"] for s in similar[:3])
|
|
154
|
-
warning =
|
|
387
|
+
warning = (
|
|
388
|
+
f" ⚠ SIMILAR FOLLOWUPS EXIST: {ids} "
|
|
389
|
+
f"(scores: {', '.join(str(s['_similarity']) for s in similar[:3])}). Consider updating instead."
|
|
390
|
+
)
|
|
155
391
|
|
|
156
392
|
try:
|
|
157
393
|
conn.execute(
|
|
158
394
|
"INSERT INTO followups (id, date, description, verification, status, reasoning, recurrence, created_at, updated_at) "
|
|
159
395
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
160
|
-
(id, date, description, verification, status, reasoning, recurrence, now, now)
|
|
396
|
+
(id, date, description, verification, status, reasoning, recurrence, now, now),
|
|
161
397
|
)
|
|
162
398
|
conn.commit()
|
|
163
399
|
fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
|
|
164
400
|
except sqlite3.IntegrityError:
|
|
165
401
|
return {"error": f"Followup {id} already exists. Use update instead."}
|
|
402
|
+
|
|
166
403
|
row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
404
|
+
add_item_history(
|
|
405
|
+
"followup",
|
|
406
|
+
id,
|
|
407
|
+
"created",
|
|
408
|
+
note=f"Followup created. Date={date or '—'}. Recurrence={recurrence or '—'}.",
|
|
409
|
+
actor="db",
|
|
410
|
+
)
|
|
167
411
|
result = dict(row)
|
|
168
412
|
if warning:
|
|
169
413
|
result["warning"] = warning
|
|
170
414
|
return result
|
|
171
415
|
|
|
172
416
|
|
|
173
|
-
def update_followup(
|
|
174
|
-
|
|
417
|
+
def update_followup(
|
|
418
|
+
id: str,
|
|
419
|
+
*,
|
|
420
|
+
log_history: bool = True,
|
|
421
|
+
history_event: str = "updated",
|
|
422
|
+
history_actor: str = "db",
|
|
423
|
+
history_note: str = "",
|
|
424
|
+
**kwargs,
|
|
425
|
+
) -> dict:
|
|
426
|
+
"""Update any fields of a followup."""
|
|
175
427
|
conn = get_db()
|
|
176
428
|
row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
177
429
|
if not row:
|
|
178
|
-
|
|
179
|
-
|
|
430
|
+
return {"error": f"Followup {id} not found"}
|
|
431
|
+
|
|
432
|
+
allowed = {"description", "date", "verification", "status", "reasoning", "recurrence", "priority"}
|
|
180
433
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
|
181
434
|
if not updates:
|
|
182
|
-
|
|
435
|
+
return {"error": "No valid fields to update"}
|
|
436
|
+
|
|
183
437
|
updates["updated_at"] = now_epoch()
|
|
184
438
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
185
439
|
values = list(updates.values()) + [id]
|
|
186
440
|
conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", values)
|
|
187
441
|
conn.commit()
|
|
188
|
-
row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
189
|
-
r = dict(row)
|
|
190
|
-
fts_upsert("followup", id, id, f"{r.get('description','')} {r.get('verification','')} {r.get('reasoning','')}", "followup", commit=False)
|
|
191
|
-
return r
|
|
192
442
|
|
|
443
|
+
new_row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
444
|
+
if new_row:
|
|
445
|
+
new_row_dict = dict(new_row)
|
|
446
|
+
fts_upsert(
|
|
447
|
+
"followup",
|
|
448
|
+
id,
|
|
449
|
+
id,
|
|
450
|
+
f"{new_row_dict.get('description','')} {new_row_dict.get('verification','')} {new_row_dict.get('reasoning','')}",
|
|
451
|
+
"followup",
|
|
452
|
+
commit=False,
|
|
453
|
+
)
|
|
454
|
+
if log_history:
|
|
455
|
+
note = history_note or _format_changes(
|
|
456
|
+
row,
|
|
457
|
+
new_row,
|
|
458
|
+
["description", "date", "verification", "status", "reasoning", "recurrence", "priority"],
|
|
459
|
+
)
|
|
460
|
+
add_item_history("followup", id, history_event, note=note or "Followup updated.", actor=history_actor)
|
|
461
|
+
return dict(new_row)
|
|
193
462
|
|
|
194
|
-
def _calc_next_recurrence_date(recurrence: str, current_date: str = None) -> str:
|
|
195
|
-
"""Calculate the next date for a recurring followup.
|
|
196
463
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
monthly:1, monthly:10, monthly:15
|
|
200
|
-
quarterly
|
|
201
|
-
"""
|
|
464
|
+
def _calc_next_recurrence_date(recurrence: str, current_date: str = None) -> str | None:
|
|
465
|
+
"""Calculate the next date for a recurring followup."""
|
|
202
466
|
today = datetime.date.today()
|
|
203
467
|
base = datetime.date.fromisoformat(current_date) if current_date else today
|
|
204
468
|
|
|
205
|
-
if recurrence.startswith(
|
|
206
|
-
day_name = recurrence.split(
|
|
207
|
-
day_map = {
|
|
208
|
-
|
|
469
|
+
if recurrence.startswith("weekly:"):
|
|
470
|
+
day_name = recurrence.split(":")[1].lower()
|
|
471
|
+
day_map = {
|
|
472
|
+
"monday": 0,
|
|
473
|
+
"tuesday": 1,
|
|
474
|
+
"wednesday": 2,
|
|
475
|
+
"thursday": 3,
|
|
476
|
+
"friday": 4,
|
|
477
|
+
"saturday": 5,
|
|
478
|
+
"sunday": 6,
|
|
479
|
+
}
|
|
209
480
|
target_day = day_map.get(day_name, 0)
|
|
210
481
|
days_ahead = (target_day - today.weekday()) % 7
|
|
211
482
|
if days_ahead == 0:
|
|
212
|
-
days_ahead = 7
|
|
483
|
+
days_ahead = 7
|
|
213
484
|
return (today + datetime.timedelta(days=days_ahead)).isoformat()
|
|
214
485
|
|
|
215
|
-
|
|
216
|
-
target_day = int(recurrence.split(
|
|
217
|
-
# Next month from today
|
|
486
|
+
if recurrence.startswith("monthly:"):
|
|
487
|
+
target_day = int(recurrence.split(":")[1])
|
|
218
488
|
if today.month == 12:
|
|
219
|
-
|
|
489
|
+
year, month = today.year + 1, 1
|
|
220
490
|
else:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
491
|
+
year, month = today.year, today.month + 1
|
|
492
|
+
import calendar
|
|
493
|
+
|
|
494
|
+
max_day = calendar.monthrange(year, month)[1]
|
|
495
|
+
return datetime.date(year, month, min(target_day, max_day)).isoformat()
|
|
225
496
|
|
|
226
|
-
|
|
227
|
-
# 3 months from current date
|
|
497
|
+
if recurrence == "quarterly":
|
|
228
498
|
month = base.month + 3
|
|
229
499
|
year = base.year
|
|
230
500
|
if month > 12:
|
|
231
501
|
month -= 12
|
|
232
502
|
year += 1
|
|
233
503
|
import calendar
|
|
504
|
+
|
|
234
505
|
max_day = calendar.monthrange(year, month)[1]
|
|
235
506
|
return datetime.date(year, month, min(base.day, max_day)).isoformat()
|
|
236
507
|
|
|
237
508
|
return None
|
|
238
509
|
|
|
239
510
|
|
|
240
|
-
def complete_followup(id: str, result: str =
|
|
241
|
-
"""Mark a followup as completed
|
|
242
|
-
If the followup has a recurrence pattern, auto-creates the next occurrence."""
|
|
511
|
+
def complete_followup(id: str, result: str = "") -> dict:
|
|
512
|
+
"""Mark a followup as completed. If recurring, archive old row and spawn next."""
|
|
243
513
|
conn = get_db()
|
|
244
514
|
row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
245
515
|
if not row:
|
|
246
516
|
return {"error": f"Followup {id} not found"}
|
|
247
517
|
|
|
248
|
-
today = datetime.date.today().isoformat()
|
|
249
518
|
kwargs = {"status": "COMPLETED"}
|
|
250
519
|
if result:
|
|
251
|
-
existing = row["verification"] or
|
|
520
|
+
existing = row["verification"] or ""
|
|
252
521
|
kwargs["verification"] = f"{existing}\n{result}".strip() if existing else result
|
|
253
522
|
|
|
254
|
-
update_result = update_followup(id, **kwargs)
|
|
523
|
+
update_result = update_followup(id, log_history=False, **kwargs)
|
|
524
|
+
if "error" in update_result:
|
|
525
|
+
return update_result
|
|
526
|
+
add_item_history(
|
|
527
|
+
"followup",
|
|
528
|
+
id,
|
|
529
|
+
"completed",
|
|
530
|
+
note=result or "Followup marked COMPLETED.",
|
|
531
|
+
actor="db",
|
|
532
|
+
)
|
|
255
533
|
|
|
256
|
-
# Auto-regenerate if recurring
|
|
257
534
|
recurrence = row["recurrence"]
|
|
258
535
|
if recurrence:
|
|
536
|
+
today = datetime.date.today().isoformat()
|
|
259
537
|
next_date = _calc_next_recurrence_date(recurrence, row["date"])
|
|
260
538
|
if next_date:
|
|
261
|
-
# Rename completed one to include date suffix, then create fresh one
|
|
262
539
|
archived_id = f"{id}-{today}"
|
|
263
540
|
conn.execute("UPDATE followups SET id = ? WHERE id = ?", (archived_id, id))
|
|
541
|
+
_reassign_item_identity(conn, "followup", id, archived_id)
|
|
264
542
|
conn.commit()
|
|
265
543
|
|
|
266
|
-
# Fix FTS: remove old entry for original ID, add entry for archived ID
|
|
267
544
|
conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (id,))
|
|
268
545
|
archived_row = conn.execute("SELECT * FROM followups WHERE id = ?", (archived_id,)).fetchone()
|
|
269
546
|
if archived_row:
|
|
270
547
|
fts_upsert(
|
|
271
|
-
"followup",
|
|
548
|
+
"followup",
|
|
549
|
+
archived_id,
|
|
550
|
+
archived_id,
|
|
272
551
|
f"{archived_row['description']} {archived_row['verification'] or ''} {archived_row['reasoning'] or ''}",
|
|
273
|
-
"followup",
|
|
552
|
+
"followup",
|
|
553
|
+
commit=False,
|
|
274
554
|
)
|
|
275
555
|
|
|
276
|
-
# create_followup handles its own FTS entry for the new recurring ID
|
|
277
556
|
create_followup(
|
|
278
557
|
id=id,
|
|
279
558
|
description=row["description"],
|
|
280
559
|
date=next_date,
|
|
281
|
-
verification=
|
|
282
|
-
reasoning=row["reasoning"] or
|
|
560
|
+
verification="",
|
|
561
|
+
reasoning=row["reasoning"] or "",
|
|
283
562
|
recurrence=recurrence,
|
|
284
563
|
)
|
|
285
|
-
|
|
286
|
-
|
|
564
|
+
add_item_history(
|
|
565
|
+
"followup",
|
|
566
|
+
archived_id,
|
|
567
|
+
"recurrence_archived",
|
|
568
|
+
note=f"Recurring followup archived as {archived_id}. Next occurrence spawned as {id} for {next_date}.",
|
|
569
|
+
actor="db",
|
|
570
|
+
)
|
|
571
|
+
add_item_history(
|
|
572
|
+
"followup",
|
|
573
|
+
id,
|
|
574
|
+
"recurrence_spawned",
|
|
575
|
+
note=f"Spawned automatically from {archived_id}.",
|
|
576
|
+
actor="db",
|
|
577
|
+
metadata={"source_followup_id": archived_id},
|
|
578
|
+
)
|
|
287
579
|
return {
|
|
288
580
|
"id": archived_id,
|
|
289
581
|
"status": "COMPLETED",
|
|
@@ -296,44 +588,79 @@ def complete_followup(id: str, result: str = '') -> dict:
|
|
|
296
588
|
|
|
297
589
|
|
|
298
590
|
def delete_followup(id: str) -> bool:
|
|
299
|
-
"""
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
591
|
+
"""Soft-delete a followup by setting status to DELETED."""
|
|
592
|
+
result = update_followup(id, status="DELETED", log_history=False)
|
|
593
|
+
if "error" in result:
|
|
594
|
+
return False
|
|
595
|
+
add_item_history("followup", id, "deleted", note="Followup soft-deleted (status=DELETED).", actor="db")
|
|
596
|
+
return True
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def restore_followup(id: str) -> dict:
|
|
600
|
+
"""Restore a followup from DELETED to PENDING."""
|
|
601
|
+
row = get_followup(id)
|
|
602
|
+
if not row:
|
|
603
|
+
return {"error": f"Followup {id} not found"}
|
|
604
|
+
result = update_followup(id, status="PENDING", log_history=False)
|
|
605
|
+
if "error" in result:
|
|
606
|
+
return result
|
|
607
|
+
previous = row.get("status") or "unknown"
|
|
608
|
+
add_item_history("followup", id, "restored", note=f"Followup restored from {previous} to PENDING.", actor="db")
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def add_followup_note(id: str, note: str, actor: str = "nexo") -> dict:
|
|
613
|
+
"""Append an operational note to a followup history."""
|
|
614
|
+
row = get_followup(id)
|
|
615
|
+
if not row:
|
|
616
|
+
return {"error": f"Followup {id} not found"}
|
|
617
|
+
return add_item_history("followup", id, "note", note=note, actor=actor)
|
|
306
618
|
|
|
307
619
|
|
|
308
|
-
def get_followups(filter_type: str =
|
|
309
|
-
"""Get followups by filter:
|
|
620
|
+
def get_followups(filter_type: str = "all") -> list[dict]:
|
|
621
|
+
"""Get followups by filter: active, due, completed, deleted, history."""
|
|
310
622
|
conn = get_db()
|
|
311
623
|
today = datetime.date.today().isoformat()
|
|
312
|
-
if filter_type ==
|
|
624
|
+
if filter_type == "completed":
|
|
313
625
|
rows = conn.execute(
|
|
314
626
|
"SELECT * FROM followups WHERE status LIKE 'COMPLETED%' ORDER BY updated_at DESC"
|
|
315
627
|
).fetchall()
|
|
316
|
-
elif filter_type ==
|
|
628
|
+
elif filter_type == "deleted":
|
|
629
|
+
rows = conn.execute(
|
|
630
|
+
"SELECT * FROM followups WHERE status = 'DELETED' ORDER BY updated_at DESC"
|
|
631
|
+
).fetchall()
|
|
632
|
+
elif filter_type in {"history", "any"}:
|
|
633
|
+
rows = conn.execute(
|
|
634
|
+
"SELECT * FROM followups ORDER BY updated_at DESC"
|
|
635
|
+
).fetchall()
|
|
636
|
+
elif filter_type == "due":
|
|
317
637
|
rows = conn.execute(
|
|
318
|
-
"SELECT * FROM followups WHERE
|
|
319
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting') "
|
|
638
|
+
f"SELECT * FROM followups WHERE {_active_status_where()} "
|
|
320
639
|
"AND date IS NOT NULL AND date <= ? "
|
|
321
640
|
"ORDER BY date ASC",
|
|
322
|
-
(today,)
|
|
641
|
+
(today,),
|
|
323
642
|
).fetchall()
|
|
324
|
-
else:
|
|
643
|
+
else:
|
|
325
644
|
rows = conn.execute(
|
|
326
|
-
"SELECT * FROM followups WHERE
|
|
327
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting') "
|
|
645
|
+
f"SELECT * FROM followups WHERE {_active_status_where()} "
|
|
328
646
|
"ORDER BY date ASC NULLS LAST"
|
|
329
647
|
).fetchall()
|
|
330
648
|
return [dict(r) for r in rows]
|
|
331
649
|
|
|
332
650
|
|
|
333
|
-
def get_followup(id: str) -> dict | None:
|
|
334
|
-
"""Get a single followup by id."""
|
|
651
|
+
def get_followup(id: str, include_history: bool = False) -> dict | None:
|
|
652
|
+
"""Get a single followup by id, optionally with history and read token."""
|
|
335
653
|
conn = get_db()
|
|
336
654
|
row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
|
|
337
|
-
|
|
655
|
+
if not row:
|
|
656
|
+
return None
|
|
657
|
+
result = dict(row)
|
|
658
|
+
if include_history:
|
|
659
|
+
result["history"] = get_item_history("followup", id)
|
|
660
|
+
result["history_rules"] = _history_rules("followup")
|
|
661
|
+
result["read_token"] = _issue_item_read_token("followup", id)
|
|
662
|
+
return result
|
|
338
663
|
|
|
339
664
|
|
|
665
|
+
def get_followup_history(id: str, limit: int = 20) -> list[dict]:
|
|
666
|
+
return get_item_history("followup", id, limit=limit)
|