nexo-brain 2.7.0 → 3.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.
- 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 +295 -7
- package/src/cli.py +111 -0
- package/src/client_preferences.py +99 -1
- package/src/client_sync.py +207 -3
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +141 -1
- 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/boot.py +45 -19
- package/src/doctor/providers/runtime.py +923 -8
- 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/requirements.txt +1 -0
- 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
|
|
@@ -5,9 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
7
|
import sys
|
|
8
|
-
import tomllib
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
10
|
+
try:
|
|
11
|
+
import tomllib
|
|
12
|
+
except ModuleNotFoundError: # Python < 3.11
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
11
15
|
from runtime_power import load_schedule_config, save_schedule_config
|
|
12
16
|
|
|
13
17
|
|
|
@@ -30,6 +34,12 @@ AUTOMATION_BACKEND_KEYS = (
|
|
|
30
34
|
CLIENT_CLAUDE_CODE,
|
|
31
35
|
CLIENT_CODEX,
|
|
32
36
|
)
|
|
37
|
+
AUTOMATION_TASK_PROFILE_KEYS = (
|
|
38
|
+
"default",
|
|
39
|
+
"fast",
|
|
40
|
+
"balanced",
|
|
41
|
+
"deep",
|
|
42
|
+
)
|
|
33
43
|
INSTALL_PREFERENCE_KEYS = {
|
|
34
44
|
"ask",
|
|
35
45
|
"auto",
|
|
@@ -40,6 +50,10 @@ DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
|
40
50
|
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
41
51
|
DEFAULT_CODEX_MODEL = "gpt-5.4"
|
|
42
52
|
DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
|
|
53
|
+
DEFAULT_FAST_MODEL = "gpt-5.4-mini"
|
|
54
|
+
DEFAULT_FAST_REASONING_EFFORT = "medium"
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
def _user_home() -> Path:
|
|
44
58
|
return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
|
|
45
59
|
|
|
@@ -77,6 +91,7 @@ def default_client_preferences() -> dict:
|
|
|
77
91
|
"automation_enabled": True,
|
|
78
92
|
"automation_backend": CLIENT_CLAUDE_CODE,
|
|
79
93
|
"client_runtime_profiles": default_client_runtime_profiles(),
|
|
94
|
+
"automation_task_profiles": default_automation_task_profiles(),
|
|
80
95
|
"client_install_preferences": {
|
|
81
96
|
CLIENT_CLAUDE_CODE: "ask",
|
|
82
97
|
CLIENT_CODEX: "ask",
|
|
@@ -237,6 +252,31 @@ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
|
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
|
|
255
|
+
def default_automation_task_profiles() -> dict[str, dict[str, str]]:
|
|
256
|
+
return {
|
|
257
|
+
"default": {
|
|
258
|
+
"backend": "",
|
|
259
|
+
"model": "",
|
|
260
|
+
"reasoning_effort": "",
|
|
261
|
+
},
|
|
262
|
+
"fast": {
|
|
263
|
+
"backend": CLIENT_CODEX,
|
|
264
|
+
"model": DEFAULT_FAST_MODEL,
|
|
265
|
+
"reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
|
|
266
|
+
},
|
|
267
|
+
"balanced": {
|
|
268
|
+
"backend": "",
|
|
269
|
+
"model": "",
|
|
270
|
+
"reasoning_effort": "",
|
|
271
|
+
},
|
|
272
|
+
"deep": {
|
|
273
|
+
"backend": CLIENT_CLAUDE_CODE,
|
|
274
|
+
"model": DEFAULT_CLAUDE_CODE_MODEL,
|
|
275
|
+
"reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
240
280
|
def _normalize_runtime_model(value, *, default: str) -> str:
|
|
241
281
|
candidate = str(value or "").strip()
|
|
242
282
|
return candidate or default
|
|
@@ -276,6 +316,31 @@ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
|
|
|
276
316
|
return normalized
|
|
277
317
|
|
|
278
318
|
|
|
319
|
+
def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
|
|
320
|
+
defaults = default_automation_task_profiles()
|
|
321
|
+
normalized = {key: dict(profile) for key, profile in defaults.items()}
|
|
322
|
+
if not isinstance(value, dict):
|
|
323
|
+
return normalized
|
|
324
|
+
|
|
325
|
+
for raw_profile, raw_value in value.items():
|
|
326
|
+
profile_key = str(raw_profile or "").strip().lower()
|
|
327
|
+
if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
|
|
328
|
+
continue
|
|
329
|
+
if not isinstance(raw_value, dict):
|
|
330
|
+
continue
|
|
331
|
+
backend = normalize_backend_key(raw_value.get("backend"))
|
|
332
|
+
if backend == BACKEND_NONE:
|
|
333
|
+
backend = ""
|
|
334
|
+
normalized[profile_key] = {
|
|
335
|
+
"backend": backend or defaults[profile_key]["backend"],
|
|
336
|
+
"model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
|
|
337
|
+
"reasoning_effort": str(
|
|
338
|
+
raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
|
|
339
|
+
).strip().lower(),
|
|
340
|
+
}
|
|
341
|
+
return normalized
|
|
342
|
+
|
|
343
|
+
|
|
279
344
|
def normalize_client_preferences(
|
|
280
345
|
schedule: dict | None = None,
|
|
281
346
|
*,
|
|
@@ -312,6 +377,9 @@ def normalize_client_preferences(
|
|
|
312
377
|
"automation_enabled": automation_enabled,
|
|
313
378
|
"automation_backend": automation_backend,
|
|
314
379
|
"client_runtime_profiles": runtime_profiles,
|
|
380
|
+
"automation_task_profiles": normalize_automation_task_profiles(
|
|
381
|
+
schedule.get("automation_task_profiles")
|
|
382
|
+
),
|
|
315
383
|
"client_install_preferences": install_preferences,
|
|
316
384
|
}
|
|
317
385
|
|
|
@@ -325,6 +393,7 @@ def apply_client_preferences(
|
|
|
325
393
|
automation_enabled=None,
|
|
326
394
|
automation_backend: str | None = None,
|
|
327
395
|
client_runtime_profiles: dict | None = None,
|
|
396
|
+
automation_task_profiles: dict | None = None,
|
|
328
397
|
client_install_preferences: dict | None = None,
|
|
329
398
|
) -> dict:
|
|
330
399
|
merged = dict(schedule or {})
|
|
@@ -352,6 +421,11 @@ def apply_client_preferences(
|
|
|
352
421
|
if client_runtime_profiles is not None
|
|
353
422
|
else current["client_runtime_profiles"]
|
|
354
423
|
)
|
|
424
|
+
merged["automation_task_profiles"] = normalize_automation_task_profiles(
|
|
425
|
+
automation_task_profiles
|
|
426
|
+
if automation_task_profiles is not None
|
|
427
|
+
else current["automation_task_profiles"]
|
|
428
|
+
)
|
|
355
429
|
merged["client_install_preferences"] = normalize_client_install_preferences(
|
|
356
430
|
client_install_preferences
|
|
357
431
|
if client_install_preferences is not None
|
|
@@ -372,6 +446,7 @@ def save_client_preferences(
|
|
|
372
446
|
automation_enabled=None,
|
|
373
447
|
automation_backend: str | None = None,
|
|
374
448
|
client_runtime_profiles: dict | None = None,
|
|
449
|
+
automation_task_profiles: dict | None = None,
|
|
375
450
|
client_install_preferences: dict | None = None,
|
|
376
451
|
) -> Path:
|
|
377
452
|
schedule = apply_client_preferences(
|
|
@@ -382,6 +457,7 @@ def save_client_preferences(
|
|
|
382
457
|
automation_enabled=automation_enabled,
|
|
383
458
|
automation_backend=automation_backend,
|
|
384
459
|
client_runtime_profiles=client_runtime_profiles,
|
|
460
|
+
automation_task_profiles=automation_task_profiles,
|
|
385
461
|
client_install_preferences=client_install_preferences,
|
|
386
462
|
)
|
|
387
463
|
return save_schedule_config(schedule)
|
|
@@ -477,3 +553,25 @@ def resolve_client_runtime_profile(
|
|
|
477
553
|
default=defaults[client_key]["reasoning_effort"],
|
|
478
554
|
),
|
|
479
555
|
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def resolve_automation_task_profile(
|
|
559
|
+
profile: str | None,
|
|
560
|
+
*,
|
|
561
|
+
preferences: dict | None = None,
|
|
562
|
+
) -> dict[str, str]:
|
|
563
|
+
normalized = preferences or load_client_preferences()
|
|
564
|
+
defaults = default_automation_task_profiles()
|
|
565
|
+
profile_key = str(profile or "").strip().lower() or "default"
|
|
566
|
+
if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
|
|
567
|
+
profile_key = "default"
|
|
568
|
+
configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
|
|
569
|
+
selected = dict(configured.get(profile_key) or defaults[profile_key])
|
|
570
|
+
backend = selected.get("backend") or resolve_automation_backend(normalized)
|
|
571
|
+
runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
|
|
572
|
+
return {
|
|
573
|
+
"name": profile_key,
|
|
574
|
+
"backend": backend,
|
|
575
|
+
"model": selected.get("model") or runtime_profile["model"],
|
|
576
|
+
"reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
|
|
577
|
+
}
|
package/src/client_sync.py
CHANGED
|
@@ -5,12 +5,18 @@ 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
|
|
11
|
-
import tomllib
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
try:
|
|
16
|
+
import tomllib
|
|
17
|
+
except ModuleNotFoundError: # Python < 3.11
|
|
18
|
+
import tomli as tomllib
|
|
19
|
+
|
|
14
20
|
from bootstrap_docs import sync_client_bootstrap
|
|
15
21
|
|
|
16
22
|
try:
|
|
@@ -308,6 +314,179 @@ def _write_json_object(path: Path, payload: dict) -> None:
|
|
|
308
314
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
309
315
|
|
|
310
316
|
|
|
317
|
+
CORE_HOOK_SPECS = [
|
|
318
|
+
{
|
|
319
|
+
"event": "SessionStart",
|
|
320
|
+
"identity": "session-start-ts",
|
|
321
|
+
"timeout": 2,
|
|
322
|
+
"command_template": lambda nexo_home, _runtime_root, _hooks_dir: (
|
|
323
|
+
f"mkdir -p {shlex.quote(str(nexo_home / 'operations'))} && "
|
|
324
|
+
f"date +%s > {shlex.quote(str(nexo_home / 'operations' / '.session-start-ts'))}"
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"event": "SessionStart",
|
|
329
|
+
"identity": "daily-briefing-check.sh",
|
|
330
|
+
"timeout": 5,
|
|
331
|
+
"script": "daily-briefing-check.sh",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"event": "SessionStart",
|
|
335
|
+
"identity": "session-start.sh",
|
|
336
|
+
"timeout": 35,
|
|
337
|
+
"script": "session-start.sh",
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
"event": "Stop",
|
|
341
|
+
"identity": "session-stop.sh",
|
|
342
|
+
"timeout": 10,
|
|
343
|
+
"script": "session-stop.sh",
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"event": "PostToolUse",
|
|
347
|
+
"identity": "capture-tool-logs.sh",
|
|
348
|
+
"timeout": 5,
|
|
349
|
+
"script": "capture-tool-logs.sh",
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"event": "PostToolUse",
|
|
353
|
+
"identity": "capture-session.sh",
|
|
354
|
+
"timeout": 3,
|
|
355
|
+
"script": "capture-session.sh",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"event": "PostToolUse",
|
|
359
|
+
"identity": "inbox-hook.sh",
|
|
360
|
+
"timeout": 5,
|
|
361
|
+
"script": "inbox-hook.sh",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"event": "PostToolUse",
|
|
365
|
+
"identity": "protocol-guardrail.sh",
|
|
366
|
+
"timeout": 5,
|
|
367
|
+
"script": "protocol-guardrail.sh",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"event": "PreCompact",
|
|
371
|
+
"identity": "pre-compact.sh",
|
|
372
|
+
"timeout": 10,
|
|
373
|
+
"script": "pre-compact.sh",
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"event": "PostCompact",
|
|
377
|
+
"identity": "post-compact.sh",
|
|
378
|
+
"timeout": 10,
|
|
379
|
+
"script": "post-compact.sh",
|
|
380
|
+
},
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _resolve_hook_source_dir(runtime_root: Path) -> Path:
|
|
385
|
+
direct = runtime_root / "hooks"
|
|
386
|
+
if direct.is_dir():
|
|
387
|
+
return direct
|
|
388
|
+
sibling = runtime_root.parent / "src" / "hooks"
|
|
389
|
+
if sibling.is_dir():
|
|
390
|
+
return sibling
|
|
391
|
+
fallback = runtime_root.parent / "hooks"
|
|
392
|
+
if fallback.is_dir():
|
|
393
|
+
return fallback
|
|
394
|
+
return direct
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _render_hook_command(spec: dict, *, nexo_home: Path, runtime_root: Path, hooks_dir: Path) -> str:
|
|
398
|
+
command_template = spec.get("command_template")
|
|
399
|
+
if callable(command_template):
|
|
400
|
+
return command_template(nexo_home, runtime_root, hooks_dir)
|
|
401
|
+
script_name = spec.get("script", "").strip()
|
|
402
|
+
script_path = hooks_dir / script_name
|
|
403
|
+
return (
|
|
404
|
+
f"NEXO_HOME={shlex.quote(str(nexo_home))} "
|
|
405
|
+
f"NEXO_CODE={shlex.quote(str(runtime_root))} "
|
|
406
|
+
f"bash {shlex.quote(str(script_path))}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _hook_identity(command: str) -> str:
|
|
411
|
+
text = str(command or "")
|
|
412
|
+
if ".session-start-ts" in text:
|
|
413
|
+
return "session-start-ts"
|
|
414
|
+
match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
|
|
415
|
+
if match:
|
|
416
|
+
return match.group(1)
|
|
417
|
+
return text.strip()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _normalize_hook_sections(entries) -> list[dict]:
|
|
421
|
+
normalized: list[dict] = []
|
|
422
|
+
for entry in entries or []:
|
|
423
|
+
if not isinstance(entry, dict):
|
|
424
|
+
continue
|
|
425
|
+
hooks = entry.get("hooks")
|
|
426
|
+
if isinstance(hooks, list):
|
|
427
|
+
normalized.append(
|
|
428
|
+
{
|
|
429
|
+
"matcher": entry.get("matcher", "*") or "*",
|
|
430
|
+
"hooks": [dict(hook) for hook in hooks if isinstance(hook, dict)],
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
if entry.get("command"):
|
|
435
|
+
hook = {"type": entry.get("type", "command"), "command": entry["command"]}
|
|
436
|
+
if entry.get("timeout"):
|
|
437
|
+
hook["timeout"] = entry["timeout"]
|
|
438
|
+
normalized.append({"matcher": entry.get("matcher", "*") or "*", "hooks": [hook]})
|
|
439
|
+
return normalized
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _merge_core_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
|
|
443
|
+
hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
|
|
444
|
+
hooks_dir = _resolve_hook_source_dir(runtime_root)
|
|
445
|
+
managed_count = 0
|
|
446
|
+
|
|
447
|
+
for spec in CORE_HOOK_SPECS:
|
|
448
|
+
event = spec["event"]
|
|
449
|
+
sections = _normalize_hook_sections(hooks_payload.get(event))
|
|
450
|
+
hooks_payload[event] = sections
|
|
451
|
+
command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
|
|
452
|
+
identity = spec["identity"]
|
|
453
|
+
|
|
454
|
+
found = False
|
|
455
|
+
for section in sections:
|
|
456
|
+
for hook in section["hooks"]:
|
|
457
|
+
if _hook_identity(hook.get("command", "")) != identity:
|
|
458
|
+
continue
|
|
459
|
+
hook["type"] = "command"
|
|
460
|
+
hook["command"] = command
|
|
461
|
+
if spec.get("timeout"):
|
|
462
|
+
hook["timeout"] = spec["timeout"]
|
|
463
|
+
found = True
|
|
464
|
+
managed_count += 1
|
|
465
|
+
break
|
|
466
|
+
if found:
|
|
467
|
+
break
|
|
468
|
+
|
|
469
|
+
if found:
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
target = None
|
|
473
|
+
for section in sections:
|
|
474
|
+
if section.get("matcher", "*") == "*":
|
|
475
|
+
target = section
|
|
476
|
+
break
|
|
477
|
+
if target is None:
|
|
478
|
+
target = {"matcher": "*", "hooks": []}
|
|
479
|
+
sections.append(target)
|
|
480
|
+
|
|
481
|
+
new_hook = {"type": "command", "command": command}
|
|
482
|
+
if spec.get("timeout"):
|
|
483
|
+
new_hook["timeout"] = spec["timeout"]
|
|
484
|
+
target["hooks"].append(new_hook)
|
|
485
|
+
managed_count += 1
|
|
486
|
+
|
|
487
|
+
return hooks_payload, managed_count
|
|
488
|
+
|
|
489
|
+
|
|
311
490
|
def _sync_json_client(path: Path, server_config: dict, label: str, *, managed_metadata: dict | None = None) -> dict:
|
|
312
491
|
payload = _load_json_object(path)
|
|
313
492
|
mcp_servers = payload.setdefault("mcpServers", {})
|
|
@@ -343,6 +522,32 @@ def _claude_desktop_managed_metadata(server_config: dict, *, operator_name: str)
|
|
|
343
522
|
}
|
|
344
523
|
|
|
345
524
|
|
|
525
|
+
def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
|
|
526
|
+
payload = _load_json_object(path)
|
|
527
|
+
mcp_servers = payload.setdefault("mcpServers", {})
|
|
528
|
+
if not isinstance(mcp_servers, dict):
|
|
529
|
+
mcp_servers = {}
|
|
530
|
+
payload["mcpServers"] = mcp_servers
|
|
531
|
+
action = "updated" if "nexo" in mcp_servers else "created"
|
|
532
|
+
mcp_servers["nexo"] = server_config
|
|
533
|
+
|
|
534
|
+
runtime_root = Path(server_config.get("env", {}).get("NEXO_CODE", "")).expanduser()
|
|
535
|
+
nexo_home = Path(server_config.get("env", {}).get("NEXO_HOME", "")).expanduser()
|
|
536
|
+
payload["hooks"], managed_hook_count = _merge_core_hooks(
|
|
537
|
+
payload.get("hooks", {}),
|
|
538
|
+
runtime_root=runtime_root,
|
|
539
|
+
nexo_home=nexo_home,
|
|
540
|
+
)
|
|
541
|
+
_write_json_object(path, payload)
|
|
542
|
+
return {
|
|
543
|
+
"ok": True,
|
|
544
|
+
"client": "claude_code",
|
|
545
|
+
"action": action,
|
|
546
|
+
"path": str(path),
|
|
547
|
+
"managed_hook_count": managed_hook_count,
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
346
551
|
def sync_claude_code(
|
|
347
552
|
*,
|
|
348
553
|
nexo_home: str | os.PathLike[str] | None = None,
|
|
@@ -358,10 +563,9 @@ def sync_claude_code(
|
|
|
358
563
|
python_path=python_path,
|
|
359
564
|
operator_name=operator_name,
|
|
360
565
|
)
|
|
361
|
-
result =
|
|
566
|
+
result = _sync_claude_code_settings(
|
|
362
567
|
_claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
|
|
363
568
|
server_config,
|
|
364
|
-
"claude_code",
|
|
365
569
|
)
|
|
366
570
|
bootstrap_result = sync_client_bootstrap(
|
|
367
571
|
"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]:
|