nexo-brain 5.7.0 → 5.8.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `5.7.0` is the current packaged-runtime line: `nexo update` now keeps Claude Code and Codex CLIs in lockstep with NEXO Brain itself. When the global `@anthropic-ai/claude-code` or `@openai/codex` packages are installed, the updater checks the npm registry and runs `npm install -g <pkg>@latest` in-line — so the terminal boot model stays aligned with the settings NEXO already wrote to `~/.claude/settings.json`. Packages the operator never installed are skipped silently. Pass `nexo update --no-clis` to keep the terminal CLIs pinned.
21
+ Version `5.8.0` is the current packaged-runtime line: first-class `internal` and `owner` columns on `followups` and `reminders`. Migration #40 adds both fields with an idempotent one-shot backfill, so the "who does this task belong to?" classification moves from client-side regex (Desktop) to persistent storage every MCP client shares. Taxonomy is intentionally generic `owner in {user, waiting, agent, shared}` — so third-party agents plugging into the shared Brain can render whatever assistant label they carry without inheriting NEXO branding. `nexo_reminder_create`, `nexo_reminder_update`, `nexo_followup_create`, and `nexo_followup_update` gain optional `internal` and `owner` parameters that win over the default heuristic.
22
+
23
+ Previously in `5.7.0`: `nexo update` now keeps Claude Code and Codex CLIs in lockstep with NEXO Brain itself. When the global `@anthropic-ai/claude-code` or `@openai/codex` packages are installed, the updater checks the npm registry and runs `npm install -g <pkg>@latest` in-line — so the terminal boot model stays aligned with the settings NEXO already wrote to `~/.claude/settings.json`. Packages the operator never installed are skipped silently. Pass `nexo update --no-clis` to keep the terminal CLIs pinned.
22
24
 
23
25
  Previously in `5.6.1`: update-path hardening — 0-byte `.db` orphans from interrupted installs are now purged from `~/.nexo/` and `~/.nexo/data/` before the pre-update backup, and `sync_claude_code_model()` propagates the NEXO-recommended model into `~/.claude/settings.json` whenever `heal_runtime_profiles()` migrates the `claude_code` default.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -0,0 +1,154 @@
1
+ """NEXO DB — Task classification helpers (internal + owner).
2
+
3
+ Introduced in migration #40. Every followup and reminder carries two
4
+ classification attributes so clients (Desktop Home, dashboard, future
5
+ agents) do not need to compute them with client-side regex:
6
+
7
+ internal (INTEGER 0/1):
8
+ 1 if the task is bookkeeping the agent keeps for itself
9
+ (protocol enforcer, deep-sleep housekeeping, audit trail,
10
+ release gates, retroactive learnings). These are hidden from
11
+ normal user views by default.
12
+
13
+ owner (TEXT):
14
+ 'user' — the user has to act (was 'Para ti' in Desktop).
15
+ 'waiting' — blocked on an external response (was 'Esperando').
16
+ 'agent' — the AI agent handles it autonomously. Intentionally
17
+ named 'agent' and NOT 'nexo' so non-NEXO deployments
18
+ render whatever label fits (e.g. 'Claude', 'Codex',
19
+ hotel-assistant name). The user-facing label is
20
+ resolved client-side.
21
+ 'shared' — collaborative follow-up (was 'Seguimiento').
22
+ NULL — unclassified; clients fall back to the legacy
23
+ client-side heuristic for backward compat.
24
+
25
+ Agents creating tasks via nexo_followup_create / nexo_reminder_create
26
+ can override both fields explicitly. If they leave them blank, the
27
+ Brain applies the heuristic below so a vanilla agent keeps sensible
28
+ behaviour out of the box.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ # Task-ID prefixes historically owned by NEXO's own automation. They are
36
+ # kept as a default heuristic because they match the existing corpus of
37
+ # 468+ followups and 40+ reminders. Any agent not following this naming
38
+ # convention will simply not match these patterns and its tasks will
39
+ # stay visible (internal=0) unless the agent sets internal=1 explicitly
40
+ # on create — which is exactly what we want for a pluralistic ecosystem.
41
+ _INTERNAL_ID_PATTERNS = [
42
+ re.compile(r"^NF-PROTOCOL[-_]", re.IGNORECASE),
43
+ re.compile(r"^NF-DS[-_]", re.IGNORECASE),
44
+ re.compile(r"^NF-AUDIT[-_]", re.IGNORECASE),
45
+ re.compile(r"^NF-OPPORTUNITY[-_]", re.IGNORECASE),
46
+ re.compile(r"^NF-RETRO[-_]", re.IGNORECASE),
47
+ re.compile(r"^R-RELEASE[-_]", re.IGNORECASE),
48
+ re.compile(r"^R-FU-NF-PROTOCOL[-_]", re.IGNORECASE),
49
+ re.compile(r"^R-FU-NF-DS[-_]", re.IGNORECASE),
50
+ re.compile(r"^R-FU-NF-AUDIT[-_]", re.IGNORECASE),
51
+ ]
52
+
53
+ # Spanish user-action verbs. The heuristic is Spanish-first because the
54
+ # existing corpus is Spanish, but since every agent can override `owner`
55
+ # explicitly on create, deployments in other languages are not blocked.
56
+ _USER_VERB_RX = re.compile(
57
+ r"\b(francisco debe|debes|llamar|responder|revisar|validar|confirmar|"
58
+ r"decidir|aprobar|firmar|enviar email|mandar email|contestar|"
59
+ r"reuni[óo]n|reservar|comprar)\b",
60
+ re.IGNORECASE,
61
+ )
62
+
63
+ _WAITING_RX = re.compile(
64
+ r"\b(esperando|esperar|bloqueo|bloqueado|pendiente respuesta|"
65
+ r"pendiente de|en espera)\b",
66
+ re.IGNORECASE,
67
+ )
68
+
69
+ _AGENT_RX = re.compile(
70
+ r"\b(monitoreo|monitorizar|monitor|auditor[íi]a diaria|"
71
+ r"promoci[óo]n diaria|seguir|seguimiento 24|72h|checkpoint|runner|cron)\b",
72
+ re.IGNORECASE,
73
+ )
74
+
75
+ VALID_OWNERS = {"user", "waiting", "agent", "shared"}
76
+
77
+
78
+ def is_internal_id(task_id: str | None) -> bool:
79
+ """Return True when the ID matches a known agent-internal prefix."""
80
+ tid = (task_id or "").strip()
81
+ if not tid:
82
+ return False
83
+ return any(pat.search(tid) for pat in _INTERNAL_ID_PATTERNS)
84
+
85
+
86
+ def classify_owner(
87
+ task_id: str | None,
88
+ description: str | None,
89
+ category: str | None = None,
90
+ recurrence: str | None = None,
91
+ ) -> str:
92
+ """Classify ownership into one of VALID_OWNERS using the legacy rules."""
93
+ tid = (task_id or "").strip()
94
+ desc = (description or "").strip()
95
+ cat = (category or "").strip().lower()
96
+ rec = (recurrence or "").strip()
97
+
98
+ if cat == "waiting" or _WAITING_RX.search(desc):
99
+ return "waiting"
100
+ if _USER_VERB_RX.search(desc) or tid.lower().startswith("nf-protocol-"):
101
+ return "user"
102
+ if rec or _AGENT_RX.search(desc):
103
+ return "agent"
104
+ return "shared"
105
+
106
+
107
+ def classify_task(
108
+ task_id: str | None,
109
+ description: str | None,
110
+ category: str | None = None,
111
+ recurrence: str | None = None,
112
+ ) -> tuple[int, str]:
113
+ """Compute (internal, owner) pair for a task.
114
+
115
+ Returns integers for internal so the SQLite column (INTEGER DEFAULT 0)
116
+ and the JSON round-trip stay consistent. Clients can truthy-check either
117
+ int or bool safely.
118
+ """
119
+ internal = 1 if is_internal_id(task_id) else 0
120
+ owner = classify_owner(task_id, description, category, recurrence)
121
+ return internal, owner
122
+
123
+
124
+ def normalise_owner(value: str | None) -> str | None:
125
+ """Accept owner overrides from agents and clamp to VALID_OWNERS.
126
+
127
+ Returns None for empty input (so the DB keeps NULL / pre-existing value)
128
+ and coerces invalid strings to None rather than silently persisting
129
+ garbage. Callers decide whether to fall back to classify_owner().
130
+ """
131
+ if value is None:
132
+ return None
133
+ normalised = str(value).strip().lower()
134
+ if not normalised:
135
+ return None
136
+ return normalised if normalised in VALID_OWNERS else None
137
+
138
+
139
+ def normalise_internal(value) -> int | None:
140
+ """Coerce agent-supplied internal flag into {0, 1} or None."""
141
+ if value is None:
142
+ return None
143
+ if isinstance(value, bool):
144
+ return 1 if value else 0
145
+ if isinstance(value, (int, float)):
146
+ return 1 if int(value) != 0 else 0
147
+ text = str(value).strip().lower()
148
+ if not text:
149
+ return None
150
+ if text in {"1", "true", "yes", "y", "on", "internal"}:
151
+ return 1
152
+ if text in {"0", "false", "no", "n", "off", "external", "public"}:
153
+ return 0
154
+ return None
@@ -8,6 +8,7 @@ import sqlite3
8
8
  from typing import Any
9
9
 
10
10
  from db._core import get_db, now_epoch
11
+ from db._classification import classify_task, normalise_internal, normalise_owner
11
12
  from db._fts import fts_upsert
12
13
  from db._hot_context import capture_context_event
13
14
 
@@ -249,15 +250,47 @@ def create_reminder(
249
250
  date: str = None,
250
251
  status: str = "PENDING",
251
252
  category: str = "general",
253
+ internal: object = None,
254
+ owner: str | None = None,
252
255
  ) -> dict:
253
- """Create a new reminder."""
256
+ """Create a new reminder.
257
+
258
+ Agents may pass `internal` (0/1, bool, or string) and `owner`
259
+ ('user'|'waiting'|'agent'|'shared') to override the default
260
+ classification. When omitted, classify_task() applies the legacy
261
+ heuristic so behaviour matches pre-migration #40.
262
+ """
254
263
  conn = get_db()
255
264
  now = now_epoch()
265
+
266
+ auto_internal, auto_owner = classify_task(id, description, category, None)
267
+ internal_value = normalise_internal(internal)
268
+ if internal_value is None:
269
+ internal_value = auto_internal
270
+ owner_value = normalise_owner(owner) or auto_owner
271
+
272
+ columns = {str(row["name"]) for row in conn.execute("PRAGMA table_info(reminders)").fetchall()}
273
+ payload: dict[str, object] = {
274
+ "id": id,
275
+ "date": date,
276
+ "description": description,
277
+ "status": status,
278
+ "category": category,
279
+ "created_at": now,
280
+ "updated_at": now,
281
+ }
282
+ if "internal" in columns:
283
+ payload["internal"] = internal_value
284
+ if "owner" in columns:
285
+ payload["owner"] = owner_value
286
+
287
+ insert_columns = [c for c in payload if c in columns]
288
+ placeholders = ", ".join("?" for _ in insert_columns)
289
+
256
290
  try:
257
291
  conn.execute(
258
- "INSERT INTO reminders (id, date, description, status, category, created_at, updated_at) "
259
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
260
- (id, date, description, status, category, now, now),
292
+ f"INSERT INTO reminders ({', '.join(insert_columns)}) VALUES ({placeholders})",
293
+ [payload[c] for c in insert_columns],
261
294
  )
262
295
  conn.commit()
263
296
  except sqlite3.IntegrityError:
@@ -268,7 +301,7 @@ def create_reminder(
268
301
  "reminder",
269
302
  id,
270
303
  "created",
271
- note=f"Reminder created. Category={category}. Date={date or '—'}.",
304
+ note=f"Reminder created. Category={category}. Date={date or '—'}. Owner={owner_value}.",
272
305
  actor="db",
273
306
  )
274
307
  capture_context_event(
@@ -285,7 +318,13 @@ def create_reminder(
285
318
  actor="db",
286
319
  source_type="reminder",
287
320
  source_id=id,
288
- metadata={"category": category, "status": status, "date": date or ""},
321
+ metadata={
322
+ "category": category,
323
+ "status": status,
324
+ "date": date or "",
325
+ "internal": internal_value,
326
+ "owner": owner_value,
327
+ },
289
328
  ttl_hours=24,
290
329
  )
291
330
  return dict(row)
@@ -306,11 +345,28 @@ def update_reminder(
306
345
  if not row:
307
346
  return {"error": f"Reminder {id} not found"}
308
347
 
309
- allowed = {"description", "date", "status", "category"}
348
+ allowed = {"description", "date", "status", "category", "internal", "owner"}
310
349
  updates = {k: v for k, v in kwargs.items() if k in allowed}
350
+ if "internal" in updates:
351
+ coerced = normalise_internal(updates["internal"])
352
+ if coerced is None:
353
+ updates.pop("internal")
354
+ else:
355
+ updates["internal"] = coerced
356
+ if "owner" in updates:
357
+ coerced = normalise_owner(updates["owner"])
358
+ if coerced is None:
359
+ updates.pop("owner")
360
+ else:
361
+ updates["owner"] = coerced
311
362
  if not updates:
312
363
  return {"error": "No valid fields to update"}
313
364
 
365
+ table_columns = {
366
+ str(r["name"]) for r in conn.execute("PRAGMA table_info(reminders)").fetchall()
367
+ }
368
+ updates = {k: v for k, v in updates.items() if k in table_columns or k == "updated_at"}
369
+
314
370
  updates["updated_at"] = now_epoch()
315
371
  set_clause = ", ".join(f"{k} = ?" for k in updates)
316
372
  values = list(updates.values()) + [id]
@@ -554,8 +610,15 @@ def create_followup(
554
610
  reasoning: str = "",
555
611
  recurrence: str = None,
556
612
  priority: str = "medium",
613
+ internal: object = None,
614
+ owner: str | None = None,
557
615
  ) -> dict:
558
- """Create a new followup with optional reasoning and recurrence."""
616
+ """Create a new followup with optional reasoning and recurrence.
617
+
618
+ Agents may override the default classification via `internal` and
619
+ `owner`. Omitted values are filled by classify_task() using the
620
+ legacy heuristics so pre-migration callers keep working identically.
621
+ """
559
622
  conn = get_db()
560
623
  now = now_epoch()
561
624
  similar = find_similar_followups(description)
@@ -567,6 +630,12 @@ def create_followup(
567
630
  f"(scores: {', '.join(str(s['_similarity']) for s in similar[:3])}). Consider updating instead."
568
631
  )
569
632
 
633
+ auto_internal, auto_owner = classify_task(id, description, None, recurrence)
634
+ internal_value = normalise_internal(internal)
635
+ if internal_value is None:
636
+ internal_value = auto_internal
637
+ owner_value = normalise_owner(owner) or auto_owner
638
+
570
639
  columns = {str(row["name"]) for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
571
640
  payload: dict[str, object] = {
572
641
  "id": id,
@@ -581,6 +650,10 @@ def create_followup(
581
650
  }
582
651
  if "priority" in columns:
583
652
  payload["priority"] = priority or "medium"
653
+ if "internal" in columns:
654
+ payload["internal"] = internal_value
655
+ if "owner" in columns:
656
+ payload["owner"] = owner_value
584
657
 
585
658
  insert_columns = [column for column in payload if column in columns]
586
659
  placeholders = ", ".join("?" for _ in insert_columns)
@@ -642,11 +715,31 @@ def update_followup(
642
715
  if not row:
643
716
  return {"error": f"Followup {id} not found"}
644
717
 
645
- allowed = {"description", "date", "verification", "status", "reasoning", "recurrence", "priority"}
718
+ allowed = {
719
+ "description", "date", "verification", "status",
720
+ "reasoning", "recurrence", "priority", "internal", "owner",
721
+ }
646
722
  updates = {k: v for k, v in kwargs.items() if k in allowed}
723
+ if "internal" in updates:
724
+ coerced = normalise_internal(updates["internal"])
725
+ if coerced is None:
726
+ updates.pop("internal")
727
+ else:
728
+ updates["internal"] = coerced
729
+ if "owner" in updates:
730
+ coerced = normalise_owner(updates["owner"])
731
+ if coerced is None:
732
+ updates.pop("owner")
733
+ else:
734
+ updates["owner"] = coerced
647
735
  if not updates:
648
736
  return {"error": "No valid fields to update"}
649
737
 
738
+ table_columns = {
739
+ str(r["name"]) for r in conn.execute("PRAGMA table_info(followups)").fetchall()
740
+ }
741
+ updates = {k: v for k, v in updates.items() if k in table_columns or k == "updated_at"}
742
+
650
743
  updates["updated_at"] = now_epoch()
651
744
  set_clause = ", ".join(f"{k} = ?" for k in updates)
652
745
  values = list(updates.values()) + [id]
package/src/db/_schema.py CHANGED
@@ -936,6 +936,69 @@ def _m39_hook_runs(conn):
936
936
  conn.execute("CREATE INDEX IF NOT EXISTS idx_hook_runs_status ON hook_runs(status)")
937
937
 
938
938
 
939
+ def _m40_classification_columns(conn):
940
+ """Add internal (INTEGER 0/1) and owner (TEXT) to followups and reminders.
941
+
942
+ Background: before this migration, Desktop clients had to compute the
943
+ "who does this belong to" classification client-side using Spanish regex
944
+ on description and ID-prefix pattern matching (NF-PROTOCOL-*, NF-DS-*, …).
945
+ That logic was hardcoded to NEXO's own ID convention and Spanish-speaking
946
+ users. Any third-party agent plugging into the shared Brain would either
947
+ see every task as "Seguimiento" (owner=shared fallback) or, worse, have
948
+ its real user-facing tasks hidden by the Desktop 'internal' filter.
949
+
950
+ Fix: make both attributes first-class columns agents can set on create.
951
+ Vanilla agents that omit them get the legacy heuristic (classify_task)
952
+ applied on insert and during this one-shot backfill, so existing rows
953
+ preserve their current Desktop rendering.
954
+
955
+ Values:
956
+ internal: 0 (external, visible) or 1 (agent bookkeeping, hidden).
957
+ owner: 'user' | 'waiting' | 'agent' | 'shared' | NULL.
958
+ 'agent' is deliberately generic — Desktop renders the
959
+ label using the configured assistant name, not a hardcoded
960
+ 'NEXO'.
961
+
962
+ Idempotent: _migrate_add_column is a no-op when the column exists,
963
+ _migrate_add_index likewise. The backfill only touches rows where
964
+ owner IS NULL, so re-running never overwrites agent-set values.
965
+ """
966
+ _migrate_add_column(conn, "followups", "internal", "INTEGER DEFAULT 0")
967
+ _migrate_add_column(conn, "followups", "owner", "TEXT DEFAULT NULL")
968
+ _migrate_add_column(conn, "reminders", "internal", "INTEGER DEFAULT 0")
969
+ _migrate_add_column(conn, "reminders", "owner", "TEXT DEFAULT NULL")
970
+ _migrate_add_index(conn, "idx_followups_internal", "followups", "internal")
971
+ _migrate_add_index(conn, "idx_followups_owner", "followups", "owner")
972
+ _migrate_add_index(conn, "idx_reminders_internal", "reminders", "internal")
973
+ _migrate_add_index(conn, "idx_reminders_owner", "reminders", "owner")
974
+
975
+ from db._classification import classify_task
976
+
977
+ rows = conn.execute(
978
+ "SELECT id, description, recurrence FROM followups WHERE owner IS NULL"
979
+ ).fetchall()
980
+ for row in rows:
981
+ internal, owner = classify_task(
982
+ row["id"], row["description"], None, row["recurrence"]
983
+ )
984
+ conn.execute(
985
+ "UPDATE followups SET internal = ?, owner = ? WHERE id = ?",
986
+ (internal, owner, row["id"]),
987
+ )
988
+
989
+ rows = conn.execute(
990
+ "SELECT id, description, category FROM reminders WHERE owner IS NULL"
991
+ ).fetchall()
992
+ for row in rows:
993
+ internal, owner = classify_task(
994
+ row["id"], row["description"], row["category"], None
995
+ )
996
+ conn.execute(
997
+ "UPDATE reminders SET internal = ?, owner = ? WHERE id = ?",
998
+ (internal, owner, row["id"]),
999
+ )
1000
+
1001
+
939
1002
  MIGRATIONS = [
940
1003
  (1, "learnings_columns", _m1_learnings_columns),
941
1004
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -976,6 +1039,7 @@ MIGRATIONS = [
976
1039
  (37, "cortex_goal_profile_trace", _m37_cortex_goal_profile_trace),
977
1040
  (38, "evolution_log_proposal_payload", _m38_evolution_log_proposal_payload),
978
1041
  (39, "hook_runs", _m39_hook_runs),
1042
+ (40, "classification_columns", _m40_classification_columns),
979
1043
  ]
980
1044
 
981
1045
 
package/src/server.py CHANGED
@@ -680,7 +680,14 @@ def nexo_menu() -> str:
680
680
  # ── Reminders CRUD (7 tools) ──────────────────────────────────────
681
681
 
682
682
  @mcp.tool
683
- def nexo_reminder_create(id: str, description: str, date: str = "", category: str = "general") -> str:
683
+ def nexo_reminder_create(
684
+ id: str,
685
+ description: str,
686
+ date: str = "",
687
+ category: str = "general",
688
+ internal: str = "",
689
+ owner: str = "",
690
+ ) -> str:
684
691
  """Create a new reminder for the user.
685
692
 
686
693
  Args:
@@ -688,8 +695,12 @@ def nexo_reminder_create(id: str, description: str, date: str = "", category: st
688
695
  description: What needs to be done.
689
696
  date: Target date YYYY-MM-DD (optional).
690
697
  category: One of: decisions, tasks, waiting, ideas, general.
698
+ internal: '1'/'true' to mark as agent bookkeeping (hidden from
699
+ default user views). Leave empty to auto-classify.
700
+ owner: 'user' | 'waiting' | 'agent' | 'shared'. Leave empty to
701
+ auto-classify by description heuristic.
691
702
  """
692
- return handle_reminder_create(id, description, date, category)
703
+ return handle_reminder_create(id, description, date, category, internal, owner)
693
704
 
694
705
 
695
706
  @mcp.tool
@@ -708,6 +719,8 @@ def nexo_reminder_update(
708
719
  date: str = "",
709
720
  status: str = "",
710
721
  category: str = "",
722
+ internal: str = "",
723
+ owner: str = "",
711
724
  read_token: str = "",
712
725
  ) -> str:
713
726
  """Update fields of an existing reminder. Only non-empty fields are changed.
@@ -720,9 +733,11 @@ def nexo_reminder_update(
720
733
  date: New date YYYY-MM-DD (optional).
721
734
  status: New status (optional).
722
735
  category: New category (optional).
736
+ internal: '1'/'0' to re-classify visibility (optional).
737
+ owner: New 'user'|'waiting'|'agent'|'shared' (optional).
723
738
  read_token: Token returned by `nexo_reminder_get`.
724
739
  """
725
- return handle_reminder_update(id, description, date, status, category, read_token)
740
+ return handle_reminder_update(id, description, date, status, category, internal, owner, read_token)
726
741
 
727
742
 
728
743
  @mcp.tool
@@ -779,8 +794,18 @@ def nexo_reminder_delete(id: str, read_token: str = "") -> str:
779
794
  # ── Followups CRUD (7 tools) ──────────────────────────────────────
780
795
 
781
796
  @mcp.tool
782
- def nexo_followup_create(id: str, description: str, date: str = "", verification: str = "", reasoning: str = "", recurrence: str = "", priority: str = "medium") -> str:
783
- """Create a new NEXO followup (autonomous task).
797
+ def nexo_followup_create(
798
+ id: str,
799
+ description: str,
800
+ date: str = "",
801
+ verification: str = "",
802
+ reasoning: str = "",
803
+ recurrence: str = "",
804
+ priority: str = "medium",
805
+ internal: str = "",
806
+ owner: str = "",
807
+ ) -> str:
808
+ """Create a new agent followup (autonomous task).
784
809
 
785
810
  Args:
786
811
  id: Unique ID starting with 'NF' (e.g., NF-MCP2).
@@ -791,8 +816,16 @@ def nexo_followup_create(id: str, description: str, date: str = "", verification
791
816
  recurrence: Auto-regenerate pattern (optional). Formats: 'weekly:monday', 'monthly:1', 'monthly:15', 'quarterly'.
792
817
  When completed, a new followup is auto-created with the next date. The completed one is archived with date suffix.
793
818
  priority: critical, high, medium, low (default: medium).
819
+ internal: '1'/'true' hides from default user views (agent
820
+ bookkeeping, protocol, audit). Leave empty to
821
+ auto-classify by ID prefix.
822
+ owner: 'user' | 'waiting' | 'agent' | 'shared'. Leave empty
823
+ for auto-classification.
794
824
  """
795
- return handle_followup_create(id, description, date, verification, reasoning, recurrence, priority)
825
+ return handle_followup_create(
826
+ id, description, date, verification, reasoning, recurrence, priority,
827
+ internal, owner,
828
+ )
796
829
 
797
830
 
798
831
  @mcp.tool
@@ -812,6 +845,8 @@ def nexo_followup_update(
812
845
  verification: str = "",
813
846
  status: str = "",
814
847
  priority: str = "",
848
+ internal: str = "",
849
+ owner: str = "",
815
850
  read_token: str = "",
816
851
  ) -> str:
817
852
  """Update fields of an existing followup. Only non-empty fields are changed.
@@ -825,9 +860,14 @@ def nexo_followup_update(
825
860
  verification: New verification text (optional).
826
861
  status: New status (optional).
827
862
  priority: critical, high, medium, low (optional).
863
+ internal: '1'/'0' to re-classify visibility (optional).
864
+ owner: New 'user'|'waiting'|'agent'|'shared' (optional).
828
865
  read_token: Token returned by `nexo_followup_get`.
829
866
  """
830
- return handle_followup_update(id, description, date, verification, status, priority, read_token)
867
+ return handle_followup_update(
868
+ id, description, date, verification, status, priority,
869
+ internal, owner, read_token,
870
+ )
831
871
 
832
872
 
833
873
  @mcp.tool
@@ -40,6 +40,8 @@ def _format_reminder_payload(reminder: dict) -> str:
40
40
  f"Date: {reminder.get('date') or '—'}",
41
41
  f"Status: {reminder.get('status') or '—'}",
42
42
  f"Category: {reminder.get('category') or 'general'}",
43
+ f"Owner: {reminder.get('owner') or '—'}",
44
+ f"Internal: {1 if reminder.get('internal') else 0}",
43
45
  ]
44
46
  history_rules = reminder.get("history_rules") or []
45
47
  if history_rules:
@@ -62,6 +64,8 @@ def _format_followup_payload(followup: dict) -> str:
62
64
  f"Reasoning: {followup.get('reasoning') or '—'}",
63
65
  f"Recurrence: {followup.get('recurrence') or '—'}",
64
66
  f"Priority: {followup.get('priority') or 'medium'}",
67
+ f"Owner: {followup.get('owner') or '—'}",
68
+ f"Internal: {1 if followup.get('internal') else 0}",
65
69
  ]
66
70
  history_rules = followup.get("history_rules") or []
67
71
  if history_rules:
@@ -76,18 +80,37 @@ def _format_followup_payload(followup: dict) -> str:
76
80
 
77
81
  # ── Reminders ──────────────────────────────────────────────────────────────────
78
82
 
79
- def handle_reminder_create(id: str, description: str, date: str = '', category: str = 'general') -> str:
83
+ def handle_reminder_create(
84
+ id: str,
85
+ description: str,
86
+ date: str = '',
87
+ category: str = 'general',
88
+ internal: str = '',
89
+ owner: str = '',
90
+ ) -> str:
80
91
  """Create a new reminder. id must start with 'R'."""
81
92
  if not id.startswith('R'):
82
93
  return f"ERROR: Reminder ID must start with 'R' (received: '{id}')."
83
94
 
84
- result = create_reminder(id=id, description=description, date=date or None, category=category)
95
+ result = create_reminder(
96
+ id=id,
97
+ description=description,
98
+ date=date or None,
99
+ category=category,
100
+ internal=internal if internal != '' else None,
101
+ owner=owner if owner != '' else None,
102
+ )
85
103
  if not result or "error" in result:
86
104
  error_msg = result.get("error", "unknown") if isinstance(result, dict) else "unknown"
87
105
  return f"ERROR: {error_msg}"
88
106
 
89
107
  date_str = date if date else 'no date'
90
- return f"Reminder created. Date: {date_str}. Category: {category}."
108
+ owner_final = result.get('owner') or '—'
109
+ internal_final = 1 if result.get('internal') else 0
110
+ return (
111
+ f"Reminder created. Date: {date_str}. Category: {category}. "
112
+ f"Owner: {owner_final}. Internal: {internal_final}."
113
+ )
91
114
 
92
115
 
93
116
  def handle_reminder_get(id: str) -> str:
@@ -104,6 +127,8 @@ def handle_reminder_update(
104
127
  date: str = '',
105
128
  status: str = '',
106
129
  category: str = '',
130
+ internal: str = '',
131
+ owner: str = '',
107
132
  read_token: str = '',
108
133
  ) -> str:
109
134
  """Update one or more fields of an existing reminder."""
@@ -120,6 +145,10 @@ def handle_reminder_update(
120
145
  fields['status'] = status
121
146
  if category:
122
147
  fields['category'] = category
148
+ if internal != '':
149
+ fields['internal'] = internal
150
+ if owner != '':
151
+ fields['owner'] = owner
123
152
 
124
153
  if not fields:
125
154
  return f"ERROR: No fields specified to update for {id}."
@@ -190,6 +219,8 @@ def handle_followup_create(
190
219
  reasoning: str = '',
191
220
  recurrence: str = '',
192
221
  priority: str = 'medium',
222
+ internal: str = '',
223
+ owner: str = '',
193
224
  ) -> str:
194
225
  """Create a new NEXO followup. id must start with 'NF'.
195
226
 
@@ -201,6 +232,11 @@ def handle_followup_create(
201
232
  reasoning: WHY this followup exists — what decision/context led to it
202
233
  recurrence: Recurrence pattern (optional). Formats: 'weekly:monday', 'monthly:1', 'quarterly'.
203
234
  When completed, auto-creates the next occurrence.
235
+ internal: '1' / 'true' hides this task from default user views
236
+ (agent bookkeeping, protocol enforcement, audits).
237
+ Omit to let Brain classify by ID prefix heuristic.
238
+ owner: 'user' | 'waiting' | 'agent' | 'shared'. Omit to let
239
+ Brain classify by description verbs.
204
240
  """
205
241
  if not id.startswith('NF'):
206
242
  return f"ERROR: Followup ID must start with 'NF' (received: '{id}')."
@@ -213,6 +249,8 @@ def handle_followup_create(
213
249
  reasoning=reasoning,
214
250
  recurrence=recurrence or None,
215
251
  priority=priority or "medium",
252
+ internal=internal if internal != '' else None,
253
+ owner=owner if owner != '' else None,
216
254
  )
217
255
  if not result or "error" in result:
218
256
  error_msg = result.get("error", "unknown") if isinstance(result, dict) else "unknown"
@@ -221,9 +259,12 @@ def handle_followup_create(
221
259
  date_str = date if date else 'no date'
222
260
  rec_str = f" Recurrence: {recurrence}." if recurrence else ""
223
261
  priority_str = f" Priority: {priority or 'medium'}."
262
+ owner_final = result.get('owner') or '—'
263
+ internal_final = 1 if result.get('internal') else 0
264
+ class_str = f" Owner: {owner_final}. Internal: {internal_final}."
224
265
  warning = result.get("warning", "")
225
266
  warn_str = f"\n{warning}" if warning else ""
226
- return f"Followup created. Date: {date_str}.{priority_str}{rec_str}{warn_str}"
267
+ return f"Followup created. Date: {date_str}.{priority_str}{rec_str}{class_str}{warn_str}"
227
268
 
228
269
 
229
270
  def handle_followup_get(id: str) -> str:
@@ -241,6 +282,8 @@ def handle_followup_update(
241
282
  verification: str = '',
242
283
  status: str = '',
243
284
  priority: str = '',
285
+ internal: str = '',
286
+ owner: str = '',
244
287
  read_token: str = '',
245
288
  ) -> str:
246
289
  """Update one or more fields of an existing followup."""
@@ -259,6 +302,10 @@ def handle_followup_update(
259
302
  fields['status'] = status
260
303
  if priority:
261
304
  fields['priority'] = priority
305
+ if internal != '':
306
+ fields['internal'] = internal
307
+ if owner != '':
308
+ fields['owner'] = owner
262
309
 
263
310
  if not fields:
264
311
  return f"ERROR: No fields specified to update for {id}."