nexo-brain 5.6.1 → 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 +4 -2
- package/package.json +1 -1
- package/src/auto_update.py +215 -1
- package/src/cli.py +14 -2
- package/src/db/_classification.py +154 -0
- package/src/db/_reminders.py +102 -9
- package/src/db/_schema.py +64 -0
- package/src/plugins/update.py +47 -5
- 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,9 +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.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
22
|
|
|
23
|
-
Previously in `5.
|
|
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.
|
|
24
|
+
|
|
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
|
|
|
25
27
|
Previously in `5.5.5`: data-loss guardrails + automatic self-heal. The updater now refuses to capture an already-wiped `nexo.db` into a `pre-update-*` snapshot (validated `sqlite3.backup` + pre-flight wipe guard + post-migration row-count gate), and an auto-heal restores `data/nexo.db` from the newest hourly backup on the next server boot when a wipe is detected. New `nexo recover` CLI + `nexo_recover` MCP tool.
|
|
26
28
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -1168,6 +1168,202 @@ def _check_npm_version() -> str | None:
|
|
|
1168
1168
|
return None
|
|
1169
1169
|
|
|
1170
1170
|
|
|
1171
|
+
# ── External CLI auto-update (Claude Code, Codex) ────────────────────
|
|
1172
|
+
# Keep this list aligned with the third-party CLIs that NEXO drives end-to-end.
|
|
1173
|
+
# Adding a new CLI here makes `nexo update` auto-install/bump it unless the
|
|
1174
|
+
# caller passes include_clis=False. Learning #323: use canonical npm names.
|
|
1175
|
+
_EXTERNAL_CLIS: tuple[str, ...] = (
|
|
1176
|
+
"@anthropic-ai/claude-code",
|
|
1177
|
+
"@openai/codex",
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _update_external_clis(progress_fn=None) -> dict:
|
|
1182
|
+
"""Detect and update NEXO's external terminal CLIs.
|
|
1183
|
+
|
|
1184
|
+
For each package in :data:`_EXTERNAL_CLIS`:
|
|
1185
|
+
1. Read the installed global version via ``npm list -g --json --depth=0``.
|
|
1186
|
+
2. Fetch the latest version from the npm registry via ``npm view <pkg> version``.
|
|
1187
|
+
3. When newer, run ``npm install -g <pkg>@latest``.
|
|
1188
|
+
|
|
1189
|
+
Silently skips packages that are not installed globally — NEXO does not
|
|
1190
|
+
push unsolicited installs of third-party CLIs onto operators.
|
|
1191
|
+
|
|
1192
|
+
Returns a dict keyed by package name. Each entry shape::
|
|
1193
|
+
|
|
1194
|
+
{
|
|
1195
|
+
"old": "2.1.109" | None,
|
|
1196
|
+
"new": "2.1.115" | None,
|
|
1197
|
+
"updated": True | False,
|
|
1198
|
+
"status": "updated" | "already_latest" | "not_installed"
|
|
1199
|
+
| "skipped" | "failed",
|
|
1200
|
+
"error": "<message>" # only when status == "failed"/"skipped"
|
|
1201
|
+
}
|
|
1202
|
+
"""
|
|
1203
|
+
# Reuse the npm helpers already hardened in plugins/update.py (version
|
|
1204
|
+
# parsing, TimeoutExpired handling, invalid-name validation). Falling back
|
|
1205
|
+
# to "skipped" keeps a partially-copied runtime from crashing the update.
|
|
1206
|
+
try:
|
|
1207
|
+
from plugins.update import (
|
|
1208
|
+
_get_npm_global_version,
|
|
1209
|
+
_get_npm_registry_version,
|
|
1210
|
+
_validate_npm_name,
|
|
1211
|
+
)
|
|
1212
|
+
except Exception as e: # pragma: no cover — only mid-upgrade installs hit this
|
|
1213
|
+
return {
|
|
1214
|
+
cli: {
|
|
1215
|
+
"old": None,
|
|
1216
|
+
"new": None,
|
|
1217
|
+
"updated": False,
|
|
1218
|
+
"status": "skipped",
|
|
1219
|
+
"error": f"plugins.update helpers unavailable: {e}",
|
|
1220
|
+
}
|
|
1221
|
+
for cli in _EXTERNAL_CLIS
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
results: dict[str, dict] = {}
|
|
1225
|
+
|
|
1226
|
+
for pkg in _EXTERNAL_CLIS:
|
|
1227
|
+
entry: dict = {"old": None, "new": None, "updated": False, "status": "unknown"}
|
|
1228
|
+
|
|
1229
|
+
if not _validate_npm_name(pkg):
|
|
1230
|
+
entry.update({"status": "failed", "error": f"invalid npm name: {pkg!r}"})
|
|
1231
|
+
results[pkg] = entry
|
|
1232
|
+
continue
|
|
1233
|
+
|
|
1234
|
+
old_version = _get_npm_global_version(pkg)
|
|
1235
|
+
if old_version is None:
|
|
1236
|
+
# Not installed globally — don't auto-install third-party CLIs the
|
|
1237
|
+
# operator didn't opt into. Silent skip in the final summary.
|
|
1238
|
+
entry["status"] = "not_installed"
|
|
1239
|
+
results[pkg] = entry
|
|
1240
|
+
continue
|
|
1241
|
+
|
|
1242
|
+
entry["old"] = old_version
|
|
1243
|
+
|
|
1244
|
+
latest = _get_npm_registry_version(pkg)
|
|
1245
|
+
if latest is None:
|
|
1246
|
+
entry.update({
|
|
1247
|
+
"new": old_version,
|
|
1248
|
+
"status": "failed",
|
|
1249
|
+
"error": "npm registry lookup failed",
|
|
1250
|
+
})
|
|
1251
|
+
results[pkg] = entry
|
|
1252
|
+
continue
|
|
1253
|
+
|
|
1254
|
+
if old_version == latest:
|
|
1255
|
+
entry.update({"new": old_version, "status": "already_latest"})
|
|
1256
|
+
results[pkg] = entry
|
|
1257
|
+
continue
|
|
1258
|
+
|
|
1259
|
+
if progress_fn is not None:
|
|
1260
|
+
try:
|
|
1261
|
+
progress_fn(f"Updating {pkg}: {old_version} -> {latest}...")
|
|
1262
|
+
except Exception:
|
|
1263
|
+
pass
|
|
1264
|
+
|
|
1265
|
+
try:
|
|
1266
|
+
r = subprocess.run(
|
|
1267
|
+
["npm", "install", "-g", f"{pkg}@latest"],
|
|
1268
|
+
capture_output=True,
|
|
1269
|
+
text=True,
|
|
1270
|
+
timeout=180,
|
|
1271
|
+
)
|
|
1272
|
+
except subprocess.TimeoutExpired:
|
|
1273
|
+
# Learning #294: always capture TimeoutExpired explicitly.
|
|
1274
|
+
entry.update({
|
|
1275
|
+
"new": old_version,
|
|
1276
|
+
"status": "failed",
|
|
1277
|
+
"error": "npm install timed out after 180s",
|
|
1278
|
+
})
|
|
1279
|
+
results[pkg] = entry
|
|
1280
|
+
continue
|
|
1281
|
+
except FileNotFoundError:
|
|
1282
|
+
# npm itself missing — no Node.js on PATH. Mark the rest as skipped
|
|
1283
|
+
# and bail: no point retrying subsequent packages.
|
|
1284
|
+
entry.update({
|
|
1285
|
+
"new": old_version,
|
|
1286
|
+
"status": "skipped",
|
|
1287
|
+
"error": "npm not found on PATH",
|
|
1288
|
+
})
|
|
1289
|
+
results[pkg] = entry
|
|
1290
|
+
for remaining in _EXTERNAL_CLIS:
|
|
1291
|
+
results.setdefault(remaining, {
|
|
1292
|
+
"old": None,
|
|
1293
|
+
"new": None,
|
|
1294
|
+
"updated": False,
|
|
1295
|
+
"status": "skipped",
|
|
1296
|
+
"error": "npm not found on PATH",
|
|
1297
|
+
})
|
|
1298
|
+
return results
|
|
1299
|
+
except Exception as e: # pragma: no cover — defensive
|
|
1300
|
+
entry.update({
|
|
1301
|
+
"new": old_version,
|
|
1302
|
+
"status": "failed",
|
|
1303
|
+
"error": str(e)[:500],
|
|
1304
|
+
})
|
|
1305
|
+
results[pkg] = entry
|
|
1306
|
+
continue
|
|
1307
|
+
|
|
1308
|
+
if r.returncode != 0:
|
|
1309
|
+
entry.update({
|
|
1310
|
+
"new": old_version,
|
|
1311
|
+
"status": "failed",
|
|
1312
|
+
"error": (r.stderr or r.stdout or "npm install failed").strip()[:500],
|
|
1313
|
+
})
|
|
1314
|
+
results[pkg] = entry
|
|
1315
|
+
continue
|
|
1316
|
+
|
|
1317
|
+
new_version = _get_npm_global_version(pkg) or latest
|
|
1318
|
+
entry.update({
|
|
1319
|
+
"new": new_version,
|
|
1320
|
+
"updated": new_version != old_version,
|
|
1321
|
+
"status": "updated" if new_version != old_version else "already_latest",
|
|
1322
|
+
})
|
|
1323
|
+
results[pkg] = entry
|
|
1324
|
+
|
|
1325
|
+
return results
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _format_external_clis_results(results: dict) -> list[str]:
|
|
1329
|
+
"""Render CLI update results as lines for the ``nexo update`` summary.
|
|
1330
|
+
|
|
1331
|
+
Emits a visible warning per bumped CLI (operator must restart the terminal
|
|
1332
|
+
for the new version to take effect), a warning per failure, and a single
|
|
1333
|
+
informational line when nothing changed but something was checked.
|
|
1334
|
+
"""
|
|
1335
|
+
if not results:
|
|
1336
|
+
return []
|
|
1337
|
+
|
|
1338
|
+
lines: list[str] = []
|
|
1339
|
+
any_updated = False
|
|
1340
|
+
any_failed = False
|
|
1341
|
+
any_checked_latest = False
|
|
1342
|
+
|
|
1343
|
+
for pkg, entry in results.items():
|
|
1344
|
+
status = entry.get("status")
|
|
1345
|
+
if status == "updated":
|
|
1346
|
+
any_updated = True
|
|
1347
|
+
lines.append(
|
|
1348
|
+
f" CLI updated: {pkg} {entry.get('old')} -> {entry.get('new')} "
|
|
1349
|
+
f"— reinicia terminal para activar"
|
|
1350
|
+
)
|
|
1351
|
+
elif status == "already_latest":
|
|
1352
|
+
any_checked_latest = True
|
|
1353
|
+
elif status == "failed":
|
|
1354
|
+
any_failed = True
|
|
1355
|
+
lines.append(
|
|
1356
|
+
f" WARNING: CLI {pkg} update failed: {entry.get('error', 'unknown')}"
|
|
1357
|
+
)
|
|
1358
|
+
# "not_installed" and "skipped" are intentionally silent — third-party
|
|
1359
|
+
# CLIs that the operator never installed shouldn't spam the summary.
|
|
1360
|
+
|
|
1361
|
+
if not any_updated and not any_failed and any_checked_latest:
|
|
1362
|
+
lines.append(" CLIs externos: ya en última versión")
|
|
1363
|
+
|
|
1364
|
+
return lines
|
|
1365
|
+
|
|
1366
|
+
|
|
1171
1367
|
# ── File-based migrations (migrations/ directory) ────────────────────
|
|
1172
1368
|
|
|
1173
1369
|
def _get_applied_migration_version() -> int:
|
|
@@ -2449,7 +2645,13 @@ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[s
|
|
|
2449
2645
|
return actions, "Personal schedules: " + ", ".join(parts) + "."
|
|
2450
2646
|
|
|
2451
2647
|
|
|
2452
|
-
def manual_sync_update(
|
|
2648
|
+
def manual_sync_update(
|
|
2649
|
+
*,
|
|
2650
|
+
interactive: bool = False,
|
|
2651
|
+
allow_source_pull: bool = True,
|
|
2652
|
+
progress_fn=None,
|
|
2653
|
+
include_clis: bool = True,
|
|
2654
|
+
) -> dict:
|
|
2453
2655
|
src_dir, repo_dir = _resolve_sync_source()
|
|
2454
2656
|
if src_dir is None or repo_dir is None:
|
|
2455
2657
|
return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
|
|
@@ -2513,6 +2715,18 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
|
|
|
2513
2715
|
except Exception:
|
|
2514
2716
|
pass # Non-critical
|
|
2515
2717
|
|
|
2718
|
+
# Auto-update external terminal CLIs (Claude Code, Codex). Best-effort:
|
|
2719
|
+
# a failed third-party install never aborts the NEXO sync itself.
|
|
2720
|
+
if include_clis:
|
|
2721
|
+
try:
|
|
2722
|
+
_emit_progress(progress_fn, "Checking external CLI updates...")
|
|
2723
|
+
cli_results = _update_external_clis(progress_fn=progress_fn)
|
|
2724
|
+
sync_result["external_clis"] = cli_results
|
|
2725
|
+
except Exception as e:
|
|
2726
|
+
sync_result.setdefault("warnings", []).append(
|
|
2727
|
+
f"external CLI update skipped: {e}"
|
|
2728
|
+
)
|
|
2729
|
+
|
|
2516
2730
|
_emit_progress(progress_fn, "Runtime update completed.")
|
|
2517
2731
|
except Exception as e:
|
|
2518
2732
|
_emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
|
package/src/cli.py
CHANGED
|
@@ -823,6 +823,7 @@ def _update(args):
|
|
|
823
823
|
|
|
824
824
|
dest = NEXO_HOME
|
|
825
825
|
src_dir, repo_dir = _resolve_sync_source()
|
|
826
|
+
include_clis = not getattr(args, "no_clis", False)
|
|
826
827
|
|
|
827
828
|
if src_dir is None or repo_dir is None:
|
|
828
829
|
try:
|
|
@@ -835,7 +836,7 @@ def _update(args):
|
|
|
835
836
|
)
|
|
836
837
|
return 1
|
|
837
838
|
|
|
838
|
-
result = handle_update(progress_fn=progress)
|
|
839
|
+
result = handle_update(progress_fn=progress, include_clis=include_clis)
|
|
839
840
|
runtime_power = _load_runtime_power_support()
|
|
840
841
|
public_contribution = _load_public_contribution_support()
|
|
841
842
|
choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
|
|
@@ -873,7 +874,12 @@ def _update(args):
|
|
|
873
874
|
print(f"Contributor mode: {contrib_choice.get('message')}")
|
|
874
875
|
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
875
876
|
|
|
876
|
-
result = manual_sync_update(
|
|
877
|
+
result = manual_sync_update(
|
|
878
|
+
interactive=interactive,
|
|
879
|
+
allow_source_pull=True,
|
|
880
|
+
progress_fn=progress,
|
|
881
|
+
include_clis=include_clis,
|
|
882
|
+
)
|
|
877
883
|
runtime_power = _load_runtime_power_support()
|
|
878
884
|
public_contribution = _load_public_contribution_support()
|
|
879
885
|
choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
|
|
@@ -2015,6 +2021,12 @@ def main():
|
|
|
2015
2021
|
# -- update --
|
|
2016
2022
|
update_parser = sub.add_parser("update", help="Update installed runtime")
|
|
2017
2023
|
update_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2024
|
+
update_parser.add_argument(
|
|
2025
|
+
"--no-clis",
|
|
2026
|
+
dest="no_clis",
|
|
2027
|
+
action="store_true",
|
|
2028
|
+
help="Skip auto-updating external terminal CLIs (Claude Code, Codex)",
|
|
2029
|
+
)
|
|
2018
2030
|
|
|
2019
2031
|
# -- recover --
|
|
2020
2032
|
recover_parser = sub.add_parser(
|
|
@@ -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/plugins/update.py
CHANGED
|
@@ -961,7 +961,7 @@ def _reload_launch_agents_after_bump() -> dict:
|
|
|
961
961
|
return result
|
|
962
962
|
|
|
963
963
|
|
|
964
|
-
def _handle_packaged_update(progress_fn=None) -> str:
|
|
964
|
+
def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> str:
|
|
965
965
|
"""Update a packaged (npm) install — no git repo available."""
|
|
966
966
|
old_version = _read_version()
|
|
967
967
|
|
|
@@ -1078,6 +1078,19 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
1078
1078
|
except Exception:
|
|
1079
1079
|
pass # Non-critical
|
|
1080
1080
|
|
|
1081
|
+
# Auto-update external terminal CLIs (Claude Code, Codex). Best-effort;
|
|
1082
|
+
# a third-party install failure here never rolls the update back.
|
|
1083
|
+
external_cli_lines: list[str] = []
|
|
1084
|
+
if include_clis:
|
|
1085
|
+
try:
|
|
1086
|
+
from auto_update import _update_external_clis, _format_external_clis_results
|
|
1087
|
+
_emit_progress(progress_fn, "Checking external CLI updates...")
|
|
1088
|
+
external_cli_lines = _format_external_clis_results(
|
|
1089
|
+
_update_external_clis(progress_fn=progress_fn)
|
|
1090
|
+
)
|
|
1091
|
+
except Exception:
|
|
1092
|
+
pass # Non-critical
|
|
1093
|
+
|
|
1081
1094
|
client_sync_warning = None
|
|
1082
1095
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
1083
1096
|
clients_ok, client_sync_error = _sync_packaged_clients()
|
|
@@ -1144,6 +1157,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
1144
1157
|
if retired_runtime_files:
|
|
1145
1158
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
1146
1159
|
lines.extend(_format_dep_results(dep_results))
|
|
1160
|
+
lines.extend(external_cli_lines)
|
|
1147
1161
|
if not client_sync_warning:
|
|
1148
1162
|
lines.append(" Clients: configured client targets synced")
|
|
1149
1163
|
else:
|
|
@@ -1162,7 +1176,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
1162
1176
|
return "\n".join(lines)
|
|
1163
1177
|
|
|
1164
1178
|
|
|
1165
|
-
def handle_update(
|
|
1179
|
+
def handle_update(
|
|
1180
|
+
remote: str = "origin",
|
|
1181
|
+
branch: str = "main",
|
|
1182
|
+
progress_fn=None,
|
|
1183
|
+
*,
|
|
1184
|
+
include_clis: bool = True,
|
|
1185
|
+
) -> str:
|
|
1166
1186
|
"""Pull latest NEXO code, backup databases, run migrations, and verify.
|
|
1167
1187
|
|
|
1168
1188
|
Supports both git checkouts and packaged (npm) installs.
|
|
@@ -1179,10 +1199,13 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
1179
1199
|
Args:
|
|
1180
1200
|
remote: Git remote name (default: origin)
|
|
1181
1201
|
branch: Git branch to pull (default: main)
|
|
1202
|
+
include_clis: When True (default), auto-update external terminal CLIs
|
|
1203
|
+
(Claude Code, Codex). Pass False (``nexo update --no-clis``) to
|
|
1204
|
+
keep the third-party CLIs at their current version.
|
|
1182
1205
|
"""
|
|
1183
1206
|
# Packaged install — no git repo
|
|
1184
1207
|
if not _is_git_repo():
|
|
1185
|
-
return _handle_packaged_update(progress_fn=progress_fn)
|
|
1208
|
+
return _handle_packaged_update(progress_fn=progress_fn, include_clis=include_clis)
|
|
1186
1209
|
|
|
1187
1210
|
steps_done = []
|
|
1188
1211
|
old_commit = None
|
|
@@ -1302,6 +1325,23 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
1302
1325
|
except Exception:
|
|
1303
1326
|
pass # Non-critical
|
|
1304
1327
|
|
|
1328
|
+
# Step 10b: Update external terminal CLIs (Claude Code, Codex). Runs
|
|
1329
|
+
# after migrations and before the verify-style post-steps so a stale
|
|
1330
|
+
# CLI never hides bug fixes shipped with the new NEXO release. Failures
|
|
1331
|
+
# here never abort the git update — the rollback only covers NEXO itself.
|
|
1332
|
+
external_cli_lines: list[str] = []
|
|
1333
|
+
if include_clis:
|
|
1334
|
+
try:
|
|
1335
|
+
from auto_update import _update_external_clis, _format_external_clis_results
|
|
1336
|
+
_emit_progress(progress_fn, "Checking external CLI updates...")
|
|
1337
|
+
external_cli_lines = _format_external_clis_results(
|
|
1338
|
+
_update_external_clis(progress_fn=progress_fn)
|
|
1339
|
+
)
|
|
1340
|
+
if external_cli_lines:
|
|
1341
|
+
steps_done.append("external-clis")
|
|
1342
|
+
except Exception:
|
|
1343
|
+
pass # Non-critical
|
|
1344
|
+
|
|
1305
1345
|
# Step 11: Sync shared client configs
|
|
1306
1346
|
try:
|
|
1307
1347
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
@@ -1345,8 +1385,9 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
1345
1385
|
dep_summary_lines = _format_dep_results(dep_results)
|
|
1346
1386
|
if pull_out == "Already up to date.":
|
|
1347
1387
|
msg = f"Already up to date (v{old_version}). No changes pulled."
|
|
1348
|
-
|
|
1349
|
-
|
|
1388
|
+
trailing = [*dep_summary_lines, *external_cli_lines]
|
|
1389
|
+
if trailing:
|
|
1390
|
+
msg += "\n" + "\n".join(trailing)
|
|
1350
1391
|
return msg
|
|
1351
1392
|
|
|
1352
1393
|
lines = ["UPDATE SUCCESSFUL"]
|
|
@@ -1367,6 +1408,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
1367
1408
|
if retired_runtime_files:
|
|
1368
1409
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
1369
1410
|
lines.extend(dep_summary_lines)
|
|
1411
|
+
lines.extend(external_cli_lines)
|
|
1370
1412
|
if "client-sync" in steps_done:
|
|
1371
1413
|
lines.append(" Clients: configured client targets synced")
|
|
1372
1414
|
lines.append("")
|
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}."
|