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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +139 -0
- package/src/db/_classification.py +154 -0
- package/src/db/_reminders.py +102 -9
- package/src/db/_schema.py +64 -0
- package/src/scripts/deep-sleep/extract.py +198 -45
- package/src/scripts/nexo-cron-wrapper.sh +139 -54
- package/src/scripts/nexo-watchdog.sh +58 -26
- package/src/server.py +47 -7
- package/src/tools_reminders_crud.py +51 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
package/src/db/_reminders.py
CHANGED
|
@@ -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 (
|
|
259
|
-
|
|
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={
|
|
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 = {
|
|
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
|
|