nexo-brain 3.2.0 → 4.0.1

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.
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
 
4
4
  import ast
5
5
  import importlib.util
6
+ import inspect
6
7
  import json
7
8
  import os
9
+ import re
8
10
  import sys
9
11
  from pathlib import Path
10
12
 
@@ -28,6 +30,8 @@ SECTION_ORDER = (
28
30
  "artifacts",
29
31
  )
30
32
 
33
+ _DOC_ARG_LINE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$")
34
+
31
35
 
32
36
  def _normalize_text(text: str | None) -> str:
33
37
  return str(text or "").strip().lower()
@@ -84,6 +88,287 @@ def _tool_category(name: str) -> str:
84
88
  return "general"
85
89
 
86
90
 
91
+ def _annotation_text_from_ast(node: ast.AST | None) -> str:
92
+ if node is None:
93
+ return ""
94
+ try:
95
+ return ast.unparse(node)
96
+ except Exception:
97
+ return ""
98
+
99
+
100
+ def _literal_text(value) -> str:
101
+ if value is inspect._empty:
102
+ return ""
103
+ if isinstance(value, str):
104
+ return json.dumps(value, ensure_ascii=False)
105
+ if value is None:
106
+ return "None"
107
+ return repr(value)
108
+
109
+
110
+ def _default_text_from_ast(node: ast.AST | None) -> str:
111
+ if node is None:
112
+ return ""
113
+ try:
114
+ return _literal_text(ast.literal_eval(node))
115
+ except Exception:
116
+ try:
117
+ return ast.unparse(node)
118
+ except Exception:
119
+ return "..."
120
+
121
+
122
+ def _annotation_text(annotation) -> str:
123
+ if annotation is inspect._empty:
124
+ return ""
125
+ if isinstance(annotation, str):
126
+ return annotation
127
+ if getattr(annotation, "__module__", "") == "builtins" and hasattr(annotation, "__name__"):
128
+ return str(annotation.__name__)
129
+ text = str(annotation)
130
+ return text.replace("typing.", "")
131
+
132
+
133
+ def _parse_arg_docs(doc: str) -> dict[str, str]:
134
+ docs: dict[str, str] = {}
135
+ if not doc.strip():
136
+ return docs
137
+ in_args = False
138
+ current_arg = ""
139
+ for raw_line in doc.splitlines():
140
+ line = raw_line.rstrip()
141
+ stripped = line.strip()
142
+ if stripped in {"Args:", "Arguments:"}:
143
+ in_args = True
144
+ current_arg = ""
145
+ continue
146
+ if not in_args:
147
+ continue
148
+ if stripped and not raw_line.startswith((" ", "\t")) and stripped.endswith(":"):
149
+ break
150
+ if not stripped:
151
+ current_arg = ""
152
+ continue
153
+ match = _DOC_ARG_LINE_RE.match(raw_line)
154
+ if match:
155
+ current_arg = match.group(1)
156
+ docs[current_arg] = match.group(2).strip()
157
+ continue
158
+ if current_arg:
159
+ docs[current_arg] = f"{docs[current_arg]} {stripped}".strip()
160
+ return docs
161
+
162
+
163
+ def _build_signature(name: str, params: list[dict], return_annotation: str = "") -> str:
164
+ pieces: list[str] = []
165
+ for param in params:
166
+ part = param["name"]
167
+ if param.get("annotation"):
168
+ part += f": {param['annotation']}"
169
+ if not param.get("required", False):
170
+ part += f" = {param.get('default', '')}"
171
+ pieces.append(part)
172
+ signature = f"{name}({', '.join(pieces)})"
173
+ if return_annotation:
174
+ signature += f" -> {return_annotation}"
175
+ return signature
176
+
177
+
178
+ def _example_value_for_param(param: dict) -> str:
179
+ name = str(param.get("name", "value"))
180
+ annotation = str(param.get("annotation", "")).lower()
181
+ if name == "id" or name.endswith("_id"):
182
+ return '"..."'
183
+ if name.endswith("_token") or name == "read_token":
184
+ return '"TOKEN"'
185
+ if "bool" in annotation:
186
+ return "True"
187
+ if "int" in annotation:
188
+ return "1"
189
+ if "float" in annotation:
190
+ return "1.0"
191
+ if "list" in annotation or name.endswith("s"):
192
+ return '["..."]'
193
+ if "dict" in annotation or "object" in annotation:
194
+ return '{"key": "value"}'
195
+ return '"..."'
196
+
197
+
198
+ def _generic_example(name: str, params: list[dict]) -> str:
199
+ required = [param for param in params if param.get("required", False)]
200
+ if not required:
201
+ return f"{name}()"
202
+ pieces = [f"{param['name']}={_example_value_for_param(param)}" for param in required]
203
+ return f"{name}({', '.join(pieces)})"
204
+
205
+
206
+ def _ast_params_for_node(node: ast.FunctionDef, arg_docs: dict[str, str]) -> list[dict]:
207
+ params: list[dict] = []
208
+ positional = list(node.args.posonlyargs) + list(node.args.args)
209
+ positional_defaults = [None] * (len(positional) - len(node.args.defaults)) + list(node.args.defaults)
210
+ for arg_node, default_node in zip(positional, positional_defaults):
211
+ if arg_node.arg in {"self", "cls"}:
212
+ continue
213
+ params.append(
214
+ {
215
+ "name": arg_node.arg,
216
+ "annotation": _annotation_text_from_ast(arg_node.annotation),
217
+ "required": default_node is None,
218
+ "default": "" if default_node is None else _default_text_from_ast(default_node),
219
+ "description": arg_docs.get(arg_node.arg, ""),
220
+ }
221
+ )
222
+ for arg_node, default_node in zip(node.args.kwonlyargs, node.args.kw_defaults):
223
+ if arg_node.arg in {"self", "cls"}:
224
+ continue
225
+ params.append(
226
+ {
227
+ "name": arg_node.arg,
228
+ "annotation": _annotation_text_from_ast(arg_node.annotation),
229
+ "required": default_node is None,
230
+ "default": "" if default_node is None else _default_text_from_ast(default_node),
231
+ "description": arg_docs.get(arg_node.arg, ""),
232
+ }
233
+ )
234
+ return params
235
+
236
+
237
+ def _callable_params(func, arg_docs: dict[str, str]) -> list[dict]:
238
+ params: list[dict] = []
239
+ try:
240
+ signature = inspect.signature(func)
241
+ except (TypeError, ValueError):
242
+ return params
243
+ for name, param in signature.parameters.items():
244
+ if name in {"self", "cls"}:
245
+ continue
246
+ required = param.default is inspect._empty
247
+ params.append(
248
+ {
249
+ "name": name,
250
+ "annotation": _annotation_text(param.annotation),
251
+ "required": required,
252
+ "default": "" if required else _literal_text(param.default),
253
+ "description": arg_docs.get(name, ""),
254
+ }
255
+ )
256
+ return params
257
+
258
+
259
+ def _guide_for_tool(name: str) -> dict[str, list]:
260
+ if name == "nexo_learning_add":
261
+ return {
262
+ "workflow": [
263
+ "Usa `applies_to` si quieres que el guard recuerde este learning antes de tocar un archivo, directorio o patrón concreto.",
264
+ "Usa `priority` (`critical`, `high`, `medium`, `low`) para marcar severidad operativa.",
265
+ ],
266
+ "examples": [
267
+ {
268
+ "title": "Learning mínimo",
269
+ "code": 'nexo_learning_add(category="shopify", title="Hacer pull antes de editar", content="Siempre sincronizar antes de editar el tema live.")',
270
+ },
271
+ {
272
+ "title": "Learning ligado a archivo o patrón",
273
+ "code": 'nexo_learning_add(category="recambios-bmw", title="Pull antes de editar theme", content="El admin puede tocar JSONs live.", applies_to="/abs/path/templates/product.json,templates/*.json,sections/*.liquid", prevention="Ejecutar `shopify theme pull` antes de editar.", priority="high")',
274
+ },
275
+ ],
276
+ "common_errors": [
277
+ "Usar `severity` en vez de `priority`.",
278
+ "Olvidar `title`, que es obligatorio.",
279
+ "No poner `applies_to` cuando quieres que el warning salte antes de tocar archivos concretos.",
280
+ ],
281
+ }
282
+ if name == "nexo_learning_update":
283
+ return {
284
+ "workflow": [
285
+ "Úsalo para completar o endurecer un learning existente cuando descubres nuevos archivos afectados, mejor `prevention` o prioridad distinta.",
286
+ ],
287
+ "examples": [
288
+ {
289
+ "title": "Añadir alcance a un learning existente",
290
+ "code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="Leer schema antes del primer uso", priority="high")',
291
+ },
292
+ ],
293
+ "common_errors": [
294
+ "Intentar recrear el learning desde cero cuando basta con actualizar el existente.",
295
+ ],
296
+ }
297
+ if name == "nexo_reminder_get":
298
+ return {
299
+ "workflow": [
300
+ "Devuelve el `READ_TOKEN` necesario para `update`, `delete`, `restore` y `note` sobre ese reminder.",
301
+ ],
302
+ "examples": [
303
+ {
304
+ "title": "Leer reminder y obtener token",
305
+ "code": 'nexo_reminder_get(id="R87")',
306
+ },
307
+ ],
308
+ "common_errors": [
309
+ "Intentar editar o borrar un reminder sin llamar antes a `nexo_reminder_get`.",
310
+ ],
311
+ }
312
+ if name in {"nexo_reminder_update", "nexo_reminder_delete", "nexo_reminder_restore", "nexo_reminder_note"}:
313
+ return {
314
+ "workflow": [
315
+ "Primero llama `nexo_reminder_get(id=\"R87\")` para obtener `READ_TOKEN`.",
316
+ f"Luego reutiliza ese `READ_TOKEN` en `{name}(...)`.",
317
+ ],
318
+ "examples": [
319
+ {
320
+ "title": "1. Obtener token",
321
+ "code": 'nexo_reminder_get(id="R87")',
322
+ },
323
+ {
324
+ "title": "2. Reutilizar READ_TOKEN",
325
+ "code": f'{name}(id="R87", read_token="TOKEN")',
326
+ },
327
+ ],
328
+ "common_errors": [
329
+ "Llamar a esta tool sin `READ_TOKEN` válido.",
330
+ "Usar un `READ_TOKEN` de otro reminder o de una lectura antigua.",
331
+ ],
332
+ }
333
+ if name == "nexo_followup_get":
334
+ return {
335
+ "workflow": [
336
+ "Devuelve el `READ_TOKEN` necesario para `update`, `delete`, `restore` y `note` sobre ese followup.",
337
+ ],
338
+ "examples": [
339
+ {
340
+ "title": "Leer followup y obtener token",
341
+ "code": 'nexo_followup_get(id="NF45")',
342
+ },
343
+ ],
344
+ "common_errors": [
345
+ "Intentar editar o borrar un followup sin llamar antes a `nexo_followup_get`.",
346
+ ],
347
+ }
348
+ if name in {"nexo_followup_update", "nexo_followup_delete", "nexo_followup_restore", "nexo_followup_note"}:
349
+ return {
350
+ "workflow": [
351
+ "Primero llama `nexo_followup_get(id=\"NF45\")` para obtener `READ_TOKEN`.",
352
+ f"Luego reutiliza ese `READ_TOKEN` en `{name}(...)`.",
353
+ ],
354
+ "examples": [
355
+ {
356
+ "title": "1. Obtener token",
357
+ "code": 'nexo_followup_get(id="NF45")',
358
+ },
359
+ {
360
+ "title": "2. Reutilizar READ_TOKEN",
361
+ "code": f'{name}(id="NF45", read_token="TOKEN")',
362
+ },
363
+ ],
364
+ "common_errors": [
365
+ "Llamar a esta tool sin `READ_TOKEN` válido.",
366
+ "Usar un `READ_TOKEN` de otro followup o de una lectura antigua.",
367
+ ],
368
+ }
369
+ return {}
370
+
371
+
87
372
  def _parse_core_tools() -> list[dict]:
88
373
  if not SERVER_PATH.is_file():
89
374
  return []
@@ -103,44 +388,78 @@ def _parse_core_tools() -> list[dict]:
103
388
  continue
104
389
  doc = ast.get_docstring(node) or ""
105
390
  first_line = doc.strip().splitlines()[0].strip() if doc.strip() else ""
391
+ arg_docs = _parse_arg_docs(doc)
392
+ params = _ast_params_for_node(node, arg_docs)
106
393
  entries.append(
107
394
  {
108
395
  "kind": "core_tool",
109
396
  "name": node.name,
110
397
  "description": first_line,
398
+ "doc": doc,
111
399
  "category": _tool_category(node.name),
112
400
  "path": str(SERVER_PATH),
113
401
  "line": int(getattr(node, "lineno", 0) or 0),
402
+ "params": params,
403
+ "signature": _build_signature(
404
+ node.name,
405
+ params,
406
+ _annotation_text_from_ast(node.returns),
407
+ ),
408
+ "quick_example": _generic_example(node.name, params),
114
409
  "source": "core",
115
410
  }
116
411
  )
117
412
  return entries
118
413
 
119
414
 
120
- def _plugin_module_tools(filename: str, created_by: str) -> dict[str, str]:
415
+ def _plugin_module_tools(filename: str, created_by: str) -> list[dict]:
121
416
  module_name = f"plugins.{filename[:-3]}"
122
417
  module = sys.modules.get(module_name)
123
418
  if module is None:
124
419
  plugin_dir = PLUGINS_DIR if created_by == "repo" else PERSONAL_PLUGINS_DIR
125
420
  path = Path(plugin_dir) / filename
126
421
  if not path.is_file():
127
- return {}
422
+ return []
128
423
  try:
129
424
  spec = importlib.util.spec_from_file_location(module_name, path)
130
425
  if spec is None or spec.loader is None:
131
- return {}
426
+ return []
132
427
  module = importlib.util.module_from_spec(spec)
133
428
  spec.loader.exec_module(module)
134
429
  except Exception:
135
- return {}
430
+ return []
136
431
  tools = getattr(module, "TOOLS", []) or []
137
- result: dict[str, str] = {}
432
+ result: list[dict] = []
138
433
  for item in tools:
139
434
  try:
140
- _, name, description = item
435
+ func, name, description = item
141
436
  except Exception:
142
437
  continue
143
- result[str(name)] = str(description or "")
438
+ doc = inspect.getdoc(func) or ""
439
+ arg_docs = _parse_arg_docs(doc)
440
+ params = _callable_params(func, arg_docs)
441
+ try:
442
+ return_annotation = _annotation_text(inspect.signature(func).return_annotation)
443
+ except (TypeError, ValueError):
444
+ return_annotation = ""
445
+ result.append(
446
+ {
447
+ "kind": "plugin_tool",
448
+ "name": str(name),
449
+ "description": str(description or ""),
450
+ "doc": doc,
451
+ "params": params,
452
+ "signature": _build_signature(
453
+ str(name),
454
+ params,
455
+ return_annotation,
456
+ ),
457
+ "quick_example": _generic_example(str(name), params),
458
+ "plugin": filename,
459
+ "source": created_by,
460
+ "category": _tool_category(str(name)),
461
+ }
462
+ )
144
463
  return result
145
464
 
146
465
 
@@ -150,14 +469,17 @@ def _plugin_entries() -> list[dict]:
150
469
  for row in rows:
151
470
  filename = str(row.get("filename") or "")
152
471
  created_by = str(row.get("created_by") or row.get("source") or "repo")
153
- descriptions = _plugin_module_tools(filename, created_by)
472
+ plugin_tools = _plugin_module_tools(filename, created_by)
473
+ if plugin_tools:
474
+ entries.extend(plugin_tools)
475
+ continue
154
476
  names = str(row.get("tool_names") or "").split(",")
155
477
  for name in [n.strip() for n in names if n.strip()]:
156
478
  entries.append(
157
479
  {
158
480
  "kind": "plugin_tool",
159
481
  "name": name,
160
- "description": descriptions.get(name, ""),
482
+ "description": "",
161
483
  "plugin": filename,
162
484
  "source": created_by,
163
485
  "category": _tool_category(name),
@@ -339,13 +661,20 @@ def explain_tool(name: str) -> dict | None:
339
661
  clean = _normalize_text(name)
340
662
  if not clean:
341
663
  return None
342
- exact = search_system_catalog(clean, limit=200)
343
- for row in exact:
344
- if _normalize_text(row.get("name")) == clean:
345
- return row
346
- for row in exact:
347
- if clean in _normalize_text(row.get("name")):
348
- return row
664
+ candidates = [clean]
665
+ if clean.startswith("mcp__nexo__"):
666
+ candidates.append(clean.split("mcp__nexo__", 1)[1])
667
+ if "__" in clean:
668
+ candidates.append(clean.split("__")[-1])
669
+ seen: set[str] = set()
670
+ for candidate in [item for item in candidates if item and not (item in seen or seen.add(item))]:
671
+ exact = search_system_catalog(candidate, limit=200)
672
+ for row in exact:
673
+ if _normalize_text(row.get("name")) == candidate:
674
+ return row
675
+ for row in exact:
676
+ if candidate in _normalize_text(row.get("name")):
677
+ return row
349
678
  return None
350
679
 
351
680
 
@@ -386,6 +715,12 @@ def format_catalog(catalog: dict, *, section: str = "", query: str = "", limit:
386
715
  def format_tool_explanation(entry: dict | None) -> str:
387
716
  if not entry:
388
717
  return "Tool/capability not found in the live system catalog."
718
+ params = entry.get("params") or []
719
+ required = [param for param in params if param.get("required")]
720
+ optional = [param for param in params if not param.get("required")]
721
+ guide = _guide_for_tool(str(entry.get("name") or ""))
722
+ examples = [{"title": "Quick example", "code": entry["quick_example"]}] if entry.get("quick_example") else []
723
+ examples.extend(guide.get("examples", []))
389
724
  lines = [
390
725
  f"CATALOG ENTRY — {entry.get('name') or entry.get('display_name')}",
391
726
  f"Section: {entry.get('_section') or entry.get('kind')}",
@@ -416,4 +751,36 @@ def format_tool_explanation(entry: dict | None) -> str:
416
751
  lines.append(f"Execution level: {entry['execution_level']}")
417
752
  if entry.get("domain"):
418
753
  lines.append(f"Domain: {entry['domain']}")
754
+ if entry.get("signature"):
755
+ lines.append(f"Signature: {entry['signature']}")
756
+ if required:
757
+ lines.append("Required args:")
758
+ for param in required:
759
+ detail = param.get("description") or "No description."
760
+ annotation = f" ({param['annotation']})" if param.get("annotation") else ""
761
+ lines.append(f"- {param['name']}{annotation}: {detail}")
762
+ if optional:
763
+ lines.append("Optional args:")
764
+ for param in optional:
765
+ detail = param.get("description") or "Optional."
766
+ annotation = f" ({param['annotation']})" if param.get("annotation") else ""
767
+ default = f" Default: {param['default']}." if param.get("default", "") != "" else ""
768
+ lines.append(f"- {param['name']}{annotation}: {detail}{default}")
769
+ if guide.get("workflow"):
770
+ lines.append("Workflow notes:")
771
+ for item in guide["workflow"]:
772
+ lines.append(f"- {item}")
773
+ if examples:
774
+ lines.append("Examples:")
775
+ for example in examples:
776
+ title = str(example.get("title") or "").strip()
777
+ code = str(example.get("code") or "").strip()
778
+ if title:
779
+ lines.append(f"- {title}")
780
+ if code:
781
+ lines.append(f" {code}")
782
+ if guide.get("common_errors"):
783
+ lines.append("Common errors:")
784
+ for item in guide["common_errors"]:
785
+ lines.append(f"- {item}")
419
786
  return "\n".join(lines)
@@ -542,6 +542,17 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
542
542
  except Exception:
543
543
  pass # guard_log table may not exist in older installs
544
544
 
545
+ if context_hint and _hint_suggests_correction(context_hint):
546
+ try:
547
+ if not _recent_learning_capture_exists(conn, sid, window_seconds=300):
548
+ parts.append("")
549
+ parts.append(
550
+ "⚠ LEARNING REMINDER: This looks like a user correction and no recent learning was captured. "
551
+ "If it revealed a reusable pattern, write `nexo_learning_add` NOW."
552
+ )
553
+ except Exception:
554
+ pass # Best-effort reminder only
555
+
545
556
  return "\n".join(parts)
546
557
 
547
558
 
@@ -816,6 +827,64 @@ def _hint_suggests_code_edit(hint: str) -> bool:
816
827
  return any(signal in hint_lower for signal in edit_signals)
817
828
 
818
829
 
830
+ def _hint_suggests_correction(hint: str) -> bool:
831
+ """Detect explicit user correction signals in a heartbeat context hint."""
832
+ hint_lower = hint.lower()
833
+ correction_signals = [
834
+ "that's wrong",
835
+ "that is wrong",
836
+ "wrong approach",
837
+ "not like that",
838
+ "fix this",
839
+ "fix it",
840
+ "está mal",
841
+ "esta mal",
842
+ "mal hecho",
843
+ "incorrecto",
844
+ "te equivocas",
845
+ "te has equivocado",
846
+ "lo hiciste mal",
847
+ "no era eso",
848
+ "corrige esto",
849
+ "corrígelo",
850
+ "corrigelo",
851
+ "ya te dije",
852
+ "otra vez el mismo",
853
+ "de nuevo el mismo",
854
+ "no deberías",
855
+ "no deberias",
856
+ "shouldn't have",
857
+ "should not have",
858
+ ]
859
+ return any(signal in hint_lower for signal in correction_signals)
860
+
861
+
862
+ def _recent_learning_capture_exists(conn, sid: str, window_seconds: int = 300) -> bool:
863
+ """Check whether a recent learning was captured manually or via protocol task close."""
864
+ cutoff_epoch = time.time() - window_seconds
865
+
866
+ row = conn.execute(
867
+ "SELECT 1 FROM learnings WHERE created_at >= ? LIMIT 1",
868
+ (cutoff_epoch,),
869
+ ).fetchone()
870
+ if row:
871
+ return True
872
+
873
+ row = conn.execute(
874
+ """
875
+ SELECT 1
876
+ FROM protocol_tasks
877
+ WHERE session_id = ?
878
+ AND learning_id IS NOT NULL
879
+ AND closed_at IS NOT NULL
880
+ AND CAST(strftime('%s', closed_at) AS INTEGER) >= ?
881
+ LIMIT 1
882
+ """,
883
+ (sid, int(cutoff_epoch)),
884
+ ).fetchone()
885
+ return bool(row)
886
+
887
+
819
888
  def _toolbox_summary(conn) -> str:
820
889
  """Quick count of available skills and behavioral learnings for startup reminder."""
821
890
  try: