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.
@@ -1,14 +1,205 @@
1
1
  from __future__ import annotations
2
- """NEXO DB — Reminders module."""
3
- import sqlite3, time, datetime
4
- from datetime import timedelta
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
- def create_reminder(id: str, description: str, date: str = None,
11
- status: str = 'PENDING', category: str = 'general') -> dict:
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(id: str, **kwargs) -> dict:
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
- return {"error": f"Reminder {id} not found"}
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
- return {"error": "No valid fields to update"}
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
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
44
- return dict(row)
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 with today's date."""
49
- today = datetime.date.today().isoformat()
50
- return update_reminder(id, status="COMPLETED")
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
- """Delete a reminder."""
55
- conn = get_db()
56
- result = conn.execute("DELETE FROM reminders WHERE id = ?", (id,))
57
- conn.commit()
58
- deleted = result.rowcount > 0
59
- return deleted
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 = 'all') -> list[dict]:
63
- """Get reminders by filter: 'all' (active), 'due' (date <= today), 'completed'."""
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 == 'completed':
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 == 'due':
319
+ elif filter_type == "deleted":
71
320
  rows = conn.execute(
72
- "SELECT * FROM reminders WHERE status NOT LIKE 'COMPLETED%' "
73
- "AND status NOT IN ('DELETED','archived','blocked','waiting') "
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: # 'all' — active only
334
+ else:
79
335
  rows = conn.execute(
80
- "SELECT * FROM reminders WHERE status NOT LIKE 'COMPLETED%' "
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
- return dict(row) if row else None
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 find_similar_followups(description: str, threshold: float = 0.3) -> list[dict]:
95
- """Find open followups similar to a description using keyword overlap.
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
- Returns matches sorted by similarity score (highest first).
101
- threshold: minimum overlap ratio (0.0-1.0) to consider a match.
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 status NOT LIKE 'COMPLETED%' "
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
- Checks for similar open followups before creating. If a match is found,
141
- returns a warning with the existing followup ID (still creates the new one).
142
-
143
- recurrence format: 'weekly:monday', 'monthly:1', 'monthly:10', 'quarterly', etc.
144
- When a recurring followup is completed, a new one is auto-created with the next date.
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 = f" ⚠ SIMILAR FOLLOWUPS EXIST: {ids} (scores: {', '.join(str(s['_similarity']) for s in similar[:3])}). Consider updating instead."
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 (id, date, description, verification, status, reasoning, recurrence, created_at, updated_at) "
159
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
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
- fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
440
+ if _table_exists(conn, "unified_search"):
441
+ fts_upsert("followup", id, id, f"{description} {verification} {reasoning}", "followup", commit=False)
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(id: str, **kwargs) -> dict:
174
- """Update any fields of a followup: description, date, verification, status, reasoning."""
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
- return {"error": f"Followup {id} not found"}
179
- allowed = {"description", "date", "verification", "status", "reasoning", "recurrence"}
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
- return {"error": "No valid fields to update"}
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
- Formats:
198
- weekly:monday, weekly:thursday, weekly:friday, weekly:sunday
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('weekly:'):
206
- day_name = recurrence.split(':')[1].lower()
207
- day_map = {'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
208
- 'friday': 4, 'saturday': 5, 'sunday': 6}
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 # next week, not today
525
+ days_ahead = 7
213
526
  return (today + datetime.timedelta(days=days_ahead)).isoformat()
214
527
 
215
- elif recurrence.startswith('monthly:'):
216
- target_day = int(recurrence.split(':')[1])
217
- # Next month from today
528
+ if recurrence.startswith("monthly:"):
529
+ target_day = int(recurrence.split(":")[1])
218
530
  if today.month == 12:
219
- next_date = datetime.date(today.year + 1, 1, min(target_day, 28))
531
+ year, month = today.year + 1, 1
220
532
  else:
221
- import calendar
222
- max_day = calendar.monthrange(today.year, today.month + 1)[1]
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
- elif recurrence == 'quarterly':
227
- # 3 months from current date
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 = '') -> dict:
241
- """Mark a followup as completed with today's date and optional result.
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
- # Fix FTS: remove old entry for original ID, add entry for archived ID
267
- conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (id,))
586
+ if _table_exists(conn, "unified_search"):
587
+ conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (id,))
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", archived_id, archived_id,
591
+ "followup",
592
+ archived_id,
593
+ archived_id,
272
594
  f"{archived_row['description']} {archived_row['verification'] or ''} {archived_row['reasoning'] or ''}",
273
- "followup", commit=False,
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
- # Return accurate result: the completed one is now archived_id, not id
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
- """Delete a followup."""
300
- conn = get_db()
301
- result = conn.execute("DELETE FROM followups WHERE id = ?", (id,))
302
- conn.execute("DELETE FROM unified_search WHERE source = 'followup' AND source_id = ?", (str(id),))
303
- conn.commit()
304
- deleted = result.rowcount > 0
305
- return deleted
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
- def get_followups(filter_type: str = 'all') -> list[dict]:
309
- """Get followups by filter: 'all' (active), 'due' (date <= today), 'completed'."""
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 == 'completed':
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 == 'due':
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 status NOT LIKE 'COMPLETED%' "
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: # 'all' — active only
686
+ else:
325
687
  rows = conn.execute(
326
- "SELECT * FROM followups WHERE status NOT LIKE 'COMPLETED%' "
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
- return dict(row) if row else None
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)