nexo-brain 5.7.0 → 5.8.1

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.1",
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,11 @@
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-lineso 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.1` is the current packaged-runtime line: closes a self-reinforcing `launchctl kickstart -k` loop in the watchdog that wedged deep-sleep Phase 2 between 2026-04-14 and 2026-04-17. The cron wrapper now INSERTs an in-flight row (`ended_at=NULL`) at start and traps SIGTERM/INT/HUP to close it with `exit_code=143` instead of vanishing from `cron_runs`. The watchdog interprets in-flight rows as "currently running" and only re-executes after verifying the worker process is dead. `extract.py` classifies CLI failures into transient (`overloaded_error`, rate-limit, timeout, signal retried next run) and deterministic (skipped after `MAX_POISON_ATTEMPTS`), and passes a slim shared-context (200 head lines + metadata) instead of the full 400+ KB dump. A new `auto_update._heal_deep_sleep_runtime()` repairs existing installs silently on the next `nexo update`: poisoned checkpoints, stale locks, dangling `cron_runs` rows, and bloated `.watchdog-fails` counters.
22
+
23
+ Previously in `5.8.0`: 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.
24
+
25
+ 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
26
 
23
27
  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
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.7.0",
3
+ "version": "5.8.1",
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",
@@ -875,6 +875,135 @@ def _purge_zero_byte_db_files() -> list[Path]:
875
875
  return removed
876
876
 
877
877
 
878
+ def _heal_deep_sleep_runtime(dest: Path = NEXO_HOME) -> list[str]:
879
+ """Repair deep-sleep state that older runtimes left in a bad shape.
880
+
881
+ Runs on every ``auto_update`` post-sync. The bug it fixes: between
882
+ Brain 5.6.1 and 5.8.0 the cron wrapper only wrote to ``cron_runs`` at
883
+ end, so any wrapper killed by signal produced no row. The watchdog then
884
+ saw the cron as "missing cron_runs entry" and kickstart-‍k'd the live
885
+ worker — an infinite loop that wedged deep-sleep Phase 2 on the first
886
+ session of every batch. 5.8.1 fixes the loop at the source (wrapper
887
+ start-row + watchdog in-flight detection) but older runtimes that have
888
+ already been running the buggy loop need their residue cleaned up.
889
+
890
+ Returns the list of actions performed, for logging. Failures are
891
+ swallowed: this is best-effort healing, it must never block an update.
892
+ """
893
+ import sqlite3
894
+ import time as _time
895
+
896
+ actions: list[str] = []
897
+
898
+ deep_sleep_dir = dest / "operations" / "deep-sleep"
899
+ coord_dir = dest / "coordination"
900
+ data_db = dest / "data" / "nexo.db"
901
+ now = _time.time()
902
+
903
+ # (1) Drop poisoned checkpoints: the first retry that hit Anthropic's
904
+ # overloaded_error got cached as a permanent failure. Older
905
+ # extract.py re-used that checkpoint forever. New extract.py treats
906
+ # transient errors as retryable, but old poisoned checkpoints still
907
+ # claim 0 findings — purge them so the next deep-sleep retries cleanly.
908
+ if deep_sleep_dir.is_dir():
909
+ poisoned = 0
910
+ for checkpoint_dir in deep_sleep_dir.glob("*/checkpoints"):
911
+ if not checkpoint_dir.is_dir():
912
+ continue
913
+ for entry in checkpoint_dir.glob("*.json"):
914
+ try:
915
+ content = entry.read_text()
916
+ except OSError:
917
+ continue
918
+ if "overloaded_error" in content or '"error":{"type":"' in content:
919
+ try:
920
+ entry.unlink()
921
+ poisoned += 1
922
+ except OSError:
923
+ pass
924
+ if poisoned:
925
+ actions.append(f"checkpoints-purged:{poisoned}")
926
+
927
+ # Drop debug-extract-*.txt scratch files older than 7 days.
928
+ stale_debug = 0
929
+ for entry in deep_sleep_dir.glob("debug-extract-*.txt"):
930
+ try:
931
+ if now - entry.stat().st_mtime > 7 * 86400:
932
+ entry.unlink()
933
+ stale_debug += 1
934
+ except OSError:
935
+ continue
936
+ if stale_debug:
937
+ actions.append(f"debug-scratch-purged:{stale_debug}")
938
+
939
+ # (2) Release stale deep-sleep locks so the next 04:30 run can acquire
940
+ # them. Locks older than 6h are always stale — a real run finishes
941
+ # in well under an hour.
942
+ lock_names = ("sleep.lock", "sleep-process.lock", "synthesis.lock")
943
+ released = 0
944
+ if coord_dir.is_dir():
945
+ for name in lock_names:
946
+ lock_path = coord_dir / name
947
+ if not lock_path.exists():
948
+ continue
949
+ try:
950
+ age = now - lock_path.stat().st_mtime
951
+ except OSError:
952
+ continue
953
+ if age > 6 * 3600:
954
+ try:
955
+ lock_path.unlink()
956
+ released += 1
957
+ except OSError:
958
+ pass
959
+ if released:
960
+ actions.append(f"stale-locks-released:{released}")
961
+
962
+ # (3) Close dangling cron_runs rows. Any row with ended_at IS NULL older
963
+ # than 6h is either a process killed by the old watchdog loop or a
964
+ # zombie left behind by a previous bad install. Close them with
965
+ # exit_code=143 + summary so the NEW watchdog treats the cron as
966
+ # "finished with error" rather than "in-flight forever".
967
+ if data_db.is_file():
968
+ try:
969
+ conn = sqlite3.connect(str(data_db), timeout=5)
970
+ try:
971
+ cur = conn.execute(
972
+ """
973
+ UPDATE cron_runs
974
+ SET ended_at = datetime('now'),
975
+ exit_code = 143,
976
+ error = 'healed by auto_update (pre-5.8.1 wrapper left row open)',
977
+ duration_secs = CAST(
978
+ strftime('%s','now') - strftime('%s', started_at) AS REAL
979
+ )
980
+ WHERE ended_at IS NULL
981
+ AND strftime('%s','now') - strftime('%s', started_at) > 6 * 3600
982
+ """
983
+ )
984
+ closed = cur.rowcount or 0
985
+ conn.commit()
986
+ if closed:
987
+ actions.append(f"cron_runs-closed-dangling:{closed}")
988
+ finally:
989
+ conn.close()
990
+ except Exception as exc:
991
+ actions.append(f"cron_runs-heal-warning:{exc.__class__.__name__}")
992
+
993
+ # (4) Remove .watchdog-fails registry entries older than 24h — the new
994
+ # in-flight detection makes stale counters obsolete.
995
+ fails_file = dest / "scripts" / ".watchdog-fails"
996
+ if fails_file.exists():
997
+ try:
998
+ if now - fails_file.stat().st_mtime > 24 * 3600:
999
+ fails_file.unlink()
1000
+ actions.append("watchdog-fails-reset")
1001
+ except OSError:
1002
+ pass
1003
+
1004
+ return actions
1005
+
1006
+
878
1007
  def _backup_dbs() -> str | None:
879
1008
  """Snapshot all .db files before migration. Returns backup dir or None."""
880
1009
  import sqlite3
@@ -2558,6 +2687,16 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
2558
2687
  except Exception as e:
2559
2688
  actions.append(f"client-sync-warning:{e}")
2560
2689
 
2690
+ # Heal deep-sleep residue from older buggy runtimes. Idempotent + safe:
2691
+ # no-op if the runtime is already clean.
2692
+ try:
2693
+ _emit_progress(progress_fn, "Healing deep-sleep runtime state...")
2694
+ heal_actions = _heal_deep_sleep_runtime(dest)
2695
+ for action in heal_actions:
2696
+ actions.append(f"deep-sleep-heal:{action}")
2697
+ except Exception as exc:
2698
+ actions.append(f"deep-sleep-heal-warning:{exc.__class__.__name__}")
2699
+
2561
2700
  _emit_progress(progress_fn, "Verifying runtime imports...")
2562
2701
  verify = subprocess.run(
2563
2702
  [sys.executable, "-c", "import server"],
@@ -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