nexo-brain 5.3.28 → 5.3.30
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/cli.py +23 -0
- package/src/desktop_bridge.py +459 -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
|
@@ -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
|
package/src/plugin_loader.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
|
|
11
11
|
from db import get_db
|
|
12
12
|
from fastmcp.tools import Tool
|
|
13
|
+
from tree_hygiene import is_duplicate_artifact_name
|
|
13
14
|
|
|
14
15
|
SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
16
|
PLUGINS_DIR = os.path.join(SERVER_DIR, "plugins")
|
|
@@ -47,12 +48,16 @@ def load_all_plugins(mcp) -> int:
|
|
|
47
48
|
if os.path.isdir(PLUGINS_DIR):
|
|
48
49
|
for f in sorted(os.listdir(PLUGINS_DIR)):
|
|
49
50
|
if f.endswith(".py") and f != "__init__.py":
|
|
51
|
+
if is_duplicate_artifact_name(os.path.join(PLUGINS_DIR, f)):
|
|
52
|
+
continue
|
|
50
53
|
plugin_map[f] = (PLUGINS_DIR, "repo")
|
|
51
54
|
|
|
52
55
|
# 2. Personal plugins (override if same filename)
|
|
53
56
|
if os.path.isdir(PERSONAL_PLUGINS_DIR):
|
|
54
57
|
for f in sorted(os.listdir(PERSONAL_PLUGINS_DIR)):
|
|
55
58
|
if f.endswith(".py") and f != "__init__.py":
|
|
59
|
+
if is_duplicate_artifact_name(os.path.join(PERSONAL_PLUGINS_DIR, f)):
|
|
60
|
+
continue
|
|
56
61
|
source = "personal (override)" if f in plugin_map else "personal"
|
|
57
62
|
plugin_map[f] = (PERSONAL_PLUGINS_DIR, source)
|
|
58
63
|
|
package/src/plugins/update.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from runtime_home import export_resolved_nexo_home
|
|
13
|
+
from tree_hygiene import is_duplicate_artifact_name
|
|
13
14
|
|
|
14
15
|
# Code root is the parent of plugins/:
|
|
15
16
|
# - source checkout: <repo>/src
|
|
@@ -100,7 +101,7 @@ def _refresh_installed_manifest():
|
|
|
100
101
|
if src_crons.exists():
|
|
101
102
|
dst_crons.mkdir(parents=True, exist_ok=True)
|
|
102
103
|
for f in src_crons.iterdir():
|
|
103
|
-
if f.is_file():
|
|
104
|
+
if f.is_file() and not is_duplicate_artifact_name(f):
|
|
104
105
|
dest = dst_crons / f.name
|
|
105
106
|
if _paths_match(f, dest):
|
|
106
107
|
continue
|
|
@@ -111,11 +112,11 @@ def _refresh_installed_manifest():
|
|
|
111
112
|
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
112
113
|
"script_names": sorted(
|
|
113
114
|
f.name for f in (artifact_src / "scripts").iterdir()
|
|
114
|
-
if f.is_file()
|
|
115
|
+
if f.is_file() and not is_duplicate_artifact_name(f)
|
|
115
116
|
) if (artifact_src / "scripts").is_dir() else [],
|
|
116
117
|
"hook_names": sorted(
|
|
117
118
|
f.name for f in (artifact_src / "hooks").iterdir()
|
|
118
|
-
if f.is_file()
|
|
119
|
+
if f.is_file() and not is_duplicate_artifact_name(f)
|
|
119
120
|
) if (artifact_src / "hooks").is_dir() else [],
|
|
120
121
|
}
|
|
121
122
|
(config_dir / "runtime-core-artifacts.json").write_text(
|
|
@@ -368,7 +369,7 @@ def _sync_hooks_to_home():
|
|
|
368
369
|
hooks_dest.mkdir(parents=True, exist_ok=True)
|
|
369
370
|
synced = 0
|
|
370
371
|
for f in hooks_src.iterdir():
|
|
371
|
-
if f.is_file() and f.suffix == ".sh":
|
|
372
|
+
if f.is_file() and f.suffix == ".sh" and not is_duplicate_artifact_name(f):
|
|
372
373
|
dest = hooks_dest / f.name
|
|
373
374
|
if not _paths_match(f, dest):
|
|
374
375
|
shutil.copy2(str(f), str(dest))
|
|
@@ -13,6 +13,7 @@ shift
|
|
|
13
13
|
|
|
14
14
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
15
15
|
DB="$NEXO_HOME/data/nexo.db"
|
|
16
|
+
SPOOL_DIR="$NEXO_HOME/operations/cron-spool"
|
|
16
17
|
|
|
17
18
|
# Unlock macOS Keychain so headless Claude Code can read auth tokens.
|
|
18
19
|
# Claude Code stores its API key in the login keychain which auto-locks.
|
|
@@ -21,40 +22,95 @@ if [ -f "$KEYCHAIN_PASS_FILE" ] && [ "$(uname)" = "Darwin" ]; then
|
|
|
21
22
|
security unlock-keychain -p "$(cat "$KEYCHAIN_PASS_FILE")" ~/Library/Keychains/login.keychain-db 2>/dev/null || true
|
|
22
23
|
fi
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
START_EPOCH=$(python3 - <<'PY'
|
|
26
|
+
import time
|
|
27
|
+
print(f"{time.time():.6f}")
|
|
28
|
+
PY
|
|
29
|
+
)
|
|
30
|
+
STARTED_AT=$(python3 - <<'PY'
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
|
|
33
|
+
PY
|
|
34
|
+
)
|
|
31
35
|
|
|
32
36
|
# Run the actual command, capture output
|
|
33
37
|
OUTPUT_FILE=$(mktemp)
|
|
38
|
+
trap 'rm -f "$OUTPUT_FILE"' EXIT
|
|
34
39
|
"$@" > "$OUTPUT_FILE" 2>&1
|
|
35
40
|
EXIT_CODE=$?
|
|
41
|
+
ENDED_AT=$(python3 - <<'PY'
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
|
|
44
|
+
PY
|
|
45
|
+
)
|
|
46
|
+
DURATION_SECS=$(python3 - <<PY
|
|
47
|
+
start = float("$START_EPOCH")
|
|
48
|
+
import time
|
|
49
|
+
print(round(time.time() - start, 1))
|
|
50
|
+
PY
|
|
51
|
+
)
|
|
36
52
|
|
|
37
53
|
# Extract summary (last meaningful line, max 500 chars)
|
|
38
|
-
SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500
|
|
54
|
+
SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500)
|
|
39
55
|
|
|
40
56
|
# Extract error if failed
|
|
41
57
|
ERROR=""
|
|
42
58
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
43
|
-
ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500
|
|
59
|
+
ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500)
|
|
44
60
|
fi
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
if ! python3 - "$DB" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import sqlite3
|
|
66
|
+
import sys
|
|
67
|
+
|
|
68
|
+
db_path, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
69
|
+
conn = sqlite3.connect(db_path)
|
|
70
|
+
try:
|
|
71
|
+
conn.execute(
|
|
72
|
+
"""
|
|
73
|
+
INSERT INTO cron_runs (
|
|
74
|
+
cron_id, started_at, ended_at, exit_code, summary, error, duration_secs
|
|
75
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
""",
|
|
77
|
+
(
|
|
78
|
+
cron_id,
|
|
79
|
+
started_at,
|
|
80
|
+
ended_at,
|
|
81
|
+
int(exit_code),
|
|
82
|
+
summary,
|
|
83
|
+
error,
|
|
84
|
+
float(duration_secs),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
conn.commit()
|
|
88
|
+
finally:
|
|
89
|
+
conn.close()
|
|
90
|
+
PY
|
|
91
|
+
then
|
|
92
|
+
mkdir -p "$SPOOL_DIR"
|
|
93
|
+
SPOOL_FILE="$SPOOL_DIR/${CRON_ID}-$(date +%Y%m%d-%H%M%S)-$$.json"
|
|
94
|
+
python3 - "$SPOOL_FILE" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import json
|
|
98
|
+
import sys
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
spool_file, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
102
|
+
payload = {
|
|
103
|
+
"cron_id": cron_id,
|
|
104
|
+
"started_at": started_at,
|
|
105
|
+
"ended_at": ended_at,
|
|
106
|
+
"exit_code": int(exit_code),
|
|
107
|
+
"summary": summary,
|
|
108
|
+
"error": error,
|
|
109
|
+
"duration_secs": float(duration_secs),
|
|
110
|
+
}
|
|
111
|
+
Path(spool_file).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
112
|
+
PY
|
|
113
|
+
echo "[nexo-cron-wrapper] DB write failed; spooled run to $SPOOL_FILE" >&2
|
|
114
|
+
fi
|
|
59
115
|
|
|
60
116
|
exit $EXIT_CODE
|