nexo-brain 5.3.27 → 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/hook_guardrails.py +44 -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/hook_guardrails.py
CHANGED
|
@@ -225,6 +225,23 @@ def _session_has_guard_check(conn, sid: str) -> bool:
|
|
|
225
225
|
return bool(row)
|
|
226
226
|
|
|
227
227
|
|
|
228
|
+
def _session_has_guard_for_file(conn, sid: str, filepath: str) -> bool:
|
|
229
|
+
"""Check if guard_check was called for a specific file in this session."""
|
|
230
|
+
if not filepath:
|
|
231
|
+
return False
|
|
232
|
+
normalized = _normalize_file_path(filepath)
|
|
233
|
+
basename = os.path.basename(filepath)
|
|
234
|
+
# guard_checks.files is a comma-separated or JSON list of paths/areas
|
|
235
|
+
row = conn.execute(
|
|
236
|
+
"""SELECT 1 FROM guard_checks
|
|
237
|
+
WHERE session_id = ?
|
|
238
|
+
AND (files LIKE ? OR files LIKE ? OR files LIKE ?)
|
|
239
|
+
LIMIT 1""",
|
|
240
|
+
(sid, f"%{normalized}%", f"%{basename}%", f"%{filepath}%"),
|
|
241
|
+
).fetchone()
|
|
242
|
+
return bool(row)
|
|
243
|
+
|
|
244
|
+
|
|
228
245
|
def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
|
|
229
246
|
row = conn.execute(
|
|
230
247
|
"""SELECT *
|
|
@@ -548,6 +565,28 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
548
565
|
"reason_code": "guard_unacknowledged",
|
|
549
566
|
}
|
|
550
567
|
)
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Check if guard_check was called for this specific file
|
|
571
|
+
if not _session_has_guard_for_file(conn, sid, filepath):
|
|
572
|
+
debt = _ensure_protocol_debt(
|
|
573
|
+
conn,
|
|
574
|
+
session_id=sid,
|
|
575
|
+
task_id=task["task_id"],
|
|
576
|
+
debt_type="write_without_file_guard_check",
|
|
577
|
+
severity="warn",
|
|
578
|
+
evidence=f"{tool_name} attempted on {filepath} without a prior guard_check covering that file.",
|
|
579
|
+
file_token=filepath,
|
|
580
|
+
)
|
|
581
|
+
blocks.append(
|
|
582
|
+
{
|
|
583
|
+
"file": filepath,
|
|
584
|
+
"task_id": task["task_id"],
|
|
585
|
+
"debt_id": debt.get("id"),
|
|
586
|
+
"debt_type": "write_without_file_guard_check",
|
|
587
|
+
"reason_code": "missing_file_guard",
|
|
588
|
+
}
|
|
589
|
+
)
|
|
551
590
|
|
|
552
591
|
return {
|
|
553
592
|
"ok": True,
|
|
@@ -728,6 +767,11 @@ def format_pretool_block_message(result: dict) -> str:
|
|
|
728
767
|
lines.append(
|
|
729
768
|
f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
|
|
730
769
|
)
|
|
770
|
+
elif item.get("reason_code") == "missing_file_guard":
|
|
771
|
+
lines.append(
|
|
772
|
+
f"- {file_note}: `nexo_guard_check` obligatorio antes de editar. "
|
|
773
|
+
f"Run `nexo_guard_check(files='{file_note}')` first, then retry the edit."
|
|
774
|
+
)
|
|
731
775
|
elif strictness == "learning":
|
|
732
776
|
lines.append(
|
|
733
777
|
f"- {file_note}: open `nexo_task_open(task_type='edit', files=['{file_note}'])` first, then rerun the edit."
|
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))
|