nexo-brain 3.2.0 → 4.0.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 +10 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +105 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +6 -1
- package/src/server.py +1 -0
- package/src/system_catalog.py +383 -16
- package/src/user_state_model.py +170 -0
package/src/system_catalog.py
CHANGED
|
@@ -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
|
|
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
|
|
432
|
+
result: list[dict] = []
|
|
138
433
|
for item in tools:
|
|
139
434
|
try:
|
|
140
|
-
|
|
435
|
+
func, name, description = item
|
|
141
436
|
except Exception:
|
|
142
437
|
continue
|
|
143
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Inspectable user-state model built from multiple NEXO signals."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import cognitive
|
|
9
|
+
from db import get_db
|
|
10
|
+
from db._hot_context import search_hot_context
|
|
11
|
+
from memory_backends import get_backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_tables() -> None:
|
|
15
|
+
conn = get_db()
|
|
16
|
+
conn.executescript(
|
|
17
|
+
"""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS user_state_snapshots (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
state_label TEXT NOT NULL,
|
|
21
|
+
confidence REAL DEFAULT 0.0,
|
|
22
|
+
guidance TEXT DEFAULT '',
|
|
23
|
+
signals TEXT DEFAULT '{}',
|
|
24
|
+
backend_key TEXT DEFAULT 'sqlite',
|
|
25
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_user_state_snapshots_created ON user_state_snapshots(created_at);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_user_state_snapshots_label ON user_state_snapshots(state_label);
|
|
29
|
+
"""
|
|
30
|
+
)
|
|
31
|
+
conn.commit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _recent_correction_count(days: int) -> int:
|
|
35
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
|
36
|
+
row = cognitive._get_db().execute(
|
|
37
|
+
"SELECT COUNT(*) FROM memory_corrections WHERE created_at >= ?",
|
|
38
|
+
(cutoff,),
|
|
39
|
+
).fetchone()
|
|
40
|
+
return int((row[0] if row else 0) or 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _recent_trust_event_count(days: int, event_name: str) -> int:
|
|
44
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
|
45
|
+
row = cognitive._get_db().execute(
|
|
46
|
+
"SELECT COUNT(*) FROM trust_score WHERE created_at >= ? AND event = ?",
|
|
47
|
+
(cutoff, event_name),
|
|
48
|
+
).fetchone()
|
|
49
|
+
return int((row[0] if row else 0) or 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _recent_diary_signal_count(days: int) -> int:
|
|
53
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat(timespec="seconds")
|
|
54
|
+
row = get_db().execute(
|
|
55
|
+
"SELECT COUNT(*) FROM session_diary WHERE created_at >= ? AND user_signals != ''",
|
|
56
|
+
(cutoff,),
|
|
57
|
+
).fetchone()
|
|
58
|
+
return int((row[0] if row else 0) or 0)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_user_state(days: int = 7, *, persist: bool = False) -> dict:
|
|
62
|
+
init_tables()
|
|
63
|
+
trust = float(cognitive.get_trust_score())
|
|
64
|
+
history = cognitive.get_trust_history(days=days)
|
|
65
|
+
sentiments = history.get("sentiment_distribution", {})
|
|
66
|
+
negative = int((sentiments.get("negative") or {}).get("count", 0) or 0)
|
|
67
|
+
urgent = int((sentiments.get("urgent") or {}).get("count", 0) or 0)
|
|
68
|
+
positive = int((sentiments.get("positive") or {}).get("count", 0) or 0)
|
|
69
|
+
corrections = _recent_correction_count(days)
|
|
70
|
+
repeated_errors = _recent_trust_event_count(days, "repeated_error")
|
|
71
|
+
productive_sessions = _recent_trust_event_count(days, "session_productive")
|
|
72
|
+
delegation_events = _recent_trust_event_count(days, "delegation")
|
|
73
|
+
diaries_with_signals = _recent_diary_signal_count(days)
|
|
74
|
+
active_contexts = len(search_hot_context("", hours=min(max(days, 1) * 24, 168), limit=50, state="active"))
|
|
75
|
+
waiting_contexts = len(search_hot_context("", hours=min(max(days, 1) * 24, 168), limit=50, state="waiting_user"))
|
|
76
|
+
blocked_contexts = len(search_hot_context("", hours=min(max(days, 1) * 24, 168), limit=50, state="blocked"))
|
|
77
|
+
|
|
78
|
+
frustration_score = negative * 1.5 + corrections * 0.8 + repeated_errors * 1.2 + (1 if trust < 45 else 0)
|
|
79
|
+
flow_score = positive * 1.2 + productive_sessions * 1.0 + delegation_events * 0.8 + (1 if trust > 60 else 0)
|
|
80
|
+
urgency_score = urgent * 2.0 + blocked_contexts * 0.6
|
|
81
|
+
|
|
82
|
+
if urgency_score >= max(2.0, frustration_score, flow_score):
|
|
83
|
+
label = "urgent"
|
|
84
|
+
guidance = "Immediate execution. Keep answers short. Avoid speculative detours."
|
|
85
|
+
confidence = min(0.98, 0.45 + urgency_score * 0.12)
|
|
86
|
+
elif frustration_score >= max(2.0, flow_score):
|
|
87
|
+
label = "frustrated"
|
|
88
|
+
guidance = "Ultra-concise mode. Show concrete progress and avoid avoidable questions."
|
|
89
|
+
confidence = min(0.98, 0.4 + frustration_score * 0.1)
|
|
90
|
+
elif flow_score >= 2.5:
|
|
91
|
+
label = "in_flow"
|
|
92
|
+
guidance = "Keep momentum. Bias toward execution and only interrupt for real blockers."
|
|
93
|
+
confidence = min(0.98, 0.4 + flow_score * 0.09)
|
|
94
|
+
elif waiting_contexts > 0 or active_contexts > 6:
|
|
95
|
+
label = "loaded"
|
|
96
|
+
guidance = "Prefer batching, tight summaries, and explicit next actions."
|
|
97
|
+
confidence = 0.68
|
|
98
|
+
else:
|
|
99
|
+
label = "stable"
|
|
100
|
+
guidance = "Normal mode. Clear, direct execution with selective initiative."
|
|
101
|
+
confidence = 0.6
|
|
102
|
+
|
|
103
|
+
snapshot = {
|
|
104
|
+
"state_label": label,
|
|
105
|
+
"confidence": round(confidence, 2),
|
|
106
|
+
"guidance": guidance,
|
|
107
|
+
"trust_score": round(trust, 1),
|
|
108
|
+
"signals": {
|
|
109
|
+
"negative_sentiment": negative,
|
|
110
|
+
"urgent_sentiment": urgent,
|
|
111
|
+
"positive_sentiment": positive,
|
|
112
|
+
"recent_corrections": corrections,
|
|
113
|
+
"repeated_errors": repeated_errors,
|
|
114
|
+
"productive_sessions": productive_sessions,
|
|
115
|
+
"delegation_events": delegation_events,
|
|
116
|
+
"diaries_with_user_signals": diaries_with_signals,
|
|
117
|
+
"active_contexts": active_contexts,
|
|
118
|
+
"waiting_contexts": waiting_contexts,
|
|
119
|
+
"blocked_contexts": blocked_contexts,
|
|
120
|
+
},
|
|
121
|
+
"backend": get_backend().key,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if persist:
|
|
125
|
+
conn = get_db()
|
|
126
|
+
conn.execute(
|
|
127
|
+
"INSERT INTO user_state_snapshots (state_label, confidence, guidance, signals, backend_key) VALUES (?, ?, ?, ?, ?)",
|
|
128
|
+
(
|
|
129
|
+
snapshot["state_label"],
|
|
130
|
+
snapshot["confidence"],
|
|
131
|
+
snapshot["guidance"],
|
|
132
|
+
json.dumps(snapshot["signals"], ensure_ascii=True, sort_keys=True),
|
|
133
|
+
snapshot["backend"],
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
conn.commit()
|
|
137
|
+
|
|
138
|
+
return snapshot
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_user_state_snapshots(limit: int = 20) -> list[dict]:
|
|
142
|
+
init_tables()
|
|
143
|
+
rows = get_db().execute(
|
|
144
|
+
"SELECT * FROM user_state_snapshots ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
145
|
+
(max(1, int(limit or 20)),),
|
|
146
|
+
).fetchall()
|
|
147
|
+
results = []
|
|
148
|
+
for row in rows:
|
|
149
|
+
item = dict(row)
|
|
150
|
+
try:
|
|
151
|
+
item["signals"] = json.loads(item.get("signals") or "{}")
|
|
152
|
+
except Exception:
|
|
153
|
+
item["signals"] = {}
|
|
154
|
+
results.append(item)
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def user_state_stats(days: int = 30) -> dict:
|
|
159
|
+
init_tables()
|
|
160
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat(timespec="seconds")
|
|
161
|
+
rows = get_db().execute(
|
|
162
|
+
"SELECT state_label, COUNT(*) AS cnt FROM user_state_snapshots WHERE created_at >= ? GROUP BY state_label",
|
|
163
|
+
(cutoff,),
|
|
164
|
+
).fetchall()
|
|
165
|
+
return {
|
|
166
|
+
"window_days": days,
|
|
167
|
+
"snapshots": sum(int(row["cnt"]) for row in rows),
|
|
168
|
+
"by_state": {row["state_label"]: row["cnt"] for row in rows},
|
|
169
|
+
"backend": get_backend().key,
|
|
170
|
+
}
|