nexo-brain 2.2.0 → 2.3.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/README.md +5 -5
- package/package.json +6 -3
- package/src/auto_update.py +26 -0
- package/src/crons/manifest.json +6 -13
- package/src/crons/sync.py +150 -6
- package/src/db/__init__.py +13 -0
- package/src/db/_core.py +1 -0
- package/src/db/_cron_runs.py +74 -0
- package/src/db/_entities.py +1 -0
- package/src/db/_episodic.py +41 -6
- package/src/db/_learnings.py +1 -0
- package/src/db/_reminders.py +1 -0
- package/src/db/_schema.py +64 -0
- package/src/db/_sessions.py +1 -0
- package/src/db/_skills.py +515 -0
- package/src/hooks/session-stop.sh +13 -101
- package/src/plugin_loader.py +1 -0
- package/src/plugins/episodic_memory.py +5 -3
- package/src/plugins/schedule.py +212 -0
- package/src/plugins/skills.py +264 -0
- package/src/plugins/update.py +1 -0
- package/src/scripts/deep-sleep/apply_findings.py +111 -8
- package/src/scripts/deep-sleep/collect.py +34 -11
- package/src/scripts/deep-sleep/extract-prompt.md +38 -0
- package/src/scripts/deep-sleep/extract.py +81 -8
- package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
- package/src/scripts/deep-sleep/synthesize.py +4 -1
- package/src/scripts/nexo-catchup.py +65 -29
- package/src/scripts/nexo-cron-wrapper.sh +53 -0
- package/src/scripts/nexo-daily-self-audit.py +4 -2
- package/src/scripts/nexo-deep-sleep.sh +66 -77
- package/src/scripts/nexo-evolution-run.py +13 -0
- package/src/scripts/nexo-learning-housekeep.py +157 -1
- package/src/scripts/nexo-learning-validator.py +19 -0
- package/src/scripts/nexo-postmortem-consolidator.py +3 -2
- package/src/scripts/nexo-sleep.py +16 -11
- package/src/scripts/nexo-synthesis.py +46 -3
- package/src/scripts/nexo-watchdog.sh +91 -30
- package/src/server.py +6 -1
- package/src/tools_coordination.py +1 -0
- package/src/tools_sessions.py +1 -0
- package/scripts/migrate-to-unified 2.sh +0 -813
- package/scripts/migrate-to-unified.sh +0 -813
- package/scripts/migrate-v1.5-to-v1.6 2.py +0 -778
- package/scripts/migrate-v1.5-to-v1.6.py +0 -778
- package/scripts/migrate-v1.7-to-v1.8 2.py +0 -214
- package/scripts/migrate-v1.7-to-v1.8.py +0 -214
- package/scripts/pre-commit-check 2.sh +0 -55
- package/scripts/pre-commit-check.sh +0 -55
- package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
- package/src/__pycache__/hnsw_index.cpython-310.pyc +0 -0
- package/src/__pycache__/kg_populate.cpython-310.pyc +0 -0
- package/src/__pycache__/knowledge_graph.cpython-310.pyc +0 -0
- package/src/__pycache__/plugin_loader.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_coordination.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_credentials.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_learnings.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_menu.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_reminders.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_sessions.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_task_history.cpython-310.pyc +0 -0
- package/src/auto_close_sessions 2.py +0 -159
- package/src/auto_update 2.py +0 -634
- package/src/claim_graph 2.py +0 -323
- package/src/cognitive/__init__ 2.py +0 -62
- package/src/cognitive/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_core.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_decay.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_ingest.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_memory.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_search.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
- package/src/cognitive/_core 2.py +0 -567
- package/src/cognitive/_decay 2.py +0 -382
- package/src/cognitive/_ingest 2.py +0 -892
- package/src/cognitive/_memory 2.py +0 -912
- package/src/cognitive/_search 2.py +0 -949
- package/src/cognitive/_trust 2.py +0 -464
- package/src/crons/manifest 2.json +0 -106
- package/src/crons/sync 2.py +0 -217
- package/src/dashboard/__init__ 2.py +0 -0
- package/src/dashboard/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/dashboard/__pycache__/app.cpython-310.pyc +0 -0
- package/src/dashboard/app 2.py +0 -789
- package/src/db/__init__ 2.py +0 -89
- package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-314.pyc +0 -0
- package/src/db/_core 2.py +0 -417
- package/src/db/_credentials 2.py +0 -124
- package/src/db/_entities 2.py +0 -178
- package/src/db/_episodic 2.py +0 -738
- package/src/db/_evolution 2.py +0 -54
- package/src/db/_fts 2.py +0 -406
- package/src/db/_learnings 2.py +0 -168
- package/src/db/_reminders 2.py +0 -338
- package/src/db/_schema 2.py +0 -364
- package/src/db/_sessions 2.py +0 -300
- package/src/db/_tasks 2.py +0 -91
- package/src/evolution_cycle 2.py +0 -266
- package/src/hnsw_index 2.py +0 -254
- package/src/hooks/auto_capture 2.py +0 -208
- package/src/hooks/caffeinate-guard 2.sh +0 -8
- package/src/hooks/capture-session 2.sh +0 -21
- package/src/hooks/capture-tool-logs 2.sh +0 -127
- package/src/hooks/daily-briefing-check 2.sh +0 -33
- package/src/hooks/inbox-hook 2.sh +0 -76
- package/src/hooks/post-compact 2.sh +0 -148
- package/src/hooks/pre-compact 2.sh +0 -151
- package/src/hooks/session-start 2.sh +0 -268
- package/src/hooks/session-stop 2.sh +0 -140
- package/src/kg_populate 2.py +0 -290
- package/src/knowledge_graph 2.py +0 -257
- package/src/maintenance 2.py +0 -59
- package/src/migrate_embeddings 2.py +0 -122
- package/src/plugin_loader 2.py +0 -202
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/__pycache__/__init__ 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/agents 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/agents.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/artifact_registry 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/artifact_registry.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/backup 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/backup.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cognitive_memory 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cognitive_memory.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/core_rules 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/core_rules.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cortex 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cortex.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/entities 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/entities.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/evolution 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/evolution.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/guard 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/guard.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/knowledge_graph_tools 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/knowledge_graph_tools.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/preferences 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/preferences.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/update 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/update.cpython-310.pyc +0 -0
- package/src/plugins/adaptive_mode 2.py +0 -805
- package/src/plugins/agents 2.py +0 -52
- package/src/plugins/artifact_registry 2.py +0 -450
- package/src/plugins/backup 2.py +0 -104
- package/src/plugins/cognitive_memory 2.py +0 -564
- package/src/plugins/core_rules 2.py +0 -252
- package/src/plugins/cortex 2.py +0 -299
- package/src/plugins/entities 2.py +0 -67
- package/src/plugins/episodic_memory 2.py +0 -533
- package/src/plugins/evolution 2.py +0 -115
- package/src/plugins/guard 2.py +0 -746
- package/src/plugins/knowledge_graph_tools 2.py +0 -105
- package/src/plugins/preferences 2.py +0 -47
- package/src/plugins/update 2.py +0 -256
- package/src/requirements 2.txt +0 -12
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +0 -331
- package/src/rules/migrate 2.py +0 -207
- package/src/scripts/check-context 2.py +0 -264
- package/src/scripts/nexo-auto-update 2.py +0 -6
- package/src/scripts/nexo-backup 2.sh +0 -25
- package/src/scripts/nexo-brain-activation 2.sh +0 -140
- package/src/scripts/nexo-catchup 2.py +0 -242
- package/src/scripts/nexo-cognitive-decay 2.py +0 -182
- package/src/scripts/nexo-daily-self-audit 2.py +0 -552
- package/src/scripts/nexo-deep-sleep 2.sh +0 -97
- package/src/scripts/nexo-evolution-run 2.py +0 -597
- package/src/scripts/nexo-followup-hygiene 2.py +0 -112
- package/src/scripts/nexo-github-monitor 2.py +0 -256
- package/src/scripts/nexo-github-monitor.py +0 -256
- package/src/scripts/nexo-immune 2.py +0 -927
- package/src/scripts/nexo-inbox-hook 2.sh +0 -74
- package/src/scripts/nexo-install 2.py +0 -6
- package/src/scripts/nexo-learning-housekeep 2.py +0 -245
- package/src/scripts/nexo-learning-validator 2.py +0 -207
- package/src/scripts/nexo-migrate 2.py +0 -232
- package/src/scripts/nexo-postmortem-consolidator 2.py +0 -421
- package/src/scripts/nexo-pre-commit 2.py +0 -120
- package/src/scripts/nexo-prevent-sleep 2.sh +0 -29
- package/src/scripts/nexo-proactive-dashboard 2.py +0 -345
- package/src/scripts/nexo-reflection 2.py +0 -253
- package/src/scripts/nexo-runtime-preflight 2.py +0 -274
- package/src/scripts/nexo-send-email 2.py +0 -25
- package/src/scripts/nexo-send-email.py +0 -25
- package/src/scripts/nexo-send-reply 2.py +0 -178
- package/src/scripts/nexo-send-reply.py +0 -178
- package/src/scripts/nexo-sleep 2.py +0 -592
- package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
- package/src/scripts/nexo-synthesis 2.py +0 -253
- package/src/scripts/nexo-tcc-approve 2.sh +0 -79
- package/src/scripts/nexo-update 2.sh +0 -161
- package/src/scripts/nexo-watchdog 2.sh +0 -878
- package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
- package/src/server 2.py +0 -733
- package/src/storage_router 2.py +0 -32
- package/src/tools_coordination 2.py +0 -102
- package/src/tools_credentials 2.py +0 -68
- package/src/tools_learnings 2.py +0 -220
- package/src/tools_menu 2.py +0 -227
- package/src/tools_reminders 2.py +0 -86
- package/src/tools_reminders_crud 2.py +0 -159
- package/src/tools_sessions 2.py +0 -476
- package/src/tools_task_history 2.py +0 -57
- package/templates/CLAUDE.md 2.template +0 -63
- package/templates/openclaw 2.json +0 -13
- package/tests/__init__ 2.py +0 -0
- package/tests/__init__.py +0 -0
- package/tests/conftest 2.py +0 -71
- package/tests/conftest.py +0 -71
- package/tests/test_cognitive 2.py +0 -205
- package/tests/test_cognitive.py +0 -205
- package/tests/test_knowledge_graph 2.py +0 -140
- package/tests/test_knowledge_graph.py +0 -140
- package/tests/test_migrations 2.py +0 -137
- package/tests/test_migrations.py +0 -137
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO DB — Skills module.
|
|
3
|
+
|
|
4
|
+
Skill Auto-Creation system: reusable procedures extracted from complex tasks.
|
|
5
|
+
Skills are procedural (step-by-step how-tos) vs learnings which are declarative.
|
|
6
|
+
|
|
7
|
+
Pipeline: trace → draft → published → archived, fully autonomous.
|
|
8
|
+
Trust score with decay controls quality — no human approval gates.
|
|
9
|
+
|
|
10
|
+
Promotion: draft + 2 successful uses in distinct contexts → published.
|
|
11
|
+
Degradation: trust < 20 → archived. Archived + 60 days unused → purge.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import datetime
|
|
15
|
+
from db._core import get_db
|
|
16
|
+
from db._fts import fts_upsert, fts_search
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Constants ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
VALID_LEVELS = {'trace', 'draft', 'published', 'archived'}
|
|
22
|
+
TRUST_ON_SUCCESS = 5
|
|
23
|
+
TRUST_ON_FAILURE = -10
|
|
24
|
+
TRUST_INITIAL = 50
|
|
25
|
+
TRUST_ARCHIVE_THRESHOLD = 20
|
|
26
|
+
PROMOTION_USES_REQUIRED = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── CRUD ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def create_skill(
|
|
32
|
+
skill_id: str,
|
|
33
|
+
name: str,
|
|
34
|
+
description: str = '',
|
|
35
|
+
level: str = 'trace',
|
|
36
|
+
tags: list | str = '[]',
|
|
37
|
+
trigger_patterns: list | str = '[]',
|
|
38
|
+
source_sessions: list | str = '[]',
|
|
39
|
+
linked_learnings: list | str = '[]',
|
|
40
|
+
file_path: str = '',
|
|
41
|
+
trust_score: int = TRUST_INITIAL,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""Create a new skill entry."""
|
|
44
|
+
if level not in VALID_LEVELS:
|
|
45
|
+
return {"error": f"level must be one of: {', '.join(sorted(VALID_LEVELS))}"}
|
|
46
|
+
|
|
47
|
+
tags_json = json.dumps(tags) if isinstance(tags, list) else tags
|
|
48
|
+
trigger_json = json.dumps(trigger_patterns) if isinstance(trigger_patterns, list) else trigger_patterns
|
|
49
|
+
sessions_json = json.dumps(source_sessions) if isinstance(source_sessions, list) else source_sessions
|
|
50
|
+
learnings_json = json.dumps(linked_learnings) if isinstance(linked_learnings, list) else linked_learnings
|
|
51
|
+
|
|
52
|
+
conn = get_db()
|
|
53
|
+
conn.execute(
|
|
54
|
+
"""INSERT INTO skills
|
|
55
|
+
(id, name, description, level, trust_score, file_path, tags,
|
|
56
|
+
trigger_patterns, source_sessions, linked_learnings)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
58
|
+
(skill_id, name, description, level, trust_score, file_path,
|
|
59
|
+
tags_json, trigger_json, sessions_json, learnings_json),
|
|
60
|
+
)
|
|
61
|
+
conn.commit()
|
|
62
|
+
|
|
63
|
+
# FTS index
|
|
64
|
+
body = f"{description} {tags_json} {trigger_json}"
|
|
65
|
+
fts_upsert("skill", skill_id, name, body, "skill", commit=False)
|
|
66
|
+
|
|
67
|
+
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
68
|
+
return dict(row) if row else {"id": skill_id, "status": "created"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_skill(skill_id: str) -> dict | None:
|
|
72
|
+
"""Get a skill by ID."""
|
|
73
|
+
conn = get_db()
|
|
74
|
+
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
75
|
+
return dict(row) if row else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_skills(level: str = '', tag: str = '') -> list[dict]:
|
|
79
|
+
"""List skills, optionally filtered by level or tag."""
|
|
80
|
+
conn = get_db()
|
|
81
|
+
conditions = []
|
|
82
|
+
params = []
|
|
83
|
+
|
|
84
|
+
if level:
|
|
85
|
+
conditions.append("level = ?")
|
|
86
|
+
params.append(level)
|
|
87
|
+
if tag:
|
|
88
|
+
conditions.append("tags LIKE ?")
|
|
89
|
+
params.append(f'%"{tag}"%')
|
|
90
|
+
|
|
91
|
+
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
92
|
+
rows = conn.execute(
|
|
93
|
+
f"SELECT * FROM skills {where} ORDER BY trust_score DESC, last_used_at DESC",
|
|
94
|
+
tuple(params),
|
|
95
|
+
).fetchall()
|
|
96
|
+
return [dict(r) for r in rows]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def search_skills(query: str, level: str = '') -> list[dict]:
|
|
100
|
+
"""Search skills using FTS5 for ranked results. Falls back to LIKE."""
|
|
101
|
+
fts_results = fts_search(query, source_filter="skill", limit=20)
|
|
102
|
+
if fts_results:
|
|
103
|
+
conn = get_db()
|
|
104
|
+
ids = [r['source_id'] for r in fts_results]
|
|
105
|
+
placeholders = ','.join('?' * len(ids))
|
|
106
|
+
sql = f"SELECT * FROM skills WHERE id IN ({placeholders})"
|
|
107
|
+
params = list(ids)
|
|
108
|
+
if level:
|
|
109
|
+
sql += " AND level = ?"
|
|
110
|
+
params.append(level)
|
|
111
|
+
sql += " ORDER BY trust_score DESC"
|
|
112
|
+
rows = conn.execute(sql, params).fetchall()
|
|
113
|
+
return [dict(r) for r in rows]
|
|
114
|
+
|
|
115
|
+
# Fallback to LIKE
|
|
116
|
+
conn = get_db()
|
|
117
|
+
words = query.strip().split()
|
|
118
|
+
if not words:
|
|
119
|
+
return []
|
|
120
|
+
conditions = []
|
|
121
|
+
params = []
|
|
122
|
+
for word in words:
|
|
123
|
+
p = f"%{word}%"
|
|
124
|
+
conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
|
|
125
|
+
params.extend([p, p, p, p])
|
|
126
|
+
where = " AND ".join(conditions)
|
|
127
|
+
if level:
|
|
128
|
+
where = f"level = ? AND ({where})"
|
|
129
|
+
params.insert(0, level)
|
|
130
|
+
rows = conn.execute(
|
|
131
|
+
f"SELECT * FROM skills WHERE {where} ORDER BY trust_score DESC",
|
|
132
|
+
params,
|
|
133
|
+
).fetchall()
|
|
134
|
+
return [dict(r) for r in rows]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def update_skill(skill_id: str, **kwargs) -> dict:
|
|
138
|
+
"""Update any fields of a skill."""
|
|
139
|
+
conn = get_db()
|
|
140
|
+
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
141
|
+
if not row:
|
|
142
|
+
return {"error": f"Skill {skill_id} not found"}
|
|
143
|
+
|
|
144
|
+
allowed = {
|
|
145
|
+
"name", "description", "level", "trust_score", "file_path",
|
|
146
|
+
"tags", "trigger_patterns", "source_sessions", "linked_learnings",
|
|
147
|
+
}
|
|
148
|
+
updates = {}
|
|
149
|
+
for k, v in kwargs.items():
|
|
150
|
+
if k in allowed:
|
|
151
|
+
if isinstance(v, (list, dict)):
|
|
152
|
+
updates[k] = json.dumps(v)
|
|
153
|
+
else:
|
|
154
|
+
updates[k] = v
|
|
155
|
+
|
|
156
|
+
if not updates:
|
|
157
|
+
return dict(row)
|
|
158
|
+
|
|
159
|
+
updates["updated_at"] = datetime.datetime.now().isoformat(timespec='seconds')
|
|
160
|
+
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
161
|
+
values = list(updates.values()) + [skill_id]
|
|
162
|
+
conn.execute(f"UPDATE skills SET {set_clause} WHERE id = ?", values)
|
|
163
|
+
conn.commit()
|
|
164
|
+
|
|
165
|
+
# Update FTS
|
|
166
|
+
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
167
|
+
r = dict(row)
|
|
168
|
+
body = f"{r.get('description', '')} {r.get('tags', '[]')} {r.get('trigger_patterns', '[]')}"
|
|
169
|
+
fts_upsert("skill", skill_id, r.get("name", ""), body, "skill", commit=False)
|
|
170
|
+
return r
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def delete_skill(skill_id: str) -> bool:
|
|
174
|
+
"""Delete a skill and its usage history."""
|
|
175
|
+
conn = get_db()
|
|
176
|
+
conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (skill_id,))
|
|
177
|
+
result = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
|
|
178
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (skill_id,))
|
|
179
|
+
conn.commit()
|
|
180
|
+
return result.rowcount > 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── Usage tracking & auto-promotion ────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def record_usage(skill_id: str, session_id: str = '', success: bool = True,
|
|
186
|
+
context: str = '', notes: str = '') -> dict:
|
|
187
|
+
"""Record a skill usage and auto-promote/degrade based on trust rules.
|
|
188
|
+
|
|
189
|
+
Returns the updated skill dict with promotion info.
|
|
190
|
+
"""
|
|
191
|
+
conn = get_db()
|
|
192
|
+
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
|
193
|
+
if not row:
|
|
194
|
+
return {"error": f"Skill {skill_id} not found"}
|
|
195
|
+
|
|
196
|
+
skill = dict(row)
|
|
197
|
+
|
|
198
|
+
# Record usage
|
|
199
|
+
conn.execute(
|
|
200
|
+
"INSERT INTO skill_usage (skill_id, session_id, success, context, notes) VALUES (?, ?, ?, ?, ?)",
|
|
201
|
+
(skill_id, session_id, 1 if success else 0, context, notes),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Update counters
|
|
205
|
+
delta = TRUST_ON_SUCCESS if success else TRUST_ON_FAILURE
|
|
206
|
+
new_trust = max(0, min(100, skill['trust_score'] + delta))
|
|
207
|
+
count_field = "success_count" if success else "fail_count"
|
|
208
|
+
|
|
209
|
+
conn.execute(
|
|
210
|
+
f"""UPDATE skills SET
|
|
211
|
+
use_count = use_count + 1,
|
|
212
|
+
{count_field} = {count_field} + 1,
|
|
213
|
+
trust_score = ?,
|
|
214
|
+
last_used_at = datetime('now'),
|
|
215
|
+
updated_at = datetime('now')
|
|
216
|
+
WHERE id = ?""",
|
|
217
|
+
(new_trust, skill_id),
|
|
218
|
+
)
|
|
219
|
+
conn.commit()
|
|
220
|
+
|
|
221
|
+
# Auto-promotion: draft → published if 2+ successful uses in distinct contexts
|
|
222
|
+
promotion = None
|
|
223
|
+
if skill['level'] == 'draft' and success:
|
|
224
|
+
distinct_contexts = conn.execute(
|
|
225
|
+
"""SELECT COUNT(DISTINCT context) FROM skill_usage
|
|
226
|
+
WHERE skill_id = ? AND success = 1 AND context != ''""",
|
|
227
|
+
(skill_id,),
|
|
228
|
+
).fetchone()[0]
|
|
229
|
+
if distinct_contexts >= PROMOTION_USES_REQUIRED:
|
|
230
|
+
conn.execute(
|
|
231
|
+
"UPDATE skills SET level = 'published', updated_at = datetime('now') WHERE id = ?",
|
|
232
|
+
(skill_id,),
|
|
233
|
+
)
|
|
234
|
+
conn.commit()
|
|
235
|
+
promotion = "draft → published"
|
|
236
|
+
|
|
237
|
+
# Auto-archive: trust < 20 → archived
|
|
238
|
+
if new_trust < TRUST_ARCHIVE_THRESHOLD and skill['level'] in ('draft', 'published'):
|
|
239
|
+
conn.execute(
|
|
240
|
+
"UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
|
|
241
|
+
(skill_id,),
|
|
242
|
+
)
|
|
243
|
+
conn.commit()
|
|
244
|
+
promotion = f"{skill['level']} → archived (trust={new_trust})"
|
|
245
|
+
|
|
246
|
+
result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
|
|
247
|
+
if promotion:
|
|
248
|
+
result['_promotion'] = promotion
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def match_skills(task: str, level: str = '', top_n: int = 3) -> list[dict]:
|
|
253
|
+
"""Find skills matching a task description.
|
|
254
|
+
|
|
255
|
+
Search strategy:
|
|
256
|
+
1. FTS5 on skill name/description/tags
|
|
257
|
+
2. Trigger pattern matching
|
|
258
|
+
3. Keyword overlap
|
|
259
|
+
|
|
260
|
+
Returns top-N matches sorted by relevance × trust.
|
|
261
|
+
"""
|
|
262
|
+
if not task or not task.strip():
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
conn = get_db()
|
|
266
|
+
seen = set()
|
|
267
|
+
results = []
|
|
268
|
+
|
|
269
|
+
# Level filter
|
|
270
|
+
level_filter = "AND level = ?" if level else "AND level IN ('draft', 'published')"
|
|
271
|
+
level_params = (level,) if level else ()
|
|
272
|
+
|
|
273
|
+
# Strategy 1: FTS5 search
|
|
274
|
+
fts_results = fts_search(task, source_filter="skill", limit=10)
|
|
275
|
+
if fts_results:
|
|
276
|
+
ids = [r['source_id'] for r in fts_results]
|
|
277
|
+
placeholders = ','.join('?' * len(ids))
|
|
278
|
+
rows = conn.execute(
|
|
279
|
+
f"SELECT * FROM skills WHERE id IN ({placeholders}) {level_filter} ORDER BY trust_score DESC",
|
|
280
|
+
tuple(ids) + level_params,
|
|
281
|
+
).fetchall()
|
|
282
|
+
for r in rows:
|
|
283
|
+
d = dict(r)
|
|
284
|
+
d['_match'] = 'fts'
|
|
285
|
+
if d['id'] not in seen:
|
|
286
|
+
seen.add(d['id'])
|
|
287
|
+
results.append(d)
|
|
288
|
+
|
|
289
|
+
# Strategy 2: Trigger pattern matching
|
|
290
|
+
task_lower = task.lower()
|
|
291
|
+
rows = conn.execute(
|
|
292
|
+
f"SELECT * FROM skills WHERE trigger_patterns != '[]' {level_filter}",
|
|
293
|
+
level_params,
|
|
294
|
+
).fetchall()
|
|
295
|
+
for r in rows:
|
|
296
|
+
if r['id'] in seen:
|
|
297
|
+
continue
|
|
298
|
+
try:
|
|
299
|
+
patterns = json.loads(r['trigger_patterns'])
|
|
300
|
+
for pattern in patterns:
|
|
301
|
+
if pattern.lower() in task_lower or task_lower in pattern.lower():
|
|
302
|
+
d = dict(r)
|
|
303
|
+
d['_match'] = f'trigger:{pattern}'
|
|
304
|
+
seen.add(d['id'])
|
|
305
|
+
results.append(d)
|
|
306
|
+
break
|
|
307
|
+
except (json.JSONDecodeError, TypeError):
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
# Strategy 3: Tag keyword overlap
|
|
311
|
+
task_words = set(task_lower.split())
|
|
312
|
+
rows = conn.execute(
|
|
313
|
+
f"SELECT * FROM skills WHERE tags != '[]' {level_filter}",
|
|
314
|
+
level_params,
|
|
315
|
+
).fetchall()
|
|
316
|
+
for r in rows:
|
|
317
|
+
if r['id'] in seen:
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
tags = json.loads(r['tags'])
|
|
321
|
+
tag_words = set(t.lower() for t in tags)
|
|
322
|
+
overlap = task_words & tag_words
|
|
323
|
+
if overlap:
|
|
324
|
+
d = dict(r)
|
|
325
|
+
d['_match'] = f'tags:{",".join(overlap)}'
|
|
326
|
+
seen.add(d['id'])
|
|
327
|
+
results.append(d)
|
|
328
|
+
except (json.JSONDecodeError, TypeError):
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# Sort by trust_score descending, then return top N
|
|
332
|
+
results.sort(key=lambda x: x.get('trust_score', 0), reverse=True)
|
|
333
|
+
return results[:top_n]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
|
|
337
|
+
"""Merge two similar skills into one. The survivor gets combined metadata.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
id1: First skill ID
|
|
341
|
+
id2: Second skill ID
|
|
342
|
+
keep_id: Which one to keep (default: higher trust). The other is deleted.
|
|
343
|
+
"""
|
|
344
|
+
conn = get_db()
|
|
345
|
+
s1 = conn.execute("SELECT * FROM skills WHERE id = ?", (id1,)).fetchone()
|
|
346
|
+
s2 = conn.execute("SELECT * FROM skills WHERE id = ?", (id2,)).fetchone()
|
|
347
|
+
if not s1:
|
|
348
|
+
return {"error": f"Skill {id1} not found"}
|
|
349
|
+
if not s2:
|
|
350
|
+
return {"error": f"Skill {id2} not found"}
|
|
351
|
+
|
|
352
|
+
s1, s2 = dict(s1), dict(s2)
|
|
353
|
+
|
|
354
|
+
# Decide which to keep
|
|
355
|
+
if not keep_id:
|
|
356
|
+
keep_id = id1 if s1['trust_score'] >= s2['trust_score'] else id2
|
|
357
|
+
survivor = s1 if keep_id == id1 else s2
|
|
358
|
+
donor = s2 if keep_id == id1 else s1
|
|
359
|
+
donor_id = donor['id']
|
|
360
|
+
|
|
361
|
+
# Merge tags
|
|
362
|
+
try:
|
|
363
|
+
tags1 = set(json.loads(survivor.get('tags', '[]')))
|
|
364
|
+
tags2 = set(json.loads(donor.get('tags', '[]')))
|
|
365
|
+
merged_tags = json.dumps(sorted(tags1 | tags2))
|
|
366
|
+
except (json.JSONDecodeError, TypeError):
|
|
367
|
+
merged_tags = survivor.get('tags', '[]')
|
|
368
|
+
|
|
369
|
+
# Merge trigger patterns
|
|
370
|
+
try:
|
|
371
|
+
tp1 = set(json.loads(survivor.get('trigger_patterns', '[]')))
|
|
372
|
+
tp2 = set(json.loads(donor.get('trigger_patterns', '[]')))
|
|
373
|
+
merged_tp = json.dumps(sorted(tp1 | tp2))
|
|
374
|
+
except (json.JSONDecodeError, TypeError):
|
|
375
|
+
merged_tp = survivor.get('trigger_patterns', '[]')
|
|
376
|
+
|
|
377
|
+
# Merge source sessions
|
|
378
|
+
try:
|
|
379
|
+
ss1 = set(json.loads(survivor.get('source_sessions', '[]')))
|
|
380
|
+
ss2 = set(json.loads(donor.get('source_sessions', '[]')))
|
|
381
|
+
merged_ss = json.dumps(sorted(ss1 | ss2, key=str))
|
|
382
|
+
except (json.JSONDecodeError, TypeError):
|
|
383
|
+
merged_ss = survivor.get('source_sessions', '[]')
|
|
384
|
+
|
|
385
|
+
# Merge linked learnings
|
|
386
|
+
try:
|
|
387
|
+
ll1 = set(json.loads(survivor.get('linked_learnings', '[]')))
|
|
388
|
+
ll2 = set(json.loads(donor.get('linked_learnings', '[]')))
|
|
389
|
+
merged_ll = json.dumps(sorted(ll1 | ll2, key=str))
|
|
390
|
+
except (json.JSONDecodeError, TypeError):
|
|
391
|
+
merged_ll = survivor.get('linked_learnings', '[]')
|
|
392
|
+
|
|
393
|
+
# Merge counters
|
|
394
|
+
merged_use = survivor['use_count'] + donor['use_count']
|
|
395
|
+
merged_success = survivor['success_count'] + donor['success_count']
|
|
396
|
+
merged_fail = survivor['fail_count'] + donor['fail_count']
|
|
397
|
+
merged_trust = max(survivor['trust_score'], donor['trust_score'])
|
|
398
|
+
|
|
399
|
+
# Update survivor
|
|
400
|
+
conn.execute(
|
|
401
|
+
"""UPDATE skills SET
|
|
402
|
+
tags = ?, trigger_patterns = ?, source_sessions = ?, linked_learnings = ?,
|
|
403
|
+
use_count = ?, success_count = ?, fail_count = ?, trust_score = ?,
|
|
404
|
+
updated_at = datetime('now')
|
|
405
|
+
WHERE id = ?""",
|
|
406
|
+
(merged_tags, merged_tp, merged_ss, merged_ll,
|
|
407
|
+
merged_use, merged_success, merged_fail, merged_trust, keep_id),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Move usage records from donor to survivor
|
|
411
|
+
conn.execute("UPDATE skill_usage SET skill_id = ? WHERE skill_id = ?", (keep_id, donor_id))
|
|
412
|
+
|
|
413
|
+
# Delete donor
|
|
414
|
+
conn.execute("DELETE FROM skills WHERE id = ?", (donor_id,))
|
|
415
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (donor_id,))
|
|
416
|
+
conn.commit()
|
|
417
|
+
|
|
418
|
+
result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (keep_id,)).fetchone())
|
|
419
|
+
result['_merged_from'] = donor_id
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_skill_stats() -> dict:
|
|
424
|
+
"""Get aggregate skill statistics."""
|
|
425
|
+
conn = get_db()
|
|
426
|
+
total = conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
|
|
427
|
+
by_level = {}
|
|
428
|
+
for row in conn.execute("SELECT level, COUNT(*) as cnt FROM skills GROUP BY level").fetchall():
|
|
429
|
+
by_level[row['level']] = row['cnt']
|
|
430
|
+
|
|
431
|
+
avg_trust = conn.execute(
|
|
432
|
+
"SELECT AVG(trust_score) FROM skills WHERE level != 'archived'"
|
|
433
|
+
).fetchone()[0] or 0
|
|
434
|
+
|
|
435
|
+
total_uses = conn.execute("SELECT COUNT(*) FROM skill_usage").fetchone()[0]
|
|
436
|
+
success_rate = 0
|
|
437
|
+
if total_uses > 0:
|
|
438
|
+
successes = conn.execute("SELECT COUNT(*) FROM skill_usage WHERE success = 1").fetchone()[0]
|
|
439
|
+
success_rate = round(successes / total_uses * 100, 1)
|
|
440
|
+
|
|
441
|
+
recent_uses = conn.execute(
|
|
442
|
+
"SELECT COUNT(*) FROM skill_usage WHERE created_at >= datetime('now', '-7 days')"
|
|
443
|
+
).fetchone()[0]
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
"total": total,
|
|
447
|
+
"by_level": by_level,
|
|
448
|
+
"avg_trust": round(avg_trust, 1),
|
|
449
|
+
"total_uses": total_uses,
|
|
450
|
+
"success_rate": success_rate,
|
|
451
|
+
"uses_last_7d": recent_uses,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def decay_unused_skills(dry_run: bool = False) -> dict:
|
|
456
|
+
"""Decay and purge unused skills. Called by immune.py or maintenance cron.
|
|
457
|
+
|
|
458
|
+
Rules:
|
|
459
|
+
- draft: no use in 30 days → trust = 0 → archived
|
|
460
|
+
- published: no use in 90 days → trust -= 5
|
|
461
|
+
- archived: no use in 60 days → purge (delete)
|
|
462
|
+
"""
|
|
463
|
+
conn = get_db()
|
|
464
|
+
actions = {"decayed": [], "archived": [], "purged": []}
|
|
465
|
+
|
|
466
|
+
# Draft: 30 days no use → archive
|
|
467
|
+
rows = conn.execute("""
|
|
468
|
+
SELECT * FROM skills WHERE level = 'draft'
|
|
469
|
+
AND (last_used_at IS NULL OR last_used_at < datetime('now', '-30 days'))
|
|
470
|
+
AND created_at < datetime('now', '-30 days')
|
|
471
|
+
""").fetchall()
|
|
472
|
+
for r in rows:
|
|
473
|
+
if not dry_run:
|
|
474
|
+
conn.execute(
|
|
475
|
+
"UPDATE skills SET level = 'archived', trust_score = 0, updated_at = datetime('now') WHERE id = ?",
|
|
476
|
+
(r['id'],),
|
|
477
|
+
)
|
|
478
|
+
actions["archived"].append(r['id'])
|
|
479
|
+
|
|
480
|
+
# Published: 90 days no use → trust -= 5
|
|
481
|
+
rows = conn.execute("""
|
|
482
|
+
SELECT * FROM skills WHERE level = 'published'
|
|
483
|
+
AND (last_used_at IS NULL OR last_used_at < datetime('now', '-90 days'))
|
|
484
|
+
""").fetchall()
|
|
485
|
+
for r in rows:
|
|
486
|
+
new_trust = max(0, r['trust_score'] - 5)
|
|
487
|
+
if not dry_run:
|
|
488
|
+
conn.execute(
|
|
489
|
+
"UPDATE skills SET trust_score = ?, updated_at = datetime('now') WHERE id = ?",
|
|
490
|
+
(new_trust, r['id']),
|
|
491
|
+
)
|
|
492
|
+
if new_trust < TRUST_ARCHIVE_THRESHOLD:
|
|
493
|
+
conn.execute(
|
|
494
|
+
"UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
|
|
495
|
+
(r['id'],),
|
|
496
|
+
)
|
|
497
|
+
actions["archived"].append(r['id'])
|
|
498
|
+
actions["decayed"].append({"id": r['id'], "trust": f"{r['trust_score']} → {new_trust}"})
|
|
499
|
+
|
|
500
|
+
# Archived: 60 days → purge
|
|
501
|
+
rows = conn.execute("""
|
|
502
|
+
SELECT * FROM skills WHERE level = 'archived'
|
|
503
|
+
AND (last_used_at IS NULL OR last_used_at < datetime('now', '-60 days'))
|
|
504
|
+
AND updated_at < datetime('now', '-60 days')
|
|
505
|
+
""").fetchall()
|
|
506
|
+
for r in rows:
|
|
507
|
+
if not dry_run:
|
|
508
|
+
conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (r['id'],))
|
|
509
|
+
conn.execute("DELETE FROM skills WHERE id = ?", (r['id'],))
|
|
510
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (r['id'],))
|
|
511
|
+
actions["purged"].append(r['id'])
|
|
512
|
+
|
|
513
|
+
if not dry_run:
|
|
514
|
+
conn.commit()
|
|
515
|
+
return actions
|
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# NEXO Memory Stop Hook (
|
|
2
|
+
# NEXO Memory Stop Hook (v8 — non-blocking, approve always)
|
|
3
3
|
#
|
|
4
|
-
# v5
|
|
5
|
-
# v6
|
|
6
|
-
# v7
|
|
7
|
-
#
|
|
4
|
+
# v5: used "approve" + systemMessage — AI never processed post-mortem.
|
|
5
|
+
# v6: used "block" — but blocked ALL sessions including trivial ones.
|
|
6
|
+
# v7: detects trivial sessions (<5 tool calls) and approves immediately.
|
|
7
|
+
# v8: NEVER blocks. The Stop hook fires after EVERY Claude response (not just
|
|
8
|
+
# session close), so blocking causes mid-conversation interruptions.
|
|
9
|
+
# Post-mortem is now handled by:
|
|
10
|
+
# 1. Claude detecting closing intent (any language) → diary inline
|
|
11
|
+
# 2. auto_close_sessions.py → promotes draft for orphan sessions
|
|
8
12
|
#
|
|
9
|
-
#
|
|
10
|
-
# Trivial session (quick question, <5 tool calls):
|
|
11
|
-
# → APPROVE immediately, no post-mortem needed
|
|
12
|
-
#
|
|
13
|
-
# Non-trivial session:
|
|
14
|
-
# 1. User closes → hook checks flag → not found → BLOCK
|
|
15
|
-
# 2. AI executes post-mortem → creates flag
|
|
16
|
-
# 3. User closes again → hook sees flag → APPROVE
|
|
13
|
+
# This hook only refreshes the diary draft with latest data (best-effort).
|
|
17
14
|
set -uo pipefail
|
|
18
15
|
|
|
19
16
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
20
|
-
FLAG_FILE="$NEXO_HOME/operations/.postmortem-complete"
|
|
21
|
-
TODAY=$(date +%Y-%m-%d)
|
|
22
|
-
TOOL_LOG="$NEXO_HOME/operations/tool-logs/${TODAY}.jsonl"
|
|
23
17
|
|
|
24
|
-
#
|
|
18
|
+
# Refresh diary draft with latest changes/decisions (best-effort)
|
|
25
19
|
python3 -c "
|
|
26
20
|
import sys, json, os
|
|
27
21
|
nexo_home = os.environ.get('NEXO_HOME', os.path.expanduser('~/.nexo'))
|
|
@@ -50,91 +44,9 @@ for s in sessions:
|
|
|
50
44
|
)
|
|
51
45
|
" 2>/dev/null || true
|
|
52
46
|
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
# A session with <5 tool calls (excluding Read/Grep/Glob/Bash) is trivial
|
|
56
|
-
SESSION_START_TS="$NEXO_HOME/operations/.session-start-ts"
|
|
57
|
-
|
|
58
|
-
# 0.5. Detect non-interactive (claude -p) sessions — skip post-mortem entirely
|
|
59
|
-
# SessionStart hook writes .session-start-ts. If missing or stale (>30 min),
|
|
60
|
-
# this is likely a -p script session — approve immediately.
|
|
61
|
-
# Also skip if NEXO_HEADLESS=1 is set (explicit headless mode for scripts).
|
|
62
|
-
if [ "${NEXO_HEADLESS:-}" = "1" ] || [ ! -f "$SESSION_START_TS" ] || [ "$(($(date +%s) - $(cat "$SESSION_START_TS" 2>/dev/null || echo 0)))" -gt 1800 ]; then
|
|
63
|
-
cat << 'HOOKEOF'
|
|
64
|
-
{
|
|
65
|
-
"decision": "approve"
|
|
66
|
-
}
|
|
67
|
-
HOOKEOF
|
|
68
|
-
exit 0
|
|
69
|
-
fi
|
|
70
|
-
SESSION_START=0
|
|
71
|
-
if [ -f "$SESSION_START_TS" ]; then
|
|
72
|
-
SESSION_START=$(cat "$SESSION_START_TS" 2>/dev/null || echo "0")
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
TOOL_COUNT=0
|
|
76
|
-
if [ -f "$TOOL_LOG" ]; then
|
|
77
|
-
TOOL_COUNT=$(python3 -c "
|
|
78
|
-
import json, sys, os
|
|
79
|
-
session_start = float(os.environ.get('SESSION_START', '0'))
|
|
80
|
-
count = 0
|
|
81
|
-
for line in open('$TOOL_LOG'):
|
|
82
|
-
try:
|
|
83
|
-
d = json.loads(line)
|
|
84
|
-
# Only count tools from THIS session (after session-start-ts)
|
|
85
|
-
ts = d.get('timestamp', '')
|
|
86
|
-
if ts and session_start > 0:
|
|
87
|
-
from datetime import datetime
|
|
88
|
-
try:
|
|
89
|
-
entry_ts = datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
|
90
|
-
if entry_ts < session_start:
|
|
91
|
-
continue
|
|
92
|
-
except:
|
|
93
|
-
pass
|
|
94
|
-
t = d.get('tool_name', '')
|
|
95
|
-
if t and t not in ('Read', 'Grep', 'Glob', 'Bash', 'ToolSearch'):
|
|
96
|
-
count += 1
|
|
97
|
-
except:
|
|
98
|
-
pass
|
|
99
|
-
print(count)
|
|
100
|
-
" 2>/dev/null || echo "0")
|
|
101
|
-
fi
|
|
102
|
-
|
|
103
|
-
# Trivial session → approve immediately, no buffer writing, skip post-mortem
|
|
104
|
-
if [ "$TOOL_COUNT" -lt 5 ]; then
|
|
105
|
-
cat << 'HOOKEOF'
|
|
106
|
-
{
|
|
107
|
-
"decision": "approve"
|
|
108
|
-
}
|
|
109
|
-
HOOKEOF
|
|
110
|
-
exit 0
|
|
111
|
-
fi
|
|
112
|
-
|
|
113
|
-
# 2. Non-trivial session — check if post-mortem was already completed
|
|
114
|
-
# Flag has NO TTL — it persists until SessionStart cleans it up next session.
|
|
115
|
-
POSTMORTEM_DONE=false
|
|
116
|
-
if [ -f "$FLAG_FILE" ]; then
|
|
117
|
-
POSTMORTEM_DONE=true
|
|
118
|
-
fi
|
|
119
|
-
|
|
120
|
-
if [ "$POSTMORTEM_DONE" = true ]; then
|
|
121
|
-
# Post-mortem was done — allow session to close
|
|
122
|
-
# NOTE: do NOT delete flag here — SessionStart cleans it up next session
|
|
123
|
-
cat << 'HOOKEOF'
|
|
47
|
+
# Always approve — never interrupt the conversation
|
|
48
|
+
cat << 'HOOKEOF'
|
|
124
49
|
{
|
|
125
50
|
"decision": "approve"
|
|
126
51
|
}
|
|
127
52
|
HOOKEOF
|
|
128
|
-
else
|
|
129
|
-
# Post-mortem NOT done — BLOCK session close and inject instructions
|
|
130
|
-
cat << 'HOOKEOF'
|
|
131
|
-
{
|
|
132
|
-
"decision": "block",
|
|
133
|
-
"reason": "STOP HOOK — MANDATORY POST-MORTEM before ending (do NOT ask permission, do NOT skip):\n\n## 1. SELF-CRITIQUE (MANDATORY — write to session diary)\nAnswer these questions in the self_critique field of nexo_session_diary_write:\n- Did the user have to ask me for something I should have detected or done on my own?\n- Did I wait for the user to tell me something I could have verified proactively?\n- Are there systems/states I can check next session without being asked?\n- Did I repeat an error that already had a registered learning?\n- What would I do differently if I repeated this session?\nIf any answer is YES — write the specific rule that would prevent repetition.\nIf the session was flawless, write 'No self-critique — clean session.'\n\n## 2. SESSION BUFFER\nIf the session was NOT trivial, append ONE JSON line to $NEXO_HOME/brain/session_buffer.jsonl:\n{\"ts\":\"YYYY-MM-DDTHH:MM:SS\",\"tasks\":[...],\"decisions\":[...],\"user_patterns\":[...],\"files_modified\":[...],\"errors_resolved\":[...],\"self_critique\":\"short summary\",\"mood\":\"focused|impatient|exploratory|frustrated|satisfied|neutral\",\"source\":\"claude\"}\n\n## 3. FOLLOWUPS\nIf there were deploys/cron changes/fixes — nexo_followup_create with verification date.\n\n## 4. PROACTIVE SEEDS\nWhat can I leave prepared so the next session starts doing useful work without the user asking?\n\n## 5. MARK COMPLETE\nWhen ALL of the above is done, run:\nbash -c 'mkdir -p $NEXO_HOME/operations && date +%s > $NEXO_HOME/operations/.postmortem-complete'\nThe user will close again and the hook will approve.\n\nIMPORTANT: Do NOT say goodbye, do NOT say goodnight or any farewell. Just execute the steps and mark complete."
|
|
134
|
-
}
|
|
135
|
-
HOOKEOF
|
|
136
|
-
fi
|
|
137
|
-
|
|
138
|
-
# 3. Session buffer fallback REMOVED (v8)
|
|
139
|
-
# The old hook-fallback was 86% noise. Session diary (written by Claude during
|
|
140
|
-
# post-mortem) is the only source of truth now. No more buffer writing.
|
package/src/plugin_loader.py
CHANGED
|
@@ -166,7 +166,8 @@ def handle_session_diary_write(decisions: str, summary: str,
|
|
|
166
166
|
user_signals: str = '',
|
|
167
167
|
domain: str = '',
|
|
168
168
|
session_id: str = '',
|
|
169
|
-
self_critique: str = ''
|
|
169
|
+
self_critique: str = '',
|
|
170
|
+
source: str = 'claude') -> str:
|
|
170
171
|
"""Write session diary entry at end of session. OBLIGATORIO antes de cerrar.
|
|
171
172
|
|
|
172
173
|
Args:
|
|
@@ -179,13 +180,14 @@ def handle_session_diary_write(decisions: str, summary: str,
|
|
|
179
180
|
user_signals: Observable signals from user during session — response speed (fast='s' vs detailed explanations), tone (direct, frustrated, exploratory, excited), corrections given, topics he initiated vs topics NEXO initiated. Factual observations only, not interpretations.
|
|
180
181
|
domain: Project context: ecommerce, project-a, nexo, project-b, server, other
|
|
181
182
|
session_id: Current session ID
|
|
182
|
-
self_critique: REQUIRED. Honest post-mortem
|
|
183
|
+
self_critique: REQUIRED. Honest post-mortem.
|
|
184
|
+
source: Session type. 'claude' for human-interactive sessions (default), 'cron' for automated cron jobs. Affects visibility at startup.
|
|
183
185
|
"""
|
|
184
186
|
sid = session_id or 'unknown'
|
|
185
187
|
# Clean up draft — manual diary supersedes it
|
|
186
188
|
from db import delete_diary_draft
|
|
187
189
|
delete_diary_draft(sid)
|
|
188
|
-
result = write_session_diary(sid, decisions, summary, discarded, pending, context_next, mental_state, domain=domain, user_signals=user_signals, self_critique=self_critique)
|
|
190
|
+
result = write_session_diary(sid, decisions, summary, discarded, pending, context_next, mental_state, domain=domain, user_signals=user_signals, self_critique=self_critique, source=source)
|
|
189
191
|
if "error" in result:
|
|
190
192
|
return f"ERROR: {result['error']}"
|
|
191
193
|
_cognitive_ingest_safe(summary, "diary", f"diary#{result.get('id','')}", f"Session {sid} summary", domain)
|