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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/db/_classification.py +154 -0
- package/src/db/_reminders.py +102 -9
- package/src/db/_schema.py +64 -0
- 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.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.
|
|
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.
|
|
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
|
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
|
|
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(
|
|
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(
|
|
783
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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}."
|