nexo-brain 5.3.28 → 5.4.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 +5 -1
- package/bin/nexo-brain.js +24 -7
- package/package.json +1 -1
- package/src/auto_update.py +37 -16
- package/src/calibration_migration.py +242 -0
- package/src/cli.py +183 -0
- package/src/desktop_bridge.py +459 -0
- package/src/events_bus.py +155 -0
- package/src/health_check.py +195 -0
- package/src/plugin_loader.py +5 -0
- package/src/plugins/update.py +5 -4
- package/src/scripts/nexo-cron-wrapper.sh +78 -22
- package/src/scripts/nexo-update.sh +14 -288
- package/src/server.py +140 -99
- package/src/tree_hygiene.py +56 -0
- package/src/user_context.py +23 -5
package/src/cli.py
CHANGED
|
@@ -922,6 +922,19 @@ def _update(args):
|
|
|
922
922
|
print(f" Model recommendation check skipped: {exc}", file=sys.stderr)
|
|
923
923
|
else:
|
|
924
924
|
print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
|
|
925
|
+
|
|
926
|
+
# Auto-migrate calibration.json flat → nested once per user. Silent on
|
|
927
|
+
# no-op; logs a line if an actual migration happened.
|
|
928
|
+
try:
|
|
929
|
+
from calibration_migration import detect as _cal_detect, apply_migration as _cal_apply
|
|
930
|
+
if _cal_detect()["shape"] == "flat":
|
|
931
|
+
mig = _cal_apply()
|
|
932
|
+
if mig.get("status") == "migrated" and not args.json:
|
|
933
|
+
print(f"[NEXO] calibration.json migrated flat → nested (backup: {mig.get('backup')})",
|
|
934
|
+
flush=True)
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
|
|
925
938
|
return 0 if result.get("ok") else 1
|
|
926
939
|
|
|
927
940
|
|
|
@@ -1295,6 +1308,105 @@ def _chat(args):
|
|
|
1295
1308
|
return int(result.returncode)
|
|
1296
1309
|
|
|
1297
1310
|
|
|
1311
|
+
def _notify(args):
|
|
1312
|
+
"""Emit an event to the runtime events bus (events.ndjson)."""
|
|
1313
|
+
from events_bus import emit
|
|
1314
|
+
try:
|
|
1315
|
+
event = emit(
|
|
1316
|
+
args.type,
|
|
1317
|
+
text=getattr(args, "text", "") or "",
|
|
1318
|
+
reason=getattr(args, "reason", "") or "",
|
|
1319
|
+
priority=getattr(args, "priority", "normal"),
|
|
1320
|
+
source=getattr(args, "source", "nexo-brain"),
|
|
1321
|
+
)
|
|
1322
|
+
except ValueError as exc:
|
|
1323
|
+
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
1324
|
+
return 2
|
|
1325
|
+
if args.json:
|
|
1326
|
+
print(json.dumps({"ok": True, "event": event}, ensure_ascii=False, indent=2))
|
|
1327
|
+
else:
|
|
1328
|
+
print(f"notify: id={event['id']} type={event['type']} priority={event['priority']}")
|
|
1329
|
+
return 0
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _health(args):
|
|
1333
|
+
"""Collect a health snapshot and print it."""
|
|
1334
|
+
from health_check import collect
|
|
1335
|
+
report = collect()
|
|
1336
|
+
if getattr(args, "json", True) is False:
|
|
1337
|
+
# minimal text mode
|
|
1338
|
+
print(f"status: {report.get('status', 'unknown')}")
|
|
1339
|
+
for name, sub in report.get("subsystems", {}).items():
|
|
1340
|
+
print(f" {name:10s} {sub.get('status', '?')}")
|
|
1341
|
+
return 0 if report.get("status") == "ok" else 1
|
|
1342
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
1343
|
+
return 0 if report.get("status") == "ok" else 1
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _logs(args):
|
|
1347
|
+
"""Tail recent logs: events bus + operations/*.log."""
|
|
1348
|
+
import glob as _glob
|
|
1349
|
+
lines_want = max(1, int(getattr(args, "lines", 100)))
|
|
1350
|
+
source = (getattr(args, "source", "all") or "all").lower()
|
|
1351
|
+
|
|
1352
|
+
results: dict = {"source": source, "lines": lines_want, "entries": []}
|
|
1353
|
+
home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
1354
|
+
|
|
1355
|
+
def _collect_events(n: int) -> list[dict]:
|
|
1356
|
+
try:
|
|
1357
|
+
from events_bus import tail as _tail
|
|
1358
|
+
return _tail(lines=n)
|
|
1359
|
+
except Exception as exc:
|
|
1360
|
+
return [{"error": f"events tail failed: {exc}"}]
|
|
1361
|
+
|
|
1362
|
+
def _collect_ops(n: int) -> list[dict]:
|
|
1363
|
+
ops_dir = home / "operations"
|
|
1364
|
+
if not ops_dir.is_dir():
|
|
1365
|
+
return []
|
|
1366
|
+
files = sorted(
|
|
1367
|
+
ops_dir.glob("*.log"),
|
|
1368
|
+
key=lambda p: p.stat().st_mtime if p.exists() else 0,
|
|
1369
|
+
reverse=True,
|
|
1370
|
+
)[:5]
|
|
1371
|
+
out: list[dict] = []
|
|
1372
|
+
for log in files:
|
|
1373
|
+
try:
|
|
1374
|
+
text = log.read_text(errors="ignore").splitlines()[-n:]
|
|
1375
|
+
except Exception as exc:
|
|
1376
|
+
out.append({"file": str(log), "error": str(exc)})
|
|
1377
|
+
continue
|
|
1378
|
+
for line in text:
|
|
1379
|
+
out.append({"file": log.name, "line": line})
|
|
1380
|
+
return out[-n:]
|
|
1381
|
+
|
|
1382
|
+
if source in ("all", "events"):
|
|
1383
|
+
results["entries"].extend({"kind": "event", **e} for e in _collect_events(lines_want))
|
|
1384
|
+
if source in ("all", "operations"):
|
|
1385
|
+
results["entries"].extend({"kind": "log", **e} for e in _collect_ops(lines_want))
|
|
1386
|
+
if source not in ("all", "events", "operations"):
|
|
1387
|
+
# Treat as filename match inside operations/
|
|
1388
|
+
specific = home / "operations" / source
|
|
1389
|
+
if specific.is_file():
|
|
1390
|
+
try:
|
|
1391
|
+
text = specific.read_text(errors="ignore").splitlines()[-lines_want:]
|
|
1392
|
+
results["entries"] = [{"kind": "log", "file": specific.name, "line": ln} for ln in text]
|
|
1393
|
+
except Exception as exc:
|
|
1394
|
+
results["error"] = str(exc)
|
|
1395
|
+
else:
|
|
1396
|
+
results["error"] = f"unknown log source: {source}"
|
|
1397
|
+
|
|
1398
|
+
if args.json:
|
|
1399
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
1400
|
+
else:
|
|
1401
|
+
for entry in results["entries"][-lines_want:]:
|
|
1402
|
+
if entry.get("kind") == "event":
|
|
1403
|
+
print(f"[event] id={entry.get('id')} {entry.get('type')} "
|
|
1404
|
+
f"{entry.get('priority')} {entry.get('text','')}")
|
|
1405
|
+
else:
|
|
1406
|
+
print(f"[{entry.get('file','?')}] {entry.get('line','')}")
|
|
1407
|
+
return 0
|
|
1408
|
+
|
|
1409
|
+
|
|
1298
1410
|
def _doctor(args):
|
|
1299
1411
|
"""Run unified doctor diagnostics."""
|
|
1300
1412
|
try:
|
|
@@ -1306,6 +1418,22 @@ def _doctor(args):
|
|
|
1306
1418
|
return 1
|
|
1307
1419
|
|
|
1308
1420
|
init_db()
|
|
1421
|
+
|
|
1422
|
+
# Calibration migration hook — runs before the orchestrator so the rest
|
|
1423
|
+
# of doctor sees the canonical nested shape.
|
|
1424
|
+
want_migrate = getattr(args, "migrate_calibration", False) or args.fix
|
|
1425
|
+
dry = getattr(args, "calibration_dry_run", False)
|
|
1426
|
+
if want_migrate or dry:
|
|
1427
|
+
from calibration_migration import detect, apply_migration
|
|
1428
|
+
shape = detect()
|
|
1429
|
+
if shape["shape"] == "flat":
|
|
1430
|
+
mig = apply_migration(dry_run=dry)
|
|
1431
|
+
if args.json:
|
|
1432
|
+
print(json.dumps({"calibration_migration": mig}, ensure_ascii=False, indent=2))
|
|
1433
|
+
else:
|
|
1434
|
+
print(f"[NEXO] calibration migration: {mig.get('status')} — {mig.get('reason','')}",
|
|
1435
|
+
file=sys.stderr, flush=True)
|
|
1436
|
+
|
|
1309
1437
|
tier_label = getattr(args, "tier", "boot") or "boot"
|
|
1310
1438
|
print(f"[NEXO] Inspecting {tier_label} diagnostics... please wait.", file=sys.stderr, flush=True)
|
|
1311
1439
|
report = run_doctor(tier=args.tier, fix=args.fix, plane=getattr(args, "plane", ""))
|
|
@@ -1855,6 +1983,10 @@ def main():
|
|
|
1855
1983
|
)
|
|
1856
1984
|
doctor_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
1857
1985
|
doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
|
|
1986
|
+
doctor_parser.add_argument("--migrate-calibration", action="store_true",
|
|
1987
|
+
help="Force calibration.json flat → nested migration")
|
|
1988
|
+
doctor_parser.add_argument("--calibration-dry-run", action="store_true",
|
|
1989
|
+
help="Preview the calibration migration without writing")
|
|
1858
1990
|
|
|
1859
1991
|
# -- contributor --
|
|
1860
1992
|
contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
|
|
@@ -1944,6 +2076,43 @@ def main():
|
|
|
1944
2076
|
dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
|
|
1945
2077
|
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
1946
2078
|
|
|
2079
|
+
# -- desktop bridge (read-only, for NEXO Desktop and any external UI) --
|
|
2080
|
+
schema_parser = sub.add_parser("schema", help="Editable-field schema for Preferences UI")
|
|
2081
|
+
schema_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2082
|
+
|
|
2083
|
+
identity_parser = sub.add_parser("identity", help="Canonical assistant identity")
|
|
2084
|
+
identity_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2085
|
+
|
|
2086
|
+
onboard_parser = sub.add_parser("onboard", help="Onboarding wizard steps")
|
|
2087
|
+
onboard_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2088
|
+
|
|
2089
|
+
scan_profile_parser = sub.add_parser("scan-profile", help="Build profile.json from CLAUDE.md + calibration")
|
|
2090
|
+
scan_profile_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2091
|
+
scan_profile_parser.add_argument("--apply", action="store_true", help="Write profile.json (default is preview)")
|
|
2092
|
+
scan_profile_parser.add_argument("--force", action="store_true", help="Overwrite existing profile.json on --apply")
|
|
2093
|
+
|
|
2094
|
+
# -- runtime events bus + operational observability --
|
|
2095
|
+
notify_parser = sub.add_parser("notify", help="Emit an event to the runtime event bus")
|
|
2096
|
+
notify_parser.add_argument("type", choices=[
|
|
2097
|
+
"attention_required", "proactive_message", "followup_alert",
|
|
2098
|
+
"health_alert", "info",
|
|
2099
|
+
], help="Event type")
|
|
2100
|
+
notify_parser.add_argument("--text", default="", help="Short user-facing message")
|
|
2101
|
+
notify_parser.add_argument("--reason", default="", help="Internal reason / trigger")
|
|
2102
|
+
notify_parser.add_argument("--priority", choices=["low", "normal", "high", "urgent"], default="normal")
|
|
2103
|
+
notify_parser.add_argument("--source", default="nexo-brain", help="Who emitted this event")
|
|
2104
|
+
notify_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2105
|
+
|
|
2106
|
+
health_parser = sub.add_parser("health", help="Snapshot of NEXO Brain subsystem health")
|
|
2107
|
+
health_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2108
|
+
|
|
2109
|
+
logs_parser = sub.add_parser("logs", help="Tail recent operational logs")
|
|
2110
|
+
logs_parser.add_argument("--tail", action="store_true", help="Tail mode (default)")
|
|
2111
|
+
logs_parser.add_argument("--lines", type=int, default=100, help="How many lines to return")
|
|
2112
|
+
logs_parser.add_argument("--source", default="all",
|
|
2113
|
+
help="Log source: all | events | operations | <logname>")
|
|
2114
|
+
logs_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2115
|
+
|
|
1947
2116
|
args = parser.parse_args()
|
|
1948
2117
|
|
|
1949
2118
|
if args.help or (not args.command and not args.version):
|
|
@@ -2037,6 +2206,20 @@ def main():
|
|
|
2037
2206
|
return _uninstall(args)
|
|
2038
2207
|
elif args.command == "dashboard":
|
|
2039
2208
|
return _dashboard(args)
|
|
2209
|
+
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
|
2210
|
+
from desktop_bridge import cmd_schema, cmd_identity, cmd_onboard, cmd_scan_profile
|
|
2211
|
+
return {
|
|
2212
|
+
"schema": cmd_schema,
|
|
2213
|
+
"identity": cmd_identity,
|
|
2214
|
+
"onboard": cmd_onboard,
|
|
2215
|
+
"scan-profile": cmd_scan_profile,
|
|
2216
|
+
}[args.command](args)
|
|
2217
|
+
elif args.command == "notify":
|
|
2218
|
+
return _notify(args)
|
|
2219
|
+
elif args.command == "health":
|
|
2220
|
+
return _health(args)
|
|
2221
|
+
elif args.command == "logs":
|
|
2222
|
+
return _logs(args)
|
|
2040
2223
|
else:
|
|
2041
2224
|
_print_help()
|
|
2042
2225
|
return 0
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Desktop bridge — read-only commands for NEXO Desktop (Electron UI).
|
|
2
|
+
|
|
3
|
+
Exposes four read-only commands so Desktop can auto-adapt its UI:
|
|
4
|
+
nexo schema --json → editable-field schema for Preferences UI
|
|
5
|
+
nexo identity --json → canonical assistant identity + source path
|
|
6
|
+
nexo onboard --json → onboarding wizard steps
|
|
7
|
+
nexo scan-profile → build profile.json from CLAUDE.md + memory
|
|
8
|
+
|
|
9
|
+
All commands honor NEXO_HOME. None mutate state unless --apply is passed
|
|
10
|
+
on scan-profile (default is dry-run: prints the proposed payload).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
ONBOARD_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _nexo_home() -> Path:
|
|
26
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _calibration_path() -> Path:
|
|
30
|
+
return _nexo_home() / "brain" / "calibration.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _profile_path() -> Path:
|
|
34
|
+
return _nexo_home() / "brain" / "profile.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_json(path: Path) -> dict:
|
|
38
|
+
try:
|
|
39
|
+
if path.is_file():
|
|
40
|
+
return json.loads(path.read_text())
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_json(payload: Any) -> int:
|
|
47
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ schema
|
|
52
|
+
|
|
53
|
+
def _schema_fields() -> list[dict]:
|
|
54
|
+
"""Canonical list of editable fields across calibration.json / profile.json.
|
|
55
|
+
|
|
56
|
+
Each field declares its storage path (dot notation) and which file it
|
|
57
|
+
lives in, so Desktop can read/write precisely without guessing.
|
|
58
|
+
"""
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
"path": "user.name",
|
|
62
|
+
"file": "calibration.json",
|
|
63
|
+
"label": {"es": "Nombre", "en": "Name"},
|
|
64
|
+
"type": "text",
|
|
65
|
+
"group": "personal",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "user.language",
|
|
69
|
+
"file": "calibration.json",
|
|
70
|
+
"label": {"es": "Idioma", "en": "Language"},
|
|
71
|
+
"type": "select",
|
|
72
|
+
"group": "personal",
|
|
73
|
+
"options": [
|
|
74
|
+
{"value": "es", "label": {"es": "Español", "en": "Spanish"}},
|
|
75
|
+
{"value": "en", "label": {"es": "Inglés", "en": "English"}},
|
|
76
|
+
{"value": "ca", "label": {"es": "Catalán", "en": "Catalan"}},
|
|
77
|
+
{"value": "fr", "label": {"es": "Francés", "en": "French"}},
|
|
78
|
+
{"value": "de", "label": {"es": "Alemán", "en": "German"}},
|
|
79
|
+
{"value": "it", "label": {"es": "Italiano", "en": "Italian"}},
|
|
80
|
+
{"value": "pt", "label": {"es": "Portugués", "en": "Portuguese"}},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"path": "user.timezone",
|
|
85
|
+
"file": "calibration.json",
|
|
86
|
+
"label": {"es": "Zona horaria", "en": "Timezone"},
|
|
87
|
+
"type": "text",
|
|
88
|
+
"group": "personal",
|
|
89
|
+
"default": "Europe/Madrid",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"path": "user.assistant_name",
|
|
93
|
+
"file": "calibration.json",
|
|
94
|
+
"label": {"es": "Nombre del asistente", "en": "Assistant name"},
|
|
95
|
+
"type": "text",
|
|
96
|
+
"group": "personal",
|
|
97
|
+
"default": "NEXO",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"path": "personality.autonomy",
|
|
101
|
+
"file": "calibration.json",
|
|
102
|
+
"label": {"es": "Autonomía", "en": "Autonomy"},
|
|
103
|
+
"type": "select",
|
|
104
|
+
"group": "personality",
|
|
105
|
+
"options": [
|
|
106
|
+
{"value": "conservative", "label": {"es": "Conservadora", "en": "Conservative"}},
|
|
107
|
+
{"value": "balanced", "label": {"es": "Equilibrada", "en": "Balanced"}},
|
|
108
|
+
{"value": "full", "label": {"es": "Plena", "en": "Full"}},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"path": "personality.communication",
|
|
113
|
+
"file": "calibration.json",
|
|
114
|
+
"label": {"es": "Comunicación", "en": "Communication"},
|
|
115
|
+
"type": "select",
|
|
116
|
+
"group": "personality",
|
|
117
|
+
"options": [
|
|
118
|
+
{"value": "concise", "label": {"es": "Concisa", "en": "Concise"}},
|
|
119
|
+
{"value": "balanced", "label": {"es": "Equilibrada", "en": "Balanced"}},
|
|
120
|
+
{"value": "detailed", "label": {"es": "Detallada", "en": "Detailed"}},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"path": "personality.honesty",
|
|
125
|
+
"file": "calibration.json",
|
|
126
|
+
"label": {"es": "Honestidad", "en": "Honesty"},
|
|
127
|
+
"type": "select",
|
|
128
|
+
"group": "personality",
|
|
129
|
+
"options": [
|
|
130
|
+
{"value": "firm-pushback", "label": {"es": "Firme", "en": "Firm pushback"}},
|
|
131
|
+
{"value": "mention-and-follow", "label": {"es": "Menciona y sigue", "en": "Mention and follow"}},
|
|
132
|
+
{"value": "just-execute", "label": {"es": "Solo ejecuta", "en": "Just execute"}},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"path": "personality.proactivity",
|
|
137
|
+
"file": "calibration.json",
|
|
138
|
+
"label": {"es": "Proactividad", "en": "Proactivity"},
|
|
139
|
+
"type": "select",
|
|
140
|
+
"group": "personality",
|
|
141
|
+
"options": [
|
|
142
|
+
{"value": "reactive", "label": {"es": "Reactiva", "en": "Reactive"}},
|
|
143
|
+
{"value": "suggestive", "label": {"es": "Sugerente", "en": "Suggestive"}},
|
|
144
|
+
{"value": "proactive", "label": {"es": "Proactiva", "en": "Proactive"}},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"path": "personality.error_handling",
|
|
149
|
+
"file": "calibration.json",
|
|
150
|
+
"label": {"es": "Errores", "en": "Error handling"},
|
|
151
|
+
"type": "select",
|
|
152
|
+
"group": "personality",
|
|
153
|
+
"options": [
|
|
154
|
+
{"value": "brief-fix", "label": {"es": "Arreglo breve", "en": "Brief fix"}},
|
|
155
|
+
{"value": "explain-and-learn", "label": {"es": "Explica y aprende", "en": "Explain and learn"}},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"path": "preferences.menu_on_demand",
|
|
160
|
+
"file": "calibration.json",
|
|
161
|
+
"label": {"es": "Menú bajo demanda", "en": "Menu on demand"},
|
|
162
|
+
"type": "boolean",
|
|
163
|
+
"group": "preferences",
|
|
164
|
+
"default": True,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"path": "preferences.show_pending_items",
|
|
168
|
+
"file": "calibration.json",
|
|
169
|
+
"label": {"es": "Mostrar pendientes al inicio", "en": "Show pending items at startup"},
|
|
170
|
+
"type": "boolean",
|
|
171
|
+
"group": "preferences",
|
|
172
|
+
"default": False,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"path": "preferences.execution_first",
|
|
176
|
+
"file": "calibration.json",
|
|
177
|
+
"label": {"es": "Ejecuta antes de preguntar", "en": "Execute before asking"},
|
|
178
|
+
"type": "boolean",
|
|
179
|
+
"group": "preferences",
|
|
180
|
+
"default": True,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"path": "preferences.report_style",
|
|
184
|
+
"file": "calibration.json",
|
|
185
|
+
"label": {"es": "Estilo de reporte", "en": "Report style"},
|
|
186
|
+
"type": "select",
|
|
187
|
+
"group": "preferences",
|
|
188
|
+
"options": [
|
|
189
|
+
{"value": "essentials_only", "label": {"es": "Solo esencial", "en": "Essentials only"}},
|
|
190
|
+
{"value": "balanced", "label": {"es": "Equilibrado", "en": "Balanced"}},
|
|
191
|
+
{"value": "verbose", "label": {"es": "Detallado", "en": "Verbose"}},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"path": "meta.role",
|
|
196
|
+
"file": "calibration.json",
|
|
197
|
+
"label": {"es": "Rol / ocupación", "en": "Role / occupation"},
|
|
198
|
+
"type": "text",
|
|
199
|
+
"group": "about_you",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"path": "meta.technical_level",
|
|
203
|
+
"file": "calibration.json",
|
|
204
|
+
"label": {"es": "Nivel técnico", "en": "Technical level"},
|
|
205
|
+
"type": "select",
|
|
206
|
+
"group": "about_you",
|
|
207
|
+
"options": [
|
|
208
|
+
{"value": "beginner", "label": {"es": "Principiante", "en": "Beginner"}},
|
|
209
|
+
{"value": "intermediate", "label": {"es": "Intermedio", "en": "Intermediate"}},
|
|
210
|
+
{"value": "advanced", "label": {"es": "Avanzado", "en": "Advanced"}},
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _schema_groups() -> list[dict]:
|
|
217
|
+
return [
|
|
218
|
+
{"id": "personal", "label": {"es": "Personal", "en": "Personal"}, "order": 1},
|
|
219
|
+
{"id": "personality", "label": {"es": "Personalidad", "en": "Personality"}, "order": 2},
|
|
220
|
+
{"id": "preferences", "label": {"es": "Preferencias", "en": "Preferences"}, "order": 3},
|
|
221
|
+
{"id": "about_you", "label": {"es": "Sobre ti", "en": "About you"}, "order": 4},
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_schema(args) -> int:
|
|
226
|
+
payload = {
|
|
227
|
+
"schema_version": SCHEMA_VERSION,
|
|
228
|
+
"groups": _schema_groups(),
|
|
229
|
+
"fields": _schema_fields(),
|
|
230
|
+
}
|
|
231
|
+
return _print_json(payload)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------- identity
|
|
235
|
+
|
|
236
|
+
def _resolve_identity() -> dict:
|
|
237
|
+
"""Resolve the canonical assistant name + which source produced it."""
|
|
238
|
+
cal = _read_json(_calibration_path())
|
|
239
|
+
prof = _read_json(_profile_path())
|
|
240
|
+
|
|
241
|
+
probes: list[tuple[str, Any]] = [
|
|
242
|
+
("calibration.user.assistant_name",
|
|
243
|
+
cal.get("user", {}).get("assistant_name") if isinstance(cal.get("user"), dict) else None),
|
|
244
|
+
("calibration.operator_name", cal.get("operator_name")),
|
|
245
|
+
("calibration.assistant_name", cal.get("assistant_name")),
|
|
246
|
+
("calibration.identity", cal.get("identity")),
|
|
247
|
+
("profile.operator_name", prof.get("operator_name")),
|
|
248
|
+
("profile.assistant_name", prof.get("assistant_name")),
|
|
249
|
+
]
|
|
250
|
+
for source, value in probes:
|
|
251
|
+
if isinstance(value, str) and value.strip():
|
|
252
|
+
return {"name": value.strip(), "source": source,
|
|
253
|
+
"writable_source": "calibration.user.assistant_name"}
|
|
254
|
+
|
|
255
|
+
return {"name": "NEXO", "source": "default",
|
|
256
|
+
"writable_source": "calibration.user.assistant_name"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_identity(args) -> int:
|
|
260
|
+
return _print_json(_resolve_identity())
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------- onboard
|
|
264
|
+
|
|
265
|
+
def _onboard_steps() -> list[dict]:
|
|
266
|
+
return [
|
|
267
|
+
{
|
|
268
|
+
"id": "name",
|
|
269
|
+
"prompt": {"es": "¿Cómo te llamas?", "en": "What's your name?"},
|
|
270
|
+
"type": "text",
|
|
271
|
+
"writes": "user.name",
|
|
272
|
+
"file": "calibration.json",
|
|
273
|
+
"optional": False,
|
|
274
|
+
"validate": r"^\S.{0,60}$",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"id": "language",
|
|
278
|
+
"prompt": {"es": "¿En qué idioma quieres operar?", "en": "Which language should we use?"},
|
|
279
|
+
"type": "select",
|
|
280
|
+
"writes": "user.language",
|
|
281
|
+
"file": "calibration.json",
|
|
282
|
+
"optional": False,
|
|
283
|
+
"default": "es",
|
|
284
|
+
"options": [
|
|
285
|
+
{"value": "es", "label": {"es": "Español", "en": "Spanish"}},
|
|
286
|
+
{"value": "en", "label": {"es": "Inglés", "en": "English"}},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"id": "assistant_name",
|
|
291
|
+
"prompt": {"es": "¿Cómo se llamará tu asistente?", "en": "What will your assistant be called?"},
|
|
292
|
+
"type": "text",
|
|
293
|
+
"writes": "user.assistant_name",
|
|
294
|
+
"file": "calibration.json",
|
|
295
|
+
"optional": True,
|
|
296
|
+
"default": "NEXO",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"id": "role",
|
|
300
|
+
"prompt": {"es": "¿A qué te dedicas?", "en": "What do you do?"},
|
|
301
|
+
"type": "text",
|
|
302
|
+
"writes": "meta.role",
|
|
303
|
+
"file": "calibration.json",
|
|
304
|
+
"optional": True,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"id": "technical_level",
|
|
308
|
+
"prompt": {"es": "¿Cuál es tu nivel técnico?", "en": "What's your technical level?"},
|
|
309
|
+
"type": "select",
|
|
310
|
+
"writes": "meta.technical_level",
|
|
311
|
+
"file": "calibration.json",
|
|
312
|
+
"optional": True,
|
|
313
|
+
"default": "intermediate",
|
|
314
|
+
"options": [
|
|
315
|
+
{"value": "beginner", "label": {"es": "Principiante", "en": "Beginner"}},
|
|
316
|
+
{"value": "intermediate", "label": {"es": "Intermedio", "en": "Intermediate"}},
|
|
317
|
+
{"value": "advanced", "label": {"es": "Avanzado", "en": "Advanced"}},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"id": "welcome",
|
|
322
|
+
"type": "welcome",
|
|
323
|
+
"message": {
|
|
324
|
+
"es": "Listo. A partir de ahora aprendo observándote. Dime qué necesitas.",
|
|
325
|
+
"en": "Ready. From now on I learn by watching. Tell me what you need.",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_onboard(args) -> int:
|
|
332
|
+
payload = {
|
|
333
|
+
"onboard_version": ONBOARD_VERSION,
|
|
334
|
+
"steps": _onboard_steps(),
|
|
335
|
+
}
|
|
336
|
+
return _print_json(payload)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------ scan-profile
|
|
340
|
+
|
|
341
|
+
# Patterns we try to lift from the user's CLAUDE.md into profile.json.
|
|
342
|
+
# Kept deliberately narrow to avoid false positives.
|
|
343
|
+
_CLAUDE_MD_CANDIDATES = [
|
|
344
|
+
Path.home() / ".claude" / "CLAUDE.md",
|
|
345
|
+
Path.home() / "CLAUDE.md",
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _read_claude_md() -> str:
|
|
350
|
+
for p in _CLAUDE_MD_CANDIDATES:
|
|
351
|
+
try:
|
|
352
|
+
if p.is_file():
|
|
353
|
+
return p.read_text(errors="ignore")
|
|
354
|
+
except Exception:
|
|
355
|
+
continue
|
|
356
|
+
return ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _guess_role(claude_md: str, cal: dict) -> str:
|
|
360
|
+
meta_role = cal.get("meta", {}).get("role") if isinstance(cal.get("meta"), dict) else None
|
|
361
|
+
if isinstance(meta_role, str) and meta_role.strip():
|
|
362
|
+
return meta_role.strip()
|
|
363
|
+
m = re.search(r"(?im)^\s*[-*]?\s*(?:role|rol|ocupaci[oó]n|dedica)\s*[:=]\s*(.+)$", claude_md)
|
|
364
|
+
if m:
|
|
365
|
+
return m.group(1).strip().strip(".")
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _guess_technical_level(claude_md: str, cal: dict) -> str:
|
|
370
|
+
meta_tl = cal.get("meta", {}).get("technical_level") if isinstance(cal.get("meta"), dict) else None
|
|
371
|
+
if isinstance(meta_tl, str) and meta_tl.strip():
|
|
372
|
+
return meta_tl.strip()
|
|
373
|
+
text = claude_md.lower()
|
|
374
|
+
if "advanced" in text or "avanzado" in text or "senior" in text:
|
|
375
|
+
return "advanced"
|
|
376
|
+
if "intermediate" in text or "intermedio" in text:
|
|
377
|
+
return "intermediate"
|
|
378
|
+
if "beginner" in text or "principiante" in text:
|
|
379
|
+
return "beginner"
|
|
380
|
+
return ""
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _guess_timezone(cal: dict) -> str:
|
|
384
|
+
tz = cal.get("user", {}).get("timezone") if isinstance(cal.get("user"), dict) else None
|
|
385
|
+
if isinstance(tz, str) and tz.strip():
|
|
386
|
+
return tz.strip()
|
|
387
|
+
return os.environ.get("TZ", "") or ""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _build_profile_payload() -> dict:
|
|
391
|
+
cal = _read_json(_calibration_path())
|
|
392
|
+
claude_md = _read_claude_md()
|
|
393
|
+
|
|
394
|
+
user = cal.get("user", {}) if isinstance(cal.get("user"), dict) else {}
|
|
395
|
+
payload = {
|
|
396
|
+
"version": 1,
|
|
397
|
+
"source": "scan-profile",
|
|
398
|
+
"user": {
|
|
399
|
+
"name": user.get("name", "") or "",
|
|
400
|
+
"language": user.get("language", "") or "",
|
|
401
|
+
"timezone": _guess_timezone(cal),
|
|
402
|
+
},
|
|
403
|
+
"meta": {
|
|
404
|
+
"role": _guess_role(claude_md, cal),
|
|
405
|
+
"technical_level": _guess_technical_level(claude_md, cal),
|
|
406
|
+
},
|
|
407
|
+
"signals": {
|
|
408
|
+
"has_claude_md": bool(claude_md),
|
|
409
|
+
"claude_md_chars": len(claude_md),
|
|
410
|
+
"calibration_present": bool(cal),
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
return payload
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_scan_profile(args) -> int:
|
|
417
|
+
profile_path = _profile_path()
|
|
418
|
+
payload = _build_profile_payload()
|
|
419
|
+
use_json = bool(getattr(args, "json", False))
|
|
420
|
+
apply = bool(getattr(args, "apply", False))
|
|
421
|
+
force = bool(getattr(args, "force", False))
|
|
422
|
+
|
|
423
|
+
status = "preview"
|
|
424
|
+
written = False
|
|
425
|
+
reason = ""
|
|
426
|
+
|
|
427
|
+
if apply:
|
|
428
|
+
if profile_path.exists() and not force:
|
|
429
|
+
status = "skipped"
|
|
430
|
+
reason = "profile.json already exists (use --force to overwrite)"
|
|
431
|
+
else:
|
|
432
|
+
try:
|
|
433
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
profile_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
435
|
+
status = "written"
|
|
436
|
+
written = True
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
status = "error"
|
|
439
|
+
reason = f"write failed: {exc}"
|
|
440
|
+
|
|
441
|
+
result = {
|
|
442
|
+
"status": status,
|
|
443
|
+
"path": str(profile_path),
|
|
444
|
+
"written": written,
|
|
445
|
+
"reason": reason,
|
|
446
|
+
"payload": payload,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if use_json:
|
|
450
|
+
return _print_json(result)
|
|
451
|
+
|
|
452
|
+
# Human-readable fallback
|
|
453
|
+
sys.stdout.write(f"scan-profile: {status}\n")
|
|
454
|
+
sys.stdout.write(f" path: {profile_path}\n")
|
|
455
|
+
if reason:
|
|
456
|
+
sys.stdout.write(f" reason: {reason}\n")
|
|
457
|
+
sys.stdout.write(" preview:\n")
|
|
458
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
459
|
+
return 0 if status != "error" else 1
|