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.
Files changed (256) hide show
  1. package/README.md +5 -5
  2. package/package.json +6 -3
  3. package/src/auto_update.py +26 -0
  4. package/src/crons/manifest.json +6 -13
  5. package/src/crons/sync.py +150 -6
  6. package/src/db/__init__.py +13 -0
  7. package/src/db/_core.py +1 -0
  8. package/src/db/_cron_runs.py +74 -0
  9. package/src/db/_entities.py +1 -0
  10. package/src/db/_episodic.py +41 -6
  11. package/src/db/_learnings.py +1 -0
  12. package/src/db/_reminders.py +1 -0
  13. package/src/db/_schema.py +64 -0
  14. package/src/db/_sessions.py +1 -0
  15. package/src/db/_skills.py +515 -0
  16. package/src/hooks/session-stop.sh +13 -101
  17. package/src/plugin_loader.py +1 -0
  18. package/src/plugins/episodic_memory.py +5 -3
  19. package/src/plugins/schedule.py +212 -0
  20. package/src/plugins/skills.py +264 -0
  21. package/src/plugins/update.py +1 -0
  22. package/src/scripts/deep-sleep/apply_findings.py +111 -8
  23. package/src/scripts/deep-sleep/collect.py +34 -11
  24. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  25. package/src/scripts/deep-sleep/extract.py +81 -8
  26. package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
  27. package/src/scripts/deep-sleep/synthesize.py +4 -1
  28. package/src/scripts/nexo-catchup.py +65 -29
  29. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  30. package/src/scripts/nexo-daily-self-audit.py +4 -2
  31. package/src/scripts/nexo-deep-sleep.sh +66 -77
  32. package/src/scripts/nexo-evolution-run.py +13 -0
  33. package/src/scripts/nexo-learning-housekeep.py +157 -1
  34. package/src/scripts/nexo-learning-validator.py +19 -0
  35. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  36. package/src/scripts/nexo-sleep.py +16 -11
  37. package/src/scripts/nexo-synthesis.py +46 -3
  38. package/src/scripts/nexo-watchdog.sh +91 -30
  39. package/src/server.py +6 -1
  40. package/src/tools_coordination.py +1 -0
  41. package/src/tools_sessions.py +1 -0
  42. package/scripts/migrate-to-unified 2.sh +0 -813
  43. package/scripts/migrate-to-unified.sh +0 -813
  44. package/scripts/migrate-v1.5-to-v1.6 2.py +0 -778
  45. package/scripts/migrate-v1.5-to-v1.6.py +0 -778
  46. package/scripts/migrate-v1.7-to-v1.8 2.py +0 -214
  47. package/scripts/migrate-v1.7-to-v1.8.py +0 -214
  48. package/scripts/pre-commit-check 2.sh +0 -55
  49. package/scripts/pre-commit-check.sh +0 -55
  50. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  51. package/src/__pycache__/hnsw_index.cpython-310.pyc +0 -0
  52. package/src/__pycache__/kg_populate.cpython-310.pyc +0 -0
  53. package/src/__pycache__/knowledge_graph.cpython-310.pyc +0 -0
  54. package/src/__pycache__/plugin_loader.cpython-310.pyc +0 -0
  55. package/src/__pycache__/tools_coordination.cpython-310.pyc +0 -0
  56. package/src/__pycache__/tools_credentials.cpython-310.pyc +0 -0
  57. package/src/__pycache__/tools_learnings.cpython-310.pyc +0 -0
  58. package/src/__pycache__/tools_menu.cpython-310.pyc +0 -0
  59. package/src/__pycache__/tools_reminders.cpython-310.pyc +0 -0
  60. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  61. package/src/__pycache__/tools_sessions.cpython-310.pyc +0 -0
  62. package/src/__pycache__/tools_task_history.cpython-310.pyc +0 -0
  63. package/src/auto_close_sessions 2.py +0 -159
  64. package/src/auto_update 2.py +0 -634
  65. package/src/claim_graph 2.py +0 -323
  66. package/src/cognitive/__init__ 2.py +0 -62
  67. package/src/cognitive/__pycache__/__init__.cpython-310.pyc +0 -0
  68. package/src/cognitive/__pycache__/_core.cpython-310.pyc +0 -0
  69. package/src/cognitive/__pycache__/_decay.cpython-310.pyc +0 -0
  70. package/src/cognitive/__pycache__/_ingest.cpython-310.pyc +0 -0
  71. package/src/cognitive/__pycache__/_memory.cpython-310.pyc +0 -0
  72. package/src/cognitive/__pycache__/_search.cpython-310.pyc +0 -0
  73. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  74. package/src/cognitive/_core 2.py +0 -567
  75. package/src/cognitive/_decay 2.py +0 -382
  76. package/src/cognitive/_ingest 2.py +0 -892
  77. package/src/cognitive/_memory 2.py +0 -912
  78. package/src/cognitive/_search 2.py +0 -949
  79. package/src/cognitive/_trust 2.py +0 -464
  80. package/src/crons/manifest 2.json +0 -106
  81. package/src/crons/sync 2.py +0 -217
  82. package/src/dashboard/__init__ 2.py +0 -0
  83. package/src/dashboard/__pycache__/__init__.cpython-310.pyc +0 -0
  84. package/src/dashboard/__pycache__/app.cpython-310.pyc +0 -0
  85. package/src/dashboard/app 2.py +0 -789
  86. package/src/db/__init__ 2.py +0 -89
  87. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  88. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  89. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  90. package/src/db/__pycache__/_core.cpython-310.pyc +0 -0
  91. package/src/db/__pycache__/_core.cpython-312.pyc +0 -0
  92. package/src/db/__pycache__/_core.cpython-314.pyc +0 -0
  93. package/src/db/__pycache__/_credentials.cpython-310.pyc +0 -0
  94. package/src/db/__pycache__/_credentials.cpython-312.pyc +0 -0
  95. package/src/db/__pycache__/_credentials.cpython-314.pyc +0 -0
  96. package/src/db/__pycache__/_entities.cpython-310.pyc +0 -0
  97. package/src/db/__pycache__/_entities.cpython-312.pyc +0 -0
  98. package/src/db/__pycache__/_entities.cpython-314.pyc +0 -0
  99. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  100. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  101. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  102. package/src/db/__pycache__/_evolution.cpython-310.pyc +0 -0
  103. package/src/db/__pycache__/_evolution.cpython-312.pyc +0 -0
  104. package/src/db/__pycache__/_evolution.cpython-314.pyc +0 -0
  105. package/src/db/__pycache__/_fts.cpython-310.pyc +0 -0
  106. package/src/db/__pycache__/_fts.cpython-312.pyc +0 -0
  107. package/src/db/__pycache__/_fts.cpython-314.pyc +0 -0
  108. package/src/db/__pycache__/_learnings.cpython-310.pyc +0 -0
  109. package/src/db/__pycache__/_learnings.cpython-312.pyc +0 -0
  110. package/src/db/__pycache__/_learnings.cpython-314.pyc +0 -0
  111. package/src/db/__pycache__/_reminders.cpython-310.pyc +0 -0
  112. package/src/db/__pycache__/_reminders.cpython-312.pyc +0 -0
  113. package/src/db/__pycache__/_reminders.cpython-314.pyc +0 -0
  114. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  115. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  116. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  117. package/src/db/__pycache__/_sessions.cpython-310.pyc +0 -0
  118. package/src/db/__pycache__/_sessions.cpython-312.pyc +0 -0
  119. package/src/db/__pycache__/_sessions.cpython-314.pyc +0 -0
  120. package/src/db/__pycache__/_tasks.cpython-310.pyc +0 -0
  121. package/src/db/__pycache__/_tasks.cpython-312.pyc +0 -0
  122. package/src/db/__pycache__/_tasks.cpython-314.pyc +0 -0
  123. package/src/db/_core 2.py +0 -417
  124. package/src/db/_credentials 2.py +0 -124
  125. package/src/db/_entities 2.py +0 -178
  126. package/src/db/_episodic 2.py +0 -738
  127. package/src/db/_evolution 2.py +0 -54
  128. package/src/db/_fts 2.py +0 -406
  129. package/src/db/_learnings 2.py +0 -168
  130. package/src/db/_reminders 2.py +0 -338
  131. package/src/db/_schema 2.py +0 -364
  132. package/src/db/_sessions 2.py +0 -300
  133. package/src/db/_tasks 2.py +0 -91
  134. package/src/evolution_cycle 2.py +0 -266
  135. package/src/hnsw_index 2.py +0 -254
  136. package/src/hooks/auto_capture 2.py +0 -208
  137. package/src/hooks/caffeinate-guard 2.sh +0 -8
  138. package/src/hooks/capture-session 2.sh +0 -21
  139. package/src/hooks/capture-tool-logs 2.sh +0 -127
  140. package/src/hooks/daily-briefing-check 2.sh +0 -33
  141. package/src/hooks/inbox-hook 2.sh +0 -76
  142. package/src/hooks/post-compact 2.sh +0 -148
  143. package/src/hooks/pre-compact 2.sh +0 -151
  144. package/src/hooks/session-start 2.sh +0 -268
  145. package/src/hooks/session-stop 2.sh +0 -140
  146. package/src/kg_populate 2.py +0 -290
  147. package/src/knowledge_graph 2.py +0 -257
  148. package/src/maintenance 2.py +0 -59
  149. package/src/migrate_embeddings 2.py +0 -122
  150. package/src/plugin_loader 2.py +0 -202
  151. package/src/plugins/__init__ 2.py +0 -0
  152. package/src/plugins/__pycache__/__init__ 2.cpython-310.pyc +0 -0
  153. package/src/plugins/__pycache__/__init__.cpython-310.pyc +0 -0
  154. package/src/plugins/__pycache__/adaptive_mode 2.cpython-310.pyc +0 -0
  155. package/src/plugins/__pycache__/adaptive_mode.cpython-310.pyc +0 -0
  156. package/src/plugins/__pycache__/agents 2.cpython-310.pyc +0 -0
  157. package/src/plugins/__pycache__/agents.cpython-310.pyc +0 -0
  158. package/src/plugins/__pycache__/artifact_registry 2.cpython-310.pyc +0 -0
  159. package/src/plugins/__pycache__/artifact_registry.cpython-310.pyc +0 -0
  160. package/src/plugins/__pycache__/backup 2.cpython-310.pyc +0 -0
  161. package/src/plugins/__pycache__/backup.cpython-310.pyc +0 -0
  162. package/src/plugins/__pycache__/cognitive_memory 2.cpython-310.pyc +0 -0
  163. package/src/plugins/__pycache__/cognitive_memory.cpython-310.pyc +0 -0
  164. package/src/plugins/__pycache__/core_rules 2.cpython-310.pyc +0 -0
  165. package/src/plugins/__pycache__/core_rules.cpython-310.pyc +0 -0
  166. package/src/plugins/__pycache__/cortex 2.cpython-310.pyc +0 -0
  167. package/src/plugins/__pycache__/cortex.cpython-310.pyc +0 -0
  168. package/src/plugins/__pycache__/entities 2.cpython-310.pyc +0 -0
  169. package/src/plugins/__pycache__/entities.cpython-310.pyc +0 -0
  170. package/src/plugins/__pycache__/episodic_memory 2.cpython-310.pyc +0 -0
  171. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  172. package/src/plugins/__pycache__/evolution 2.cpython-310.pyc +0 -0
  173. package/src/plugins/__pycache__/evolution.cpython-310.pyc +0 -0
  174. package/src/plugins/__pycache__/guard 2.cpython-310.pyc +0 -0
  175. package/src/plugins/__pycache__/guard.cpython-310.pyc +0 -0
  176. package/src/plugins/__pycache__/knowledge_graph_tools 2.cpython-310.pyc +0 -0
  177. package/src/plugins/__pycache__/knowledge_graph_tools.cpython-310.pyc +0 -0
  178. package/src/plugins/__pycache__/preferences 2.cpython-310.pyc +0 -0
  179. package/src/plugins/__pycache__/preferences.cpython-310.pyc +0 -0
  180. package/src/plugins/__pycache__/update 2.cpython-310.pyc +0 -0
  181. package/src/plugins/__pycache__/update.cpython-310.pyc +0 -0
  182. package/src/plugins/adaptive_mode 2.py +0 -805
  183. package/src/plugins/agents 2.py +0 -52
  184. package/src/plugins/artifact_registry 2.py +0 -450
  185. package/src/plugins/backup 2.py +0 -104
  186. package/src/plugins/cognitive_memory 2.py +0 -564
  187. package/src/plugins/core_rules 2.py +0 -252
  188. package/src/plugins/cortex 2.py +0 -299
  189. package/src/plugins/entities 2.py +0 -67
  190. package/src/plugins/episodic_memory 2.py +0 -533
  191. package/src/plugins/evolution 2.py +0 -115
  192. package/src/plugins/guard 2.py +0 -746
  193. package/src/plugins/knowledge_graph_tools 2.py +0 -105
  194. package/src/plugins/preferences 2.py +0 -47
  195. package/src/plugins/update 2.py +0 -256
  196. package/src/requirements 2.txt +0 -12
  197. package/src/rules/__init__ 2.py +0 -0
  198. package/src/rules/core-rules 2.json +0 -331
  199. package/src/rules/migrate 2.py +0 -207
  200. package/src/scripts/check-context 2.py +0 -264
  201. package/src/scripts/nexo-auto-update 2.py +0 -6
  202. package/src/scripts/nexo-backup 2.sh +0 -25
  203. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  204. package/src/scripts/nexo-catchup 2.py +0 -242
  205. package/src/scripts/nexo-cognitive-decay 2.py +0 -182
  206. package/src/scripts/nexo-daily-self-audit 2.py +0 -552
  207. package/src/scripts/nexo-deep-sleep 2.sh +0 -97
  208. package/src/scripts/nexo-evolution-run 2.py +0 -597
  209. package/src/scripts/nexo-followup-hygiene 2.py +0 -112
  210. package/src/scripts/nexo-github-monitor 2.py +0 -256
  211. package/src/scripts/nexo-github-monitor.py +0 -256
  212. package/src/scripts/nexo-immune 2.py +0 -927
  213. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  214. package/src/scripts/nexo-install 2.py +0 -6
  215. package/src/scripts/nexo-learning-housekeep 2.py +0 -245
  216. package/src/scripts/nexo-learning-validator 2.py +0 -207
  217. package/src/scripts/nexo-migrate 2.py +0 -232
  218. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -421
  219. package/src/scripts/nexo-pre-commit 2.py +0 -120
  220. package/src/scripts/nexo-prevent-sleep 2.sh +0 -29
  221. package/src/scripts/nexo-proactive-dashboard 2.py +0 -345
  222. package/src/scripts/nexo-reflection 2.py +0 -253
  223. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  224. package/src/scripts/nexo-send-email 2.py +0 -25
  225. package/src/scripts/nexo-send-email.py +0 -25
  226. package/src/scripts/nexo-send-reply 2.py +0 -178
  227. package/src/scripts/nexo-send-reply.py +0 -178
  228. package/src/scripts/nexo-sleep 2.py +0 -592
  229. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  230. package/src/scripts/nexo-synthesis 2.py +0 -253
  231. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  232. package/src/scripts/nexo-update 2.sh +0 -161
  233. package/src/scripts/nexo-watchdog 2.sh +0 -878
  234. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  235. package/src/server 2.py +0 -733
  236. package/src/storage_router 2.py +0 -32
  237. package/src/tools_coordination 2.py +0 -102
  238. package/src/tools_credentials 2.py +0 -68
  239. package/src/tools_learnings 2.py +0 -220
  240. package/src/tools_menu 2.py +0 -227
  241. package/src/tools_reminders 2.py +0 -86
  242. package/src/tools_reminders_crud 2.py +0 -159
  243. package/src/tools_sessions 2.py +0 -476
  244. package/src/tools_task_history 2.py +0 -57
  245. package/templates/CLAUDE.md 2.template +0 -63
  246. package/templates/openclaw 2.json +0 -13
  247. package/tests/__init__ 2.py +0 -0
  248. package/tests/__init__.py +0 -0
  249. package/tests/conftest 2.py +0 -71
  250. package/tests/conftest.py +0 -71
  251. package/tests/test_cognitive 2.py +0 -205
  252. package/tests/test_cognitive.py +0 -205
  253. package/tests/test_knowledge_graph 2.py +0 -140
  254. package/tests/test_knowledge_graph.py +0 -140
  255. package/tests/test_migrations 2.py +0 -137
  256. 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 (v7BLOCKING post-mortem with trivial session detection)
2
+ # NEXO Memory Stop Hook (v8non-blocking, approve always)
3
3
  #
4
- # v5 bug: used "approve" + systemMessage — AI never processed post-mortem.
5
- # v6 fix: uses "block" — but blocked ALL sessions including trivial ones.
6
- # v7 fix: detects trivial sessions (<5 tool calls) and approves immediately.
7
- # Non-trivial sessions get blocked until post-mortem is done.
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
- # Flow:
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
- # 0. Refresh diary draft with latest changes/decisions (best-effort)
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
- # 1. Detect trivial session count meaningful tool calls from THIS session only
54
- # Uses .session-start-ts written by SessionStart hook
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 approvenever 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.
@@ -1,3 +1,4 @@
1
+ from __future__ import annotations
1
2
  """Dynamic plugin loader for NEXO MCP server."""
2
3
 
3
4
  import importlib
@@ -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 = '') -> 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: What should I have done proactively? Did user have to ask me something I should have detected? Did I repeat known errors? What concrete rule would prevent repetition? If clean session: 'No self-critique — clean session.'
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)