nexo-brain 3.1.2 → 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.
@@ -1,14 +1,182 @@
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 _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
- def create_reminder(id: str, description: str, date: str = None,
11
- status: str = 'PENDING', category: str = 'general') -> dict:
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(id: str, **kwargs) -> dict:
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
- return {"error": f"Reminder {id} not found"}
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
- return {"error": "No valid fields to update"}
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
- row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
44
- return dict(row)
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 with today's date."""
49
- today = datetime.date.today().isoformat()
50
- return update_reminder(id, status="COMPLETED")
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
- """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
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 get_reminders(filter_type: str = 'all') -> list[dict]:
63
- """Get reminders by filter: 'all' (active), 'due' (date <= today), 'completed'."""
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 == 'completed':
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 == 'due':
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 status NOT LIKE 'COMPLETED%' "
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: # 'all' — active only
311
+ else:
79
312
  rows = conn.execute(
80
- "SELECT * FROM reminders WHERE status NOT LIKE 'COMPLETED%' "
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
- return dict(row) if row else None
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 find_similar_followups(description: str, threshold: float = 0.3) -> list[dict]:
95
- """Find open followups similar to a description using keyword overlap.
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
- Returns matches sorted by similarity score (highest first).
101
- threshold: minimum overlap ratio (0.0-1.0) to consider a match.
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 status NOT LIKE 'COMPLETED%' "
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
- 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
- """
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 = f" ⚠ SIMILAR FOLLOWUPS EXIST: {ids} (scores: {', '.join(str(s['_similarity']) for s in similar[:3])}). Consider updating instead."
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(id: str, **kwargs) -> dict:
174
- """Update any fields of a followup: description, date, verification, status, reasoning."""
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
- return {"error": f"Followup {id} not found"}
179
- allowed = {"description", "date", "verification", "status", "reasoning", "recurrence"}
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
- return {"error": "No valid fields to update"}
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
- Formats:
198
- weekly:monday, weekly:thursday, weekly:friday, weekly:sunday
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('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}
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 # next week, not today
483
+ days_ahead = 7
213
484
  return (today + datetime.timedelta(days=days_ahead)).isoformat()
214
485
 
215
- elif recurrence.startswith('monthly:'):
216
- target_day = int(recurrence.split(':')[1])
217
- # Next month from today
486
+ if recurrence.startswith("monthly:"):
487
+ target_day = int(recurrence.split(":")[1])
218
488
  if today.month == 12:
219
- next_date = datetime.date(today.year + 1, 1, min(target_day, 28))
489
+ year, month = today.year + 1, 1
220
490
  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()
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
- elif recurrence == 'quarterly':
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 = '') -> 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."""
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", archived_id, archived_id,
548
+ "followup",
549
+ archived_id,
550
+ archived_id,
272
551
  f"{archived_row['description']} {archived_row['verification'] or ''} {archived_row['reasoning'] or ''}",
273
- "followup", commit=False,
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
- # Return accurate result: the completed one is now archived_id, not id
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
- """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
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 = 'all') -> list[dict]:
309
- """Get followups by filter: 'all' (active), 'due' (date <= today), 'completed'."""
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 == 'completed':
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 == 'due':
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 status NOT LIKE 'COMPLETED%' "
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: # 'all' — active only
643
+ else:
325
644
  rows = conn.execute(
326
- "SELECT * FROM followups WHERE status NOT LIKE 'COMPLETED%' "
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
- return dict(row) if row else None
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)