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.
package/src/db/_schema.py CHANGED
@@ -674,6 +674,35 @@ def _m28_automation_runs(conn):
674
674
  _migrate_add_index(conn, "idx_automation_runs_status", "automation_runs", "status")
675
675
 
676
676
 
677
+ def _m29_item_history_and_soft_delete(conn):
678
+ """Persist reminder/followup history and read-before-mutate tokens."""
679
+ conn.execute("""
680
+ CREATE TABLE IF NOT EXISTS item_history (
681
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
682
+ item_type TEXT NOT NULL,
683
+ item_id TEXT NOT NULL,
684
+ event_type TEXT NOT NULL,
685
+ note TEXT DEFAULT '',
686
+ actor TEXT DEFAULT '',
687
+ metadata TEXT DEFAULT '{}',
688
+ created_at REAL NOT NULL
689
+ )
690
+ """)
691
+ conn.execute("""
692
+ CREATE TABLE IF NOT EXISTS item_read_tokens (
693
+ token TEXT PRIMARY KEY,
694
+ item_type TEXT NOT NULL,
695
+ item_id TEXT NOT NULL,
696
+ history_seq INTEGER DEFAULT 0,
697
+ issued_at REAL NOT NULL,
698
+ expires_at REAL NOT NULL
699
+ )
700
+ """)
701
+ _migrate_add_index(conn, "idx_item_history_lookup", "item_history", "item_type, item_id, created_at")
702
+ _migrate_add_index(conn, "idx_item_history_item", "item_history", "item_id")
703
+ _migrate_add_index(conn, "idx_item_read_tokens_lookup", "item_read_tokens", "item_type, item_id, expires_at")
704
+
705
+
677
706
  MIGRATIONS = [
678
707
  (1, "learnings_columns", _m1_learnings_columns),
679
708
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -703,6 +732,7 @@ MIGRATIONS = [
703
732
  (26, "protocol_answer_confidence", _m26_protocol_answer_confidence),
704
733
  (27, "state_watchers", _m27_state_watchers),
705
734
  (28, "automation_runs", _m28_automation_runs),
735
+ (29, "item_history_and_soft_delete", _m29_item_history_and_soft_delete),
706
736
  ]
707
737
 
708
738
 
package/src/server.py CHANGED
@@ -24,10 +24,10 @@ from tools_coordination import (
24
24
  from tools_reminders import handle_reminders
25
25
  from tools_menu import handle_menu
26
26
  from tools_reminders_crud import (
27
- handle_reminder_create, handle_reminder_update,
28
- handle_reminder_complete, handle_reminder_delete,
29
- handle_followup_create, handle_followup_update,
30
- handle_followup_complete, handle_followup_delete,
27
+ handle_reminder_create, handle_reminder_get, handle_reminder_update,
28
+ handle_reminder_complete, handle_reminder_note, handle_reminder_restore, handle_reminder_delete,
29
+ handle_followup_create, handle_followup_get, handle_followup_update,
30
+ handle_followup_complete, handle_followup_note, handle_followup_restore, handle_followup_delete,
31
31
  )
32
32
  from tools_learnings import (
33
33
  handle_learning_add, handle_learning_search,
@@ -191,6 +191,8 @@ mcp = FastMCP(
191
191
  "React: DIARY REMINDER→write diary, VIBE:NEGATIVE→ultra-concise, AUTO-PRIME→read learnings\n"
192
192
  "- **Followups:** NEXO tasks, execute silently. 'done'/'all set'→`nexo_followup_complete` NOW. "
193
193
  "Reminders=user's, alert when due\n"
194
+ "- **Reminder/followup history:** before update/delete/restore/note, call the corresponding "
195
+ "`nexo_reminder_get` / `nexo_followup_get` first and use its `READ_TOKEN`.\n"
194
196
  "- **Observe:** correction→learning. 'tomorrow'→followup. person→entity. open topic→followup 3d\n"
195
197
  "- **Trust events:** When user expresses satisfaction/thanks (any language)→`nexo_cognitive_trust(event='explicit_thanks')`. "
196
198
  "When user corrects you→`nexo_cognitive_trust(event='correction')`. "
@@ -488,7 +490,7 @@ def nexo_reminders(filter: str = "due") -> str:
488
490
  """Check reminders and followups.
489
491
 
490
492
  Args:
491
- filter: 'due' (vencidos/hoy), 'all' (todos activos), 'followups' (solo NEXO followups)
493
+ filter: 'due', 'all', 'followups', 'completed', 'deleted', 'history', or 'any'
492
494
  """
493
495
  return handle_reminders(filter)
494
496
 
@@ -503,7 +505,7 @@ def nexo_menu() -> str:
503
505
  return handle_menu()
504
506
 
505
507
 
506
- # ── Reminders CRUD (4 tools) ──────────────────────────────────────
508
+ # ── Reminders CRUD (7 tools) ──────────────────────────────────────
507
509
 
508
510
  @mcp.tool
509
511
  def nexo_reminder_create(id: str, description: str, date: str = "", category: str = "general") -> str:
@@ -519,17 +521,36 @@ def nexo_reminder_create(id: str, description: str, date: str = "", category: st
519
521
 
520
522
 
521
523
  @mcp.tool
522
- def nexo_reminder_update(id: str, description: str = "", date: str = "", status: str = "", category: str = "") -> str:
524
+ def nexo_reminder_get(id: str) -> str:
525
+ """Read a reminder with its history and usage rules.
526
+
527
+ IMPORTANT: before update/delete/restore/note, call this tool first and use the returned READ_TOKEN.
528
+ """
529
+ return handle_reminder_get(id)
530
+
531
+
532
+ @mcp.tool
533
+ def nexo_reminder_update(
534
+ id: str,
535
+ description: str = "",
536
+ date: str = "",
537
+ status: str = "",
538
+ category: str = "",
539
+ read_token: str = "",
540
+ ) -> str:
523
541
  """Update fields of an existing reminder. Only non-empty fields are changed.
524
542
 
543
+ IMPORTANT: call `nexo_reminder_get` first and pass its READ_TOKEN.
544
+
525
545
  Args:
526
546
  id: Reminder ID (e.g., R87).
527
547
  description: New description (optional).
528
548
  date: New date YYYY-MM-DD (optional).
529
549
  status: New status (optional).
530
550
  category: New category (optional).
551
+ read_token: Token returned by `nexo_reminder_get`.
531
552
  """
532
- return handle_reminder_update(id, description, date, status, category)
553
+ return handle_reminder_update(id, description, date, status, category, read_token)
533
554
 
534
555
 
535
556
  @mcp.tool
@@ -543,16 +564,47 @@ def nexo_reminder_complete(id: str) -> str:
543
564
 
544
565
 
545
566
  @mcp.tool
546
- def nexo_reminder_delete(id: str) -> str:
547
- """Delete a reminder permanently.
567
+ def nexo_reminder_note(id: str, note: str, read_token: str = "", actor: str = "nexo") -> str:
568
+ """Append a note to reminder history.
569
+
570
+ IMPORTANT: call `nexo_reminder_get` first and pass its READ_TOKEN.
571
+
572
+ Args:
573
+ id: Reminder ID (e.g., R87).
574
+ note: Operational note to append to history.
575
+ read_token: Token returned by `nexo_reminder_get`.
576
+ actor: Actor label for the history note.
577
+ """
578
+ return handle_reminder_note(id, note, read_token, actor)
579
+
580
+
581
+ @mcp.tool
582
+ def nexo_reminder_restore(id: str, read_token: str = "") -> str:
583
+ """Restore a soft-deleted reminder back to PENDING.
584
+
585
+ IMPORTANT: call `nexo_reminder_get` first and pass its READ_TOKEN.
586
+
587
+ Args:
588
+ id: Reminder ID (e.g., R87).
589
+ read_token: Token returned by `nexo_reminder_get`.
590
+ """
591
+ return handle_reminder_restore(id, read_token)
592
+
593
+
594
+ @mcp.tool
595
+ def nexo_reminder_delete(id: str, read_token: str = "") -> str:
596
+ """Soft-delete a reminder.
597
+
598
+ IMPORTANT: call `nexo_reminder_get` first and pass its READ_TOKEN.
548
599
 
549
600
  Args:
550
601
  id: Reminder ID (e.g., R87).
602
+ read_token: Token returned by `nexo_reminder_get`.
551
603
  """
552
- return handle_reminder_delete(id)
604
+ return handle_reminder_delete(id, read_token)
553
605
 
554
606
 
555
- # ── Followups CRUD (4 tools) ──────────────────────────────────────
607
+ # ── Followups CRUD (7 tools) ──────────────────────────────────────
556
608
 
557
609
  @mcp.tool
558
610
  def nexo_followup_create(id: str, description: str, date: str = "", verification: str = "", reasoning: str = "", recurrence: str = "", priority: str = "medium") -> str:
@@ -577,9 +629,28 @@ def nexo_followup_create(id: str, description: str, date: str = "", verification
577
629
 
578
630
 
579
631
  @mcp.tool
580
- def nexo_followup_update(id: str, description: str = "", date: str = "", verification: str = "", status: str = "", priority: str = "") -> str:
632
+ def nexo_followup_get(id: str) -> str:
633
+ """Read a followup with its history and usage rules.
634
+
635
+ IMPORTANT: before update/delete/restore/note, call this tool first and use the returned READ_TOKEN.
636
+ """
637
+ return handle_followup_get(id)
638
+
639
+
640
+ @mcp.tool
641
+ def nexo_followup_update(
642
+ id: str,
643
+ description: str = "",
644
+ date: str = "",
645
+ verification: str = "",
646
+ status: str = "",
647
+ priority: str = "",
648
+ read_token: str = "",
649
+ ) -> str:
581
650
  """Update fields of an existing followup. Only non-empty fields are changed.
582
651
 
652
+ IMPORTANT: call `nexo_followup_get` first and pass its READ_TOKEN.
653
+
583
654
  Args:
584
655
  id: Followup ID (e.g., NF45).
585
656
  description: New description (optional).
@@ -587,13 +658,9 @@ def nexo_followup_update(id: str, description: str = "", date: str = "", verific
587
658
  verification: New verification text (optional).
588
659
  status: New status (optional).
589
660
  priority: critical, high, medium, low (optional).
661
+ read_token: Token returned by `nexo_followup_get`.
590
662
  """
591
- result = handle_followup_update(id, description, date, verification, status)
592
- if priority in ('critical', 'high', 'medium', 'low'):
593
- from db import get_db
594
- get_db().execute("UPDATE followups SET priority = ? WHERE id = ?", (priority, id))
595
- get_db().commit()
596
- return result
663
+ return handle_followup_update(id, description, date, verification, status, priority, read_token)
597
664
 
598
665
 
599
666
  @mcp.tool
@@ -608,13 +675,44 @@ def nexo_followup_complete(id: str, result: str = "") -> str:
608
675
 
609
676
 
610
677
  @mcp.tool
611
- def nexo_followup_delete(id: str) -> str:
612
- """Delete a followup permanently.
678
+ def nexo_followup_note(id: str, note: str, read_token: str = "", actor: str = "nexo") -> str:
679
+ """Append a note to followup history.
680
+
681
+ IMPORTANT: call `nexo_followup_get` first and pass its READ_TOKEN.
682
+
683
+ Args:
684
+ id: Followup ID (e.g., NF45).
685
+ note: Operational note to append to history.
686
+ read_token: Token returned by `nexo_followup_get`.
687
+ actor: Actor label for the history note.
688
+ """
689
+ return handle_followup_note(id, note, read_token, actor)
690
+
691
+
692
+ @mcp.tool
693
+ def nexo_followup_restore(id: str, read_token: str = "") -> str:
694
+ """Restore a soft-deleted followup back to PENDING.
695
+
696
+ IMPORTANT: call `nexo_followup_get` first and pass its READ_TOKEN.
697
+
698
+ Args:
699
+ id: Followup ID (e.g., NF45).
700
+ read_token: Token returned by `nexo_followup_get`.
701
+ """
702
+ return handle_followup_restore(id, read_token)
703
+
704
+
705
+ @mcp.tool
706
+ def nexo_followup_delete(id: str, read_token: str = "") -> str:
707
+ """Soft-delete a followup.
708
+
709
+ IMPORTANT: call `nexo_followup_get` first and pass its READ_TOKEN.
613
710
 
614
711
  Args:
615
712
  id: Followup ID (e.g., NF45).
713
+ read_token: Token returned by `nexo_followup_get`.
616
714
  """
617
- return handle_followup_delete(id)
715
+ return handle_followup_delete(id, read_token)
618
716
 
619
717
 
620
718
  # ── Learnings CRUD (5 tools) ──────────────────────────────────────
@@ -19,16 +19,16 @@ def handle_reminders(filter_type: str = "due") -> str:
19
19
  """Read reminders and followups from SQLite, return relevant ones.
20
20
 
21
21
  Args:
22
- filter_type: 'due' (vencidos/hoy), 'all' (todos activos), 'followups' (solo followups)
22
+ filter_type: 'due', 'all', 'followups', 'completed', 'deleted', 'history', 'any'
23
23
  """
24
24
  parts = []
25
25
 
26
- if filter_type in ("due", "all"):
26
+ if filter_type in ("due", "all", "completed", "deleted", "history", "any"):
27
27
  r = _format_reminders(filter_type)
28
28
  if r:
29
29
  parts.append(r)
30
30
 
31
- if filter_type in ("due", "all", "followups"):
31
+ if filter_type in ("due", "all", "followups", "completed", "deleted", "history", "any"):
32
32
  f = _format_followups(filter_type)
33
33
  if f:
34
34
  parts.append(f)
@@ -52,7 +52,8 @@ def _format_reminders(filter_type: str) -> str:
52
52
  desc = desc.replace("**", "")
53
53
  due_marker = " [DUE]" if _is_due(fecha) else ""
54
54
  fecha_display = f"({fecha})" if fecha else "(—)"
55
- lines.append(f" {rid} {fecha_display}{due_marker} {desc[:120]}")
55
+ status_tag = f" [{status}]" if status and status != "PENDING" else ""
56
+ lines.append(f" {rid} {fecha_display}{due_marker}{status_tag} — {desc[:120]}")
56
57
  if "RECURRENTE" in status.upper():
57
58
  lines.append(f" Status: {status}")
58
59
 
@@ -81,6 +82,8 @@ def _format_followups(filter_type: str) -> str:
81
82
  pri = r.get("priority") or "medium"
82
83
  pri_icon = {"critical": "🔴", "high": "🟠", "medium": "", "low": "⚪"}.get(pri, "")
83
84
  pri_tag = f" {pri_icon}" if pri_icon else ""
84
- lines.append(f" {nfid} {fecha_display}{due_marker}{pri_tag}{rec_tag} — {desc[:120]}")
85
+ status = r.get("status") or ""
86
+ status_tag = f" [{status}]" if status and status != "PENDING" else ""
87
+ lines.append(f" {nfid} {fecha_display}{due_marker}{pri_tag}{rec_tag}{status_tag} — {desc[:120]}")
85
88
 
86
89
  return "\n".join(lines)
@@ -2,13 +2,77 @@
2
2
 
3
3
  from db import (
4
4
  create_reminder, update_reminder, complete_reminder, delete_reminder,
5
- get_reminders, get_reminder,
5
+ restore_reminder, add_reminder_note, get_reminder,
6
6
  create_followup, update_followup, complete_followup, delete_followup,
7
- get_followups, get_followup,
7
+ restore_followup, add_followup_note, get_followup,
8
+ validate_item_read_token,
8
9
  find_decisions_by_context_ref, update_decision_outcome,
9
10
  )
10
11
 
11
12
 
13
+ def _require_item_read(item_type: str, item_id: str, read_token: str) -> str | None:
14
+ ok, message = validate_item_read_token(read_token, item_type, item_id)
15
+ if ok:
16
+ return None
17
+ prefix = "followup" if item_type == "followup" else "reminder"
18
+ return f"ERROR: {message} Use nexo_{prefix}_get(id='{item_id}') first."
19
+
20
+
21
+ def _history_lines(history: list[dict]) -> list[str]:
22
+ if not history:
23
+ return ["- (no history)"]
24
+ lines: list[str] = []
25
+ for event in history:
26
+ created_at = event.get("created_at") or "?"
27
+ event_type = event.get("event_type") or "event"
28
+ actor = event.get("actor") or "system"
29
+ note = (event.get("note") or "").strip()
30
+ suffix = f" — {note}" if note else ""
31
+ lines.append(f"- {created_at} [{event_type}] ({actor}){suffix}")
32
+ return lines
33
+
34
+
35
+ def _format_reminder_payload(reminder: dict) -> str:
36
+ lines = [
37
+ f"REMINDER {reminder['id']}",
38
+ f"Description: {reminder.get('description') or ''}",
39
+ f"Date: {reminder.get('date') or '—'}",
40
+ f"Status: {reminder.get('status') or '—'}",
41
+ f"Category: {reminder.get('category') or 'general'}",
42
+ ]
43
+ history_rules = reminder.get("history_rules") or []
44
+ if history_rules:
45
+ lines.append("Usage rules:")
46
+ lines.extend(f"- {rule}" for rule in history_rules)
47
+ lines.append("History:")
48
+ lines.extend(_history_lines(reminder.get("history") or []))
49
+ if reminder.get("read_token"):
50
+ lines.append(f"READ_TOKEN: {reminder['read_token']}")
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _format_followup_payload(followup: dict) -> str:
55
+ lines = [
56
+ f"FOLLOWUP {followup['id']}",
57
+ f"Description: {followup.get('description') or ''}",
58
+ f"Date: {followup.get('date') or '—'}",
59
+ f"Status: {followup.get('status') or '—'}",
60
+ f"Verification: {followup.get('verification') or '—'}",
61
+ f"Reasoning: {followup.get('reasoning') or '—'}",
62
+ f"Recurrence: {followup.get('recurrence') or '—'}",
63
+ f"Priority: {followup.get('priority') or 'medium'}",
64
+ ]
65
+ history_rules = followup.get("history_rules") or []
66
+ if history_rules:
67
+ lines.append("Usage rules:")
68
+ lines.extend(f"- {rule}" for rule in history_rules)
69
+ lines.append("History:")
70
+ lines.extend(_history_lines(followup.get("history") or []))
71
+ if followup.get("read_token"):
72
+ lines.append(f"READ_TOKEN: {followup['read_token']}")
73
+ return "\n".join(lines)
74
+
75
+
12
76
  # ── Reminders ──────────────────────────────────────────────────────────────────
13
77
 
14
78
  def handle_reminder_create(id: str, description: str, date: str = '', category: str = 'general') -> str:
@@ -25,8 +89,27 @@ def handle_reminder_create(id: str, description: str, date: str = '', category:
25
89
  return f"Reminder created. Date: {date_str}. Category: {category}."
26
90
 
27
91
 
28
- def handle_reminder_update(id: str, description: str = '', date: str = '', status: str = '', category: str = '') -> str:
92
+ def handle_reminder_get(id: str) -> str:
93
+ """Read a reminder with history and return a read token for safe mutations."""
94
+ result = get_reminder(id=id, include_history=True)
95
+ if not result:
96
+ return f"ERROR: Reminder {id} not found."
97
+ return _format_reminder_payload(result)
98
+
99
+
100
+ def handle_reminder_update(
101
+ id: str,
102
+ description: str = '',
103
+ date: str = '',
104
+ status: str = '',
105
+ category: str = '',
106
+ read_token: str = '',
107
+ ) -> str:
29
108
  """Update one or more fields of an existing reminder."""
109
+ error = _require_item_read("reminder", id, read_token)
110
+ if error:
111
+ return error
112
+
30
113
  fields: dict = {}
31
114
  if description:
32
115
  fields['description'] = description
@@ -41,8 +124,9 @@ def handle_reminder_update(id: str, description: str = '', date: str = '', statu
41
124
  return f"ERROR: No fields specified to update for {id}."
42
125
 
43
126
  result = update_reminder(id=id, **fields)
44
- if not result:
45
- return f"ERROR: Reminder {id} not found."
127
+ if not result or "error" in result:
128
+ error_msg = result.get("error", f"Reminder {id} not found.") if isinstance(result, dict) else f"Reminder {id} not found."
129
+ return f"ERROR: {error_msg}"
46
130
 
47
131
  changed = ', '.join(fields.keys())
48
132
  return f"Reminder {id} updated: {changed}."
@@ -57,13 +141,42 @@ def handle_reminder_complete(id: str) -> str:
57
141
  return f"Reminder {id} marked COMPLETED."
58
142
 
59
143
 
60
- def handle_reminder_delete(id: str) -> str:
61
- """Delete a reminder permanently."""
144
+ def handle_reminder_note(id: str, note: str, read_token: str = '', actor: str = 'nexo') -> str:
145
+ """Append a note to reminder history."""
146
+ if not note.strip():
147
+ return "ERROR: note is required."
148
+ error = _require_item_read("reminder", id, read_token)
149
+ if error:
150
+ return error
151
+ result = add_reminder_note(id=id, note=note.strip(), actor=actor or "nexo")
152
+ if not result or "error" in result:
153
+ error_msg = result.get("error", f"Reminder {id} not found.") if isinstance(result, dict) else f"Reminder {id} not found."
154
+ return f"ERROR: {error_msg}"
155
+ return f"Reminder {id} note added."
156
+
157
+
158
+ def handle_reminder_restore(id: str, read_token: str = '') -> str:
159
+ """Restore a soft-deleted reminder."""
160
+ error = _require_item_read("reminder", id, read_token)
161
+ if error:
162
+ return error
163
+ result = restore_reminder(id=id)
164
+ if not result or "error" in result:
165
+ error_msg = result.get("error", f"Reminder {id} not found.") if isinstance(result, dict) else f"Reminder {id} not found."
166
+ return f"ERROR: {error_msg}"
167
+ return f"Reminder {id} restored to PENDING."
168
+
169
+
170
+ def handle_reminder_delete(id: str, read_token: str = '') -> str:
171
+ """Soft-delete a reminder."""
172
+ error = _require_item_read("reminder", id, read_token)
173
+ if error:
174
+ return error
62
175
  result = delete_reminder(id=id)
63
176
  if not result:
64
177
  return f"ERROR: Reminder {id} not found."
65
178
 
66
- return f"Reminder {id} deleted."
179
+ return f"Reminder {id} soft-deleted."
67
180
 
68
181
 
69
182
  # ── Followups ──────────────────────────────────────────────────────────────────
@@ -95,8 +208,28 @@ def handle_followup_create(id: str, description: str, date: str = '', verificati
95
208
  return f"Followup created. Date: {date_str}.{rec_str}{warn_str}"
96
209
 
97
210
 
98
- def handle_followup_update(id: str, description: str = '', date: str = '', verification: str = '', status: str = '') -> str:
211
+ def handle_followup_get(id: str) -> str:
212
+ """Read a followup with history and return a read token for safe mutations."""
213
+ result = get_followup(id=id, include_history=True)
214
+ if not result:
215
+ return f"ERROR: Followup {id} not found."
216
+ return _format_followup_payload(result)
217
+
218
+
219
+ def handle_followup_update(
220
+ id: str,
221
+ description: str = '',
222
+ date: str = '',
223
+ verification: str = '',
224
+ status: str = '',
225
+ priority: str = '',
226
+ read_token: str = '',
227
+ ) -> str:
99
228
  """Update one or more fields of an existing followup."""
229
+ error = _require_item_read("followup", id, read_token)
230
+ if error:
231
+ return error
232
+
100
233
  fields: dict = {}
101
234
  if description:
102
235
  fields['description'] = description
@@ -106,16 +239,19 @@ def handle_followup_update(id: str, description: str = '', date: str = '', verif
106
239
  fields['verification'] = verification
107
240
  if status:
108
241
  fields['status'] = status
242
+ if priority:
243
+ fields['priority'] = priority
109
244
 
110
245
  if not fields:
111
246
  return f"ERROR: No fields specified to update for {id}."
112
247
 
113
248
  result = update_followup(id=id, **fields)
114
- if not result:
115
- return f"ERROR: Followup {id} not found."
249
+ if not result or "error" in result:
250
+ error_msg = result.get("error", f"Followup {id} not found.") if isinstance(result, dict) else f"Followup {id} not found."
251
+ return f"ERROR: {error_msg}"
116
252
 
117
253
  changed = ', '.join(fields.keys())
118
- return f"Followup updated: {changed}."
254
+ return f"Followup {id} updated: {changed}."
119
255
 
120
256
 
121
257
  def handle_followup_complete(id: str, result: str = '') -> str:
@@ -157,10 +293,39 @@ def handle_followup_complete(id: str, result: str = '') -> str:
157
293
  return msg
158
294
 
159
295
 
160
- def handle_followup_delete(id: str) -> str:
161
- """Delete a followup permanently."""
296
+ def handle_followup_note(id: str, note: str, read_token: str = '', actor: str = 'nexo') -> str:
297
+ """Append a note to followup history."""
298
+ if not note.strip():
299
+ return "ERROR: note is required."
300
+ error = _require_item_read("followup", id, read_token)
301
+ if error:
302
+ return error
303
+ result = add_followup_note(id=id, note=note.strip(), actor=actor or "nexo")
304
+ if not result or "error" in result:
305
+ error_msg = result.get("error", f"Followup {id} not found.") if isinstance(result, dict) else f"Followup {id} not found."
306
+ return f"ERROR: {error_msg}"
307
+ return f"Followup {id} note added."
308
+
309
+
310
+ def handle_followup_restore(id: str, read_token: str = '') -> str:
311
+ """Restore a soft-deleted followup."""
312
+ error = _require_item_read("followup", id, read_token)
313
+ if error:
314
+ return error
315
+ result = restore_followup(id=id)
316
+ if not result or "error" in result:
317
+ error_msg = result.get("error", f"Followup {id} not found.") if isinstance(result, dict) else f"Followup {id} not found."
318
+ return f"ERROR: {error_msg}"
319
+ return f"Followup {id} restored to PENDING."
320
+
321
+
322
+ def handle_followup_delete(id: str, read_token: str = '') -> str:
323
+ """Soft-delete a followup."""
324
+ error = _require_item_read("followup", id, read_token)
325
+ if error:
326
+ return error
162
327
  result = delete_followup(id=id)
163
328
  if not result:
164
329
  return f"ERROR: Followup {id} not found."
165
330
 
166
- return f"Followup deleted."
331
+ return f"Followup {id} soft-deleted."