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