nexo-brain 2.7.0 → 3.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 +66 -12
- package/hooks/hooks.json +79 -0
- package/package.json +1 -1
- package/src/agent_runner.py +290 -6
- package/src/cli.py +111 -0
- package/src/client_preferences.py +94 -0
- package/src/client_sync.py +202 -2
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +140 -0
- package/src/dashboard/templates/base.html +4 -0
- package/src/dashboard/templates/protocol.html +199 -0
- package/src/db/__init__.py +23 -1
- package/src/db/_learnings.py +31 -4
- package/src/db/_personal_scripts.py +12 -0
- package/src/db/_protocol.py +303 -0
- package/src/db/_schema.py +248 -0
- package/src/db/_watchers.py +173 -0
- package/src/db/_workflow.py +952 -0
- package/src/doctor/providers/runtime.py +918 -7
- package/src/evolution_cycle.py +62 -0
- package/src/hook_guardrails.py +308 -0
- package/src/hooks/protocol-guardrail.sh +10 -0
- package/src/nexo_sdk.py +103 -0
- package/src/plugins/cognitive_memory.py +18 -0
- package/src/plugins/cortex.py +55 -35
- package/src/plugins/guard.py +132 -16
- package/src/plugins/protocol.py +911 -0
- package/src/plugins/schedule.py +40 -6
- package/src/plugins/simple_api.py +103 -0
- package/src/plugins/skills.py +67 -0
- package/src/plugins/state_watchers.py +79 -0
- package/src/plugins/workflow.py +588 -0
- package/src/public_contribution.py +86 -12
- package/src/script_registry.py +142 -0
- package/src/scripts/deep-sleep/apply_findings.py +204 -0
- package/src/scripts/deep-sleep/collect.py +49 -4
- package/src/scripts/nexo-agent-run.py +2 -0
- package/src/scripts/nexo-daily-self-audit.py +843 -5
- package/src/scripts/nexo-evolution-run.py +343 -1
- package/src/server.py +92 -6
- package/src/skills_runtime.py +151 -0
- package/src/state_watchers_runtime.py +334 -0
- package/src/tools_learnings.py +345 -7
- package/src/tools_sessions.py +183 -0
- package/templates/CLAUDE.md.template +9 -1
- package/templates/CODEX.AGENTS.md.template +10 -2
package/src/cli.py
CHANGED
|
@@ -1056,6 +1056,23 @@ def _skills_apply(args):
|
|
|
1056
1056
|
return 0 if result.get("ok") else 1
|
|
1057
1057
|
|
|
1058
1058
|
|
|
1059
|
+
def _skills_test(args):
|
|
1060
|
+
from skills_runtime import test_skill
|
|
1061
|
+
|
|
1062
|
+
try:
|
|
1063
|
+
params = json.loads(args.params) if args.params else {}
|
|
1064
|
+
except json.JSONDecodeError as e:
|
|
1065
|
+
print(f"Invalid params JSON: {e}", file=sys.stderr)
|
|
1066
|
+
return 1
|
|
1067
|
+
|
|
1068
|
+
result = test_skill(args.id, params=params, mode=args.mode, context=args.context)
|
|
1069
|
+
if args.json:
|
|
1070
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1071
|
+
else:
|
|
1072
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1073
|
+
return 0 if result.get("ok") else 1
|
|
1074
|
+
|
|
1075
|
+
|
|
1059
1076
|
def _skills_sync(args):
|
|
1060
1077
|
from skills_runtime import sync_skills
|
|
1061
1078
|
|
|
@@ -1103,6 +1120,62 @@ def _skills_evolution(args):
|
|
|
1103
1120
|
return 0
|
|
1104
1121
|
|
|
1105
1122
|
|
|
1123
|
+
def _skills_promote(args):
|
|
1124
|
+
from skills_runtime import promote_skill
|
|
1125
|
+
|
|
1126
|
+
result = promote_skill(args.id, target_level=args.target_level, reason=args.reason)
|
|
1127
|
+
if args.json:
|
|
1128
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1129
|
+
else:
|
|
1130
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1131
|
+
return 0 if result.get("ok") else 1
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _skills_retire(args):
|
|
1135
|
+
from skills_runtime import retire_skill
|
|
1136
|
+
|
|
1137
|
+
result = retire_skill(args.id, replacement_id=args.replacement_id, reason=args.reason)
|
|
1138
|
+
if args.json:
|
|
1139
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1140
|
+
else:
|
|
1141
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1142
|
+
return 0 if result.get("ok") else 1
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _skills_compose(args):
|
|
1146
|
+
from skills_runtime import compose_skills
|
|
1147
|
+
|
|
1148
|
+
try:
|
|
1149
|
+
component_ids = json.loads(args.component_ids) if args.component_ids.strip().startswith("[") else [
|
|
1150
|
+
item.strip() for item in args.component_ids.split(",") if item.strip()
|
|
1151
|
+
]
|
|
1152
|
+
tags = json.loads(args.tags) if args.tags.strip().startswith("[") else [
|
|
1153
|
+
item.strip() for item in args.tags.split(",") if item.strip()
|
|
1154
|
+
]
|
|
1155
|
+
trigger_patterns = json.loads(args.trigger_patterns) if args.trigger_patterns.strip().startswith("[") else [
|
|
1156
|
+
item.strip() for item in args.trigger_patterns.split(",") if item.strip()
|
|
1157
|
+
]
|
|
1158
|
+
except json.JSONDecodeError as e:
|
|
1159
|
+
print(f"Invalid JSON: {e}", file=sys.stderr)
|
|
1160
|
+
return 1
|
|
1161
|
+
|
|
1162
|
+
result = compose_skills(
|
|
1163
|
+
new_skill_id=args.new_id,
|
|
1164
|
+
name=args.name,
|
|
1165
|
+
component_ids=component_ids,
|
|
1166
|
+
description=args.description,
|
|
1167
|
+
level=args.level,
|
|
1168
|
+
mode=args.mode,
|
|
1169
|
+
tags=tags,
|
|
1170
|
+
trigger_patterns=trigger_patterns,
|
|
1171
|
+
)
|
|
1172
|
+
if args.json:
|
|
1173
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1174
|
+
else:
|
|
1175
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1176
|
+
return 0 if result.get("ok") else 1
|
|
1177
|
+
|
|
1178
|
+
|
|
1106
1179
|
def _print_help():
|
|
1107
1180
|
v = _get_version()
|
|
1108
1181
|
print(f"""NEXO Runtime CLI v{v}
|
|
@@ -1248,6 +1321,13 @@ def main():
|
|
|
1248
1321
|
skills_apply_p.add_argument("--context", default="", help="Usage context for feedback loop")
|
|
1249
1322
|
skills_apply_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1250
1323
|
|
|
1324
|
+
skills_test_p = skills_sub.add_parser("test", help="Dry-run test a skill")
|
|
1325
|
+
skills_test_p.add_argument("id", help="Skill ID")
|
|
1326
|
+
skills_test_p.add_argument("--params", default="{}", help="JSON parameters")
|
|
1327
|
+
skills_test_p.add_argument("--mode", default="auto", choices=["auto", "guide", "execute", "hybrid"])
|
|
1328
|
+
skills_test_p.add_argument("--context", default="", help="Testing context")
|
|
1329
|
+
skills_test_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1330
|
+
|
|
1251
1331
|
skills_sync_p = skills_sub.add_parser("sync", help="Sync filesystem skills")
|
|
1252
1332
|
skills_sync_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1253
1333
|
|
|
@@ -1264,6 +1344,29 @@ def main():
|
|
|
1264
1344
|
skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
|
|
1265
1345
|
skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1266
1346
|
|
|
1347
|
+
skills_promote_p = skills_sub.add_parser("promote", help="Promote a skill lifecycle level")
|
|
1348
|
+
skills_promote_p.add_argument("id", help="Skill ID")
|
|
1349
|
+
skills_promote_p.add_argument("--target-level", default="published", choices=["draft", "published", "stable"])
|
|
1350
|
+
skills_promote_p.add_argument("--reason", default="", help="Why promote this skill")
|
|
1351
|
+
skills_promote_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1352
|
+
|
|
1353
|
+
skills_retire_p = skills_sub.add_parser("retire", help="Archive a skill")
|
|
1354
|
+
skills_retire_p.add_argument("id", help="Skill ID")
|
|
1355
|
+
skills_retire_p.add_argument("--replacement-id", default="", help="Optional replacement skill ID")
|
|
1356
|
+
skills_retire_p.add_argument("--reason", default="", help="Why retire this skill")
|
|
1357
|
+
skills_retire_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1358
|
+
|
|
1359
|
+
skills_compose_p = skills_sub.add_parser("compose", help="Compose multiple skills into one")
|
|
1360
|
+
skills_compose_p.add_argument("new_id", help="New skill ID")
|
|
1361
|
+
skills_compose_p.add_argument("name", help="New skill name")
|
|
1362
|
+
skills_compose_p.add_argument("--component-ids", required=True, help="JSON array or comma-separated skill IDs")
|
|
1363
|
+
skills_compose_p.add_argument("--description", default="", help="Composite skill description")
|
|
1364
|
+
skills_compose_p.add_argument("--level", default="draft", choices=["trace", "draft", "published", "stable"])
|
|
1365
|
+
skills_compose_p.add_argument("--mode", default="guide", choices=["guide", "hybrid"])
|
|
1366
|
+
skills_compose_p.add_argument("--tags", default="[]", help="JSON array or comma-separated tags")
|
|
1367
|
+
skills_compose_p.add_argument("--trigger-patterns", default="[]", help="JSON array or comma-separated trigger patterns")
|
|
1368
|
+
skills_compose_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1369
|
+
|
|
1267
1370
|
# -- dashboard --
|
|
1268
1371
|
dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
|
|
1269
1372
|
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
@@ -1332,6 +1435,8 @@ def main():
|
|
|
1332
1435
|
return _skills_get(args)
|
|
1333
1436
|
elif args.skills_command == "apply":
|
|
1334
1437
|
return _skills_apply(args)
|
|
1438
|
+
elif args.skills_command == "test":
|
|
1439
|
+
return _skills_test(args)
|
|
1335
1440
|
elif args.skills_command == "sync":
|
|
1336
1441
|
return _skills_sync(args)
|
|
1337
1442
|
elif args.skills_command == "approve":
|
|
@@ -1340,6 +1445,12 @@ def main():
|
|
|
1340
1445
|
return _skills_featured(args)
|
|
1341
1446
|
elif args.skills_command == "evolution":
|
|
1342
1447
|
return _skills_evolution(args)
|
|
1448
|
+
elif args.skills_command == "promote":
|
|
1449
|
+
return _skills_promote(args)
|
|
1450
|
+
elif args.skills_command == "retire":
|
|
1451
|
+
return _skills_retire(args)
|
|
1452
|
+
elif args.skills_command == "compose":
|
|
1453
|
+
return _skills_compose(args)
|
|
1343
1454
|
else:
|
|
1344
1455
|
skills_parser.print_help()
|
|
1345
1456
|
return 0
|
|
@@ -30,6 +30,12 @@ AUTOMATION_BACKEND_KEYS = (
|
|
|
30
30
|
CLIENT_CLAUDE_CODE,
|
|
31
31
|
CLIENT_CODEX,
|
|
32
32
|
)
|
|
33
|
+
AUTOMATION_TASK_PROFILE_KEYS = (
|
|
34
|
+
"default",
|
|
35
|
+
"fast",
|
|
36
|
+
"balanced",
|
|
37
|
+
"deep",
|
|
38
|
+
)
|
|
33
39
|
INSTALL_PREFERENCE_KEYS = {
|
|
34
40
|
"ask",
|
|
35
41
|
"auto",
|
|
@@ -40,6 +46,10 @@ DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
|
40
46
|
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
41
47
|
DEFAULT_CODEX_MODEL = "gpt-5.4"
|
|
42
48
|
DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
|
|
49
|
+
DEFAULT_FAST_MODEL = "gpt-5.4-mini"
|
|
50
|
+
DEFAULT_FAST_REASONING_EFFORT = "medium"
|
|
51
|
+
|
|
52
|
+
|
|
43
53
|
def _user_home() -> Path:
|
|
44
54
|
return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
|
|
45
55
|
|
|
@@ -77,6 +87,7 @@ def default_client_preferences() -> dict:
|
|
|
77
87
|
"automation_enabled": True,
|
|
78
88
|
"automation_backend": CLIENT_CLAUDE_CODE,
|
|
79
89
|
"client_runtime_profiles": default_client_runtime_profiles(),
|
|
90
|
+
"automation_task_profiles": default_automation_task_profiles(),
|
|
80
91
|
"client_install_preferences": {
|
|
81
92
|
CLIENT_CLAUDE_CODE: "ask",
|
|
82
93
|
CLIENT_CODEX: "ask",
|
|
@@ -237,6 +248,31 @@ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
|
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
|
|
251
|
+
def default_automation_task_profiles() -> dict[str, dict[str, str]]:
|
|
252
|
+
return {
|
|
253
|
+
"default": {
|
|
254
|
+
"backend": "",
|
|
255
|
+
"model": "",
|
|
256
|
+
"reasoning_effort": "",
|
|
257
|
+
},
|
|
258
|
+
"fast": {
|
|
259
|
+
"backend": CLIENT_CODEX,
|
|
260
|
+
"model": DEFAULT_FAST_MODEL,
|
|
261
|
+
"reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
|
|
262
|
+
},
|
|
263
|
+
"balanced": {
|
|
264
|
+
"backend": "",
|
|
265
|
+
"model": "",
|
|
266
|
+
"reasoning_effort": "",
|
|
267
|
+
},
|
|
268
|
+
"deep": {
|
|
269
|
+
"backend": CLIENT_CLAUDE_CODE,
|
|
270
|
+
"model": DEFAULT_CLAUDE_CODE_MODEL,
|
|
271
|
+
"reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
240
276
|
def _normalize_runtime_model(value, *, default: str) -> str:
|
|
241
277
|
candidate = str(value or "").strip()
|
|
242
278
|
return candidate or default
|
|
@@ -276,6 +312,31 @@ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
|
|
|
276
312
|
return normalized
|
|
277
313
|
|
|
278
314
|
|
|
315
|
+
def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
|
|
316
|
+
defaults = default_automation_task_profiles()
|
|
317
|
+
normalized = {key: dict(profile) for key, profile in defaults.items()}
|
|
318
|
+
if not isinstance(value, dict):
|
|
319
|
+
return normalized
|
|
320
|
+
|
|
321
|
+
for raw_profile, raw_value in value.items():
|
|
322
|
+
profile_key = str(raw_profile or "").strip().lower()
|
|
323
|
+
if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
|
|
324
|
+
continue
|
|
325
|
+
if not isinstance(raw_value, dict):
|
|
326
|
+
continue
|
|
327
|
+
backend = normalize_backend_key(raw_value.get("backend"))
|
|
328
|
+
if backend == BACKEND_NONE:
|
|
329
|
+
backend = ""
|
|
330
|
+
normalized[profile_key] = {
|
|
331
|
+
"backend": backend or defaults[profile_key]["backend"],
|
|
332
|
+
"model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
|
|
333
|
+
"reasoning_effort": str(
|
|
334
|
+
raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
|
|
335
|
+
).strip().lower(),
|
|
336
|
+
}
|
|
337
|
+
return normalized
|
|
338
|
+
|
|
339
|
+
|
|
279
340
|
def normalize_client_preferences(
|
|
280
341
|
schedule: dict | None = None,
|
|
281
342
|
*,
|
|
@@ -312,6 +373,9 @@ def normalize_client_preferences(
|
|
|
312
373
|
"automation_enabled": automation_enabled,
|
|
313
374
|
"automation_backend": automation_backend,
|
|
314
375
|
"client_runtime_profiles": runtime_profiles,
|
|
376
|
+
"automation_task_profiles": normalize_automation_task_profiles(
|
|
377
|
+
schedule.get("automation_task_profiles")
|
|
378
|
+
),
|
|
315
379
|
"client_install_preferences": install_preferences,
|
|
316
380
|
}
|
|
317
381
|
|
|
@@ -325,6 +389,7 @@ def apply_client_preferences(
|
|
|
325
389
|
automation_enabled=None,
|
|
326
390
|
automation_backend: str | None = None,
|
|
327
391
|
client_runtime_profiles: dict | None = None,
|
|
392
|
+
automation_task_profiles: dict | None = None,
|
|
328
393
|
client_install_preferences: dict | None = None,
|
|
329
394
|
) -> dict:
|
|
330
395
|
merged = dict(schedule or {})
|
|
@@ -352,6 +417,11 @@ def apply_client_preferences(
|
|
|
352
417
|
if client_runtime_profiles is not None
|
|
353
418
|
else current["client_runtime_profiles"]
|
|
354
419
|
)
|
|
420
|
+
merged["automation_task_profiles"] = normalize_automation_task_profiles(
|
|
421
|
+
automation_task_profiles
|
|
422
|
+
if automation_task_profiles is not None
|
|
423
|
+
else current["automation_task_profiles"]
|
|
424
|
+
)
|
|
355
425
|
merged["client_install_preferences"] = normalize_client_install_preferences(
|
|
356
426
|
client_install_preferences
|
|
357
427
|
if client_install_preferences is not None
|
|
@@ -372,6 +442,7 @@ def save_client_preferences(
|
|
|
372
442
|
automation_enabled=None,
|
|
373
443
|
automation_backend: str | None = None,
|
|
374
444
|
client_runtime_profiles: dict | None = None,
|
|
445
|
+
automation_task_profiles: dict | None = None,
|
|
375
446
|
client_install_preferences: dict | None = None,
|
|
376
447
|
) -> Path:
|
|
377
448
|
schedule = apply_client_preferences(
|
|
@@ -382,6 +453,7 @@ def save_client_preferences(
|
|
|
382
453
|
automation_enabled=automation_enabled,
|
|
383
454
|
automation_backend=automation_backend,
|
|
384
455
|
client_runtime_profiles=client_runtime_profiles,
|
|
456
|
+
automation_task_profiles=automation_task_profiles,
|
|
385
457
|
client_install_preferences=client_install_preferences,
|
|
386
458
|
)
|
|
387
459
|
return save_schedule_config(schedule)
|
|
@@ -477,3 +549,25 @@ def resolve_client_runtime_profile(
|
|
|
477
549
|
default=defaults[client_key]["reasoning_effort"],
|
|
478
550
|
),
|
|
479
551
|
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def resolve_automation_task_profile(
|
|
555
|
+
profile: str | None,
|
|
556
|
+
*,
|
|
557
|
+
preferences: dict | None = None,
|
|
558
|
+
) -> dict[str, str]:
|
|
559
|
+
normalized = preferences or load_client_preferences()
|
|
560
|
+
defaults = default_automation_task_profiles()
|
|
561
|
+
profile_key = str(profile or "").strip().lower() or "default"
|
|
562
|
+
if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
|
|
563
|
+
profile_key = "default"
|
|
564
|
+
configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
|
|
565
|
+
selected = dict(configured.get(profile_key) or defaults[profile_key])
|
|
566
|
+
backend = selected.get("backend") or resolve_automation_backend(normalized)
|
|
567
|
+
runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
|
|
568
|
+
return {
|
|
569
|
+
"name": profile_key,
|
|
570
|
+
"backend": backend,
|
|
571
|
+
"model": selected.get("model") or runtime_profile["model"],
|
|
572
|
+
"reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
|
|
573
|
+
}
|
package/src/client_sync.py
CHANGED
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
8
10
|
import shutil
|
|
9
11
|
import subprocess
|
|
10
12
|
import sys
|
|
@@ -308,6 +310,179 @@ def _write_json_object(path: Path, payload: dict) -> None:
|
|
|
308
310
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
309
311
|
|
|
310
312
|
|
|
313
|
+
CORE_HOOK_SPECS = [
|
|
314
|
+
{
|
|
315
|
+
"event": "SessionStart",
|
|
316
|
+
"identity": "session-start-ts",
|
|
317
|
+
"timeout": 2,
|
|
318
|
+
"command_template": lambda nexo_home, _runtime_root, _hooks_dir: (
|
|
319
|
+
f"mkdir -p {shlex.quote(str(nexo_home / 'operations'))} && "
|
|
320
|
+
f"date +%s > {shlex.quote(str(nexo_home / 'operations' / '.session-start-ts'))}"
|
|
321
|
+
),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"event": "SessionStart",
|
|
325
|
+
"identity": "daily-briefing-check.sh",
|
|
326
|
+
"timeout": 5,
|
|
327
|
+
"script": "daily-briefing-check.sh",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"event": "SessionStart",
|
|
331
|
+
"identity": "session-start.sh",
|
|
332
|
+
"timeout": 35,
|
|
333
|
+
"script": "session-start.sh",
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"event": "Stop",
|
|
337
|
+
"identity": "session-stop.sh",
|
|
338
|
+
"timeout": 10,
|
|
339
|
+
"script": "session-stop.sh",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"event": "PostToolUse",
|
|
343
|
+
"identity": "capture-tool-logs.sh",
|
|
344
|
+
"timeout": 5,
|
|
345
|
+
"script": "capture-tool-logs.sh",
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"event": "PostToolUse",
|
|
349
|
+
"identity": "capture-session.sh",
|
|
350
|
+
"timeout": 3,
|
|
351
|
+
"script": "capture-session.sh",
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
"event": "PostToolUse",
|
|
355
|
+
"identity": "inbox-hook.sh",
|
|
356
|
+
"timeout": 5,
|
|
357
|
+
"script": "inbox-hook.sh",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
"event": "PostToolUse",
|
|
361
|
+
"identity": "protocol-guardrail.sh",
|
|
362
|
+
"timeout": 5,
|
|
363
|
+
"script": "protocol-guardrail.sh",
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"event": "PreCompact",
|
|
367
|
+
"identity": "pre-compact.sh",
|
|
368
|
+
"timeout": 10,
|
|
369
|
+
"script": "pre-compact.sh",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"event": "PostCompact",
|
|
373
|
+
"identity": "post-compact.sh",
|
|
374
|
+
"timeout": 10,
|
|
375
|
+
"script": "post-compact.sh",
|
|
376
|
+
},
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _resolve_hook_source_dir(runtime_root: Path) -> Path:
|
|
381
|
+
direct = runtime_root / "hooks"
|
|
382
|
+
if direct.is_dir():
|
|
383
|
+
return direct
|
|
384
|
+
sibling = runtime_root.parent / "src" / "hooks"
|
|
385
|
+
if sibling.is_dir():
|
|
386
|
+
return sibling
|
|
387
|
+
fallback = runtime_root.parent / "hooks"
|
|
388
|
+
if fallback.is_dir():
|
|
389
|
+
return fallback
|
|
390
|
+
return direct
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _render_hook_command(spec: dict, *, nexo_home: Path, runtime_root: Path, hooks_dir: Path) -> str:
|
|
394
|
+
command_template = spec.get("command_template")
|
|
395
|
+
if callable(command_template):
|
|
396
|
+
return command_template(nexo_home, runtime_root, hooks_dir)
|
|
397
|
+
script_name = spec.get("script", "").strip()
|
|
398
|
+
script_path = hooks_dir / script_name
|
|
399
|
+
return (
|
|
400
|
+
f"NEXO_HOME={shlex.quote(str(nexo_home))} "
|
|
401
|
+
f"NEXO_CODE={shlex.quote(str(runtime_root))} "
|
|
402
|
+
f"bash {shlex.quote(str(script_path))}"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _hook_identity(command: str) -> str:
|
|
407
|
+
text = str(command or "")
|
|
408
|
+
if ".session-start-ts" in text:
|
|
409
|
+
return "session-start-ts"
|
|
410
|
+
match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
|
|
411
|
+
if match:
|
|
412
|
+
return match.group(1)
|
|
413
|
+
return text.strip()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _normalize_hook_sections(entries) -> list[dict]:
|
|
417
|
+
normalized: list[dict] = []
|
|
418
|
+
for entry in entries or []:
|
|
419
|
+
if not isinstance(entry, dict):
|
|
420
|
+
continue
|
|
421
|
+
hooks = entry.get("hooks")
|
|
422
|
+
if isinstance(hooks, list):
|
|
423
|
+
normalized.append(
|
|
424
|
+
{
|
|
425
|
+
"matcher": entry.get("matcher", "*") or "*",
|
|
426
|
+
"hooks": [dict(hook) for hook in hooks if isinstance(hook, dict)],
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
continue
|
|
430
|
+
if entry.get("command"):
|
|
431
|
+
hook = {"type": entry.get("type", "command"), "command": entry["command"]}
|
|
432
|
+
if entry.get("timeout"):
|
|
433
|
+
hook["timeout"] = entry["timeout"]
|
|
434
|
+
normalized.append({"matcher": entry.get("matcher", "*") or "*", "hooks": [hook]})
|
|
435
|
+
return normalized
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _merge_core_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
|
|
439
|
+
hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
|
|
440
|
+
hooks_dir = _resolve_hook_source_dir(runtime_root)
|
|
441
|
+
managed_count = 0
|
|
442
|
+
|
|
443
|
+
for spec in CORE_HOOK_SPECS:
|
|
444
|
+
event = spec["event"]
|
|
445
|
+
sections = _normalize_hook_sections(hooks_payload.get(event))
|
|
446
|
+
hooks_payload[event] = sections
|
|
447
|
+
command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
|
|
448
|
+
identity = spec["identity"]
|
|
449
|
+
|
|
450
|
+
found = False
|
|
451
|
+
for section in sections:
|
|
452
|
+
for hook in section["hooks"]:
|
|
453
|
+
if _hook_identity(hook.get("command", "")) != identity:
|
|
454
|
+
continue
|
|
455
|
+
hook["type"] = "command"
|
|
456
|
+
hook["command"] = command
|
|
457
|
+
if spec.get("timeout"):
|
|
458
|
+
hook["timeout"] = spec["timeout"]
|
|
459
|
+
found = True
|
|
460
|
+
managed_count += 1
|
|
461
|
+
break
|
|
462
|
+
if found:
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
if found:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
target = None
|
|
469
|
+
for section in sections:
|
|
470
|
+
if section.get("matcher", "*") == "*":
|
|
471
|
+
target = section
|
|
472
|
+
break
|
|
473
|
+
if target is None:
|
|
474
|
+
target = {"matcher": "*", "hooks": []}
|
|
475
|
+
sections.append(target)
|
|
476
|
+
|
|
477
|
+
new_hook = {"type": "command", "command": command}
|
|
478
|
+
if spec.get("timeout"):
|
|
479
|
+
new_hook["timeout"] = spec["timeout"]
|
|
480
|
+
target["hooks"].append(new_hook)
|
|
481
|
+
managed_count += 1
|
|
482
|
+
|
|
483
|
+
return hooks_payload, managed_count
|
|
484
|
+
|
|
485
|
+
|
|
311
486
|
def _sync_json_client(path: Path, server_config: dict, label: str, *, managed_metadata: dict | None = None) -> dict:
|
|
312
487
|
payload = _load_json_object(path)
|
|
313
488
|
mcp_servers = payload.setdefault("mcpServers", {})
|
|
@@ -343,6 +518,32 @@ def _claude_desktop_managed_metadata(server_config: dict, *, operator_name: str)
|
|
|
343
518
|
}
|
|
344
519
|
|
|
345
520
|
|
|
521
|
+
def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
|
|
522
|
+
payload = _load_json_object(path)
|
|
523
|
+
mcp_servers = payload.setdefault("mcpServers", {})
|
|
524
|
+
if not isinstance(mcp_servers, dict):
|
|
525
|
+
mcp_servers = {}
|
|
526
|
+
payload["mcpServers"] = mcp_servers
|
|
527
|
+
action = "updated" if "nexo" in mcp_servers else "created"
|
|
528
|
+
mcp_servers["nexo"] = server_config
|
|
529
|
+
|
|
530
|
+
runtime_root = Path(server_config.get("env", {}).get("NEXO_CODE", "")).expanduser()
|
|
531
|
+
nexo_home = Path(server_config.get("env", {}).get("NEXO_HOME", "")).expanduser()
|
|
532
|
+
payload["hooks"], managed_hook_count = _merge_core_hooks(
|
|
533
|
+
payload.get("hooks", {}),
|
|
534
|
+
runtime_root=runtime_root,
|
|
535
|
+
nexo_home=nexo_home,
|
|
536
|
+
)
|
|
537
|
+
_write_json_object(path, payload)
|
|
538
|
+
return {
|
|
539
|
+
"ok": True,
|
|
540
|
+
"client": "claude_code",
|
|
541
|
+
"action": action,
|
|
542
|
+
"path": str(path),
|
|
543
|
+
"managed_hook_count": managed_hook_count,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
346
547
|
def sync_claude_code(
|
|
347
548
|
*,
|
|
348
549
|
nexo_home: str | os.PathLike[str] | None = None,
|
|
@@ -358,10 +559,9 @@ def sync_claude_code(
|
|
|
358
559
|
python_path=python_path,
|
|
359
560
|
operator_name=operator_name,
|
|
360
561
|
)
|
|
361
|
-
result =
|
|
562
|
+
result = _sync_claude_code_settings(
|
|
362
563
|
_claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
|
|
363
564
|
server_config,
|
|
364
|
-
"claude_code",
|
|
365
565
|
)
|
|
366
566
|
bootstrap_result = sync_client_bootstrap(
|
|
367
567
|
"claude_code",
|
|
@@ -29,7 +29,7 @@ from cognitive._search import (
|
|
|
29
29
|
search, bm25_search, hyde_expand_query,
|
|
30
30
|
record_co_activation,
|
|
31
31
|
_kg_boost_results, _apply_temporal_boost,
|
|
32
|
-
create_trigger, check_triggers, list_triggers, delete_trigger, rearm_trigger,
|
|
32
|
+
create_trigger, preview_triggers, check_triggers, list_triggers, delete_trigger, rearm_trigger,
|
|
33
33
|
# Constants
|
|
34
34
|
CO_ACTIVATION_DECAY, CO_ACTIVATION_BOOST, CO_ACTIVATION_MIN_STRENGTH,
|
|
35
35
|
)
|
package/src/cognitive/_search.py
CHANGED
|
@@ -542,18 +542,17 @@ def create_trigger(pattern: str, action: str, context: str = "") -> int:
|
|
|
542
542
|
return cur.lastrowid
|
|
543
543
|
|
|
544
544
|
|
|
545
|
-
def
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
545
|
+
def _match_triggers(
|
|
546
|
+
text: str,
|
|
547
|
+
*,
|
|
548
|
+
use_semantic: bool = False,
|
|
549
|
+
semantic_threshold: float = 0.7,
|
|
550
|
+
fire: bool = False,
|
|
551
|
+
) -> list[dict]:
|
|
552
|
+
"""Match armed prospective triggers against text.
|
|
550
553
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
use_semantic: Also do embedding similarity matching
|
|
554
|
-
semantic_threshold: Min cosine similarity for semantic match
|
|
555
|
-
Returns:
|
|
556
|
-
List of fired triggers with actions
|
|
554
|
+
When ``fire`` is False, matches are previewed without mutating trigger state.
|
|
555
|
+
When ``fire`` is True, matching armed triggers transition to fired.
|
|
557
556
|
"""
|
|
558
557
|
if not text or not text.strip():
|
|
559
558
|
return []
|
|
@@ -571,7 +570,7 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
|
|
|
571
570
|
if use_semantic:
|
|
572
571
|
text_vec = embed(text)
|
|
573
572
|
|
|
574
|
-
|
|
573
|
+
matched_triggers = []
|
|
575
574
|
now = datetime.utcnow().isoformat()
|
|
576
575
|
|
|
577
576
|
for trigger in armed:
|
|
@@ -594,11 +593,12 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
|
|
|
594
593
|
match_type = f"semantic({sim:.3f})"
|
|
595
594
|
|
|
596
595
|
if matched:
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
596
|
+
if fire:
|
|
597
|
+
db.execute(
|
|
598
|
+
"UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
|
|
599
|
+
(now, trigger["id"])
|
|
600
|
+
)
|
|
601
|
+
matched_triggers.append({
|
|
602
602
|
"id": trigger["id"],
|
|
603
603
|
"pattern": trigger["trigger_pattern"],
|
|
604
604
|
"action": trigger["action"],
|
|
@@ -607,10 +607,30 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
|
|
|
607
607
|
"created_at": trigger["created_at"],
|
|
608
608
|
})
|
|
609
609
|
|
|
610
|
-
if
|
|
610
|
+
if fire and matched_triggers:
|
|
611
611
|
db.commit()
|
|
612
612
|
|
|
613
|
-
return
|
|
613
|
+
return matched_triggers
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def preview_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
|
|
617
|
+
"""Preview trigger matches without consuming or firing them."""
|
|
618
|
+
return _match_triggers(
|
|
619
|
+
text,
|
|
620
|
+
use_semantic=use_semantic,
|
|
621
|
+
semantic_threshold=semantic_threshold,
|
|
622
|
+
fire=False,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
|
|
627
|
+
"""Check text against all armed triggers and fire matching ones."""
|
|
628
|
+
return _match_triggers(
|
|
629
|
+
text,
|
|
630
|
+
use_semantic=use_semantic,
|
|
631
|
+
semantic_threshold=semantic_threshold,
|
|
632
|
+
fire=True,
|
|
633
|
+
)
|
|
614
634
|
|
|
615
635
|
|
|
616
636
|
def list_triggers(status: str = "armed") -> list[dict]:
|