nexo-brain 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/scripts/migrate-v1.7-to-v1.8.py +2 -2
  4. package/scripts/nexo-preflight.sh +236 -0
  5. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  6. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  7. package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
  8. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  9. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  10. package/src/auto_update.py +25 -0
  11. package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  13. package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
  14. package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
  15. package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
  16. package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
  17. package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
  18. package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
  19. package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
  20. package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
  21. package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
  22. package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
  23. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  24. package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
  25. package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
  26. package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
  27. package/src/crons/manifest.json +6 -13
  28. package/src/crons/sync.py +151 -6
  29. package/src/db/__init__.py +13 -0
  30. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
  34. package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
  35. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  36. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  37. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  38. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  39. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  40. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  41. package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
  42. package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
  43. package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
  44. package/src/db/_cron_runs.py +74 -0
  45. package/src/db/_episodic.py +40 -6
  46. package/src/db/_schema.py +64 -0
  47. package/src/db/_skills.py +514 -0
  48. package/src/hooks/session-stop.sh +13 -101
  49. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  51. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  52. package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
  53. package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
  54. package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
  55. package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
  56. package/src/plugins/episodic_memory.py +5 -3
  57. package/src/plugins/schedule.py +212 -0
  58. package/src/plugins/skills.py +264 -0
  59. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  60. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  61. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  62. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  63. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  64. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  65. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  66. package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
  67. package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
  68. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  69. package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
  70. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  71. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  72. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  73. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  74. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  75. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  76. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  77. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  78. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  79. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  80. package/src/scripts/deep-sleep/apply_findings.py +110 -8
  81. package/src/scripts/deep-sleep/collect.py +33 -11
  82. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  83. package/src/scripts/deep-sleep/extract.py +80 -8
  84. package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
  85. package/src/scripts/deep-sleep/synthesize.py +3 -1
  86. package/src/scripts/nexo-catchup.py +65 -29
  87. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  88. package/src/scripts/nexo-daily-self-audit.py +4 -2
  89. package/src/scripts/nexo-deep-sleep.sh +66 -77
  90. package/src/scripts/nexo-evolution-run.py +13 -0
  91. package/src/scripts/nexo-learning-housekeep.py +156 -1
  92. package/src/scripts/nexo-learning-validator.py +19 -0
  93. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  94. package/src/scripts/nexo-sleep.py +16 -11
  95. package/src/scripts/nexo-synthesis.py +46 -3
  96. package/src/scripts/nexo-watchdog.sh +72 -19
  97. package/src/server.py +5 -1
  98. package/src/scripts/nexo-github-monitor.py +0 -256
@@ -0,0 +1,514 @@
1
+ """NEXO DB — Skills module.
2
+
3
+ Skill Auto-Creation system: reusable procedures extracted from complex tasks.
4
+ Skills are procedural (step-by-step how-tos) vs learnings which are declarative.
5
+
6
+ Pipeline: trace → draft → published → archived, fully autonomous.
7
+ Trust score with decay controls quality — no human approval gates.
8
+
9
+ Promotion: draft + 2 successful uses in distinct contexts → published.
10
+ Degradation: trust < 20 → archived. Archived + 60 days unused → purge.
11
+ """
12
+ import json
13
+ import datetime
14
+ from db._core import get_db
15
+ from db._fts import fts_upsert, fts_search
16
+
17
+
18
+ # ── Constants ──────────────────────────────────────────────────────
19
+
20
+ VALID_LEVELS = {'trace', 'draft', 'published', 'archived'}
21
+ TRUST_ON_SUCCESS = 5
22
+ TRUST_ON_FAILURE = -10
23
+ TRUST_INITIAL = 50
24
+ TRUST_ARCHIVE_THRESHOLD = 20
25
+ PROMOTION_USES_REQUIRED = 2
26
+
27
+
28
+ # ── CRUD ───────────────────────────────────────────────────────────
29
+
30
+ def create_skill(
31
+ skill_id: str,
32
+ name: str,
33
+ description: str = '',
34
+ level: str = 'trace',
35
+ tags: list | str = '[]',
36
+ trigger_patterns: list | str = '[]',
37
+ source_sessions: list | str = '[]',
38
+ linked_learnings: list | str = '[]',
39
+ file_path: str = '',
40
+ trust_score: int = TRUST_INITIAL,
41
+ ) -> dict:
42
+ """Create a new skill entry."""
43
+ if level not in VALID_LEVELS:
44
+ return {"error": f"level must be one of: {', '.join(sorted(VALID_LEVELS))}"}
45
+
46
+ tags_json = json.dumps(tags) if isinstance(tags, list) else tags
47
+ trigger_json = json.dumps(trigger_patterns) if isinstance(trigger_patterns, list) else trigger_patterns
48
+ sessions_json = json.dumps(source_sessions) if isinstance(source_sessions, list) else source_sessions
49
+ learnings_json = json.dumps(linked_learnings) if isinstance(linked_learnings, list) else linked_learnings
50
+
51
+ conn = get_db()
52
+ conn.execute(
53
+ """INSERT INTO skills
54
+ (id, name, description, level, trust_score, file_path, tags,
55
+ trigger_patterns, source_sessions, linked_learnings)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
57
+ (skill_id, name, description, level, trust_score, file_path,
58
+ tags_json, trigger_json, sessions_json, learnings_json),
59
+ )
60
+ conn.commit()
61
+
62
+ # FTS index
63
+ body = f"{description} {tags_json} {trigger_json}"
64
+ fts_upsert("skill", skill_id, name, body, "skill", commit=False)
65
+
66
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
67
+ return dict(row) if row else {"id": skill_id, "status": "created"}
68
+
69
+
70
+ def get_skill(skill_id: str) -> dict | None:
71
+ """Get a skill by ID."""
72
+ conn = get_db()
73
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
74
+ return dict(row) if row else None
75
+
76
+
77
+ def list_skills(level: str = '', tag: str = '') -> list[dict]:
78
+ """List skills, optionally filtered by level or tag."""
79
+ conn = get_db()
80
+ conditions = []
81
+ params = []
82
+
83
+ if level:
84
+ conditions.append("level = ?")
85
+ params.append(level)
86
+ if tag:
87
+ conditions.append("tags LIKE ?")
88
+ params.append(f'%"{tag}"%')
89
+
90
+ where = "WHERE " + " AND ".join(conditions) if conditions else ""
91
+ rows = conn.execute(
92
+ f"SELECT * FROM skills {where} ORDER BY trust_score DESC, last_used_at DESC",
93
+ tuple(params),
94
+ ).fetchall()
95
+ return [dict(r) for r in rows]
96
+
97
+
98
+ def search_skills(query: str, level: str = '') -> list[dict]:
99
+ """Search skills using FTS5 for ranked results. Falls back to LIKE."""
100
+ fts_results = fts_search(query, source_filter="skill", limit=20)
101
+ if fts_results:
102
+ conn = get_db()
103
+ ids = [r['source_id'] for r in fts_results]
104
+ placeholders = ','.join('?' * len(ids))
105
+ sql = f"SELECT * FROM skills WHERE id IN ({placeholders})"
106
+ params = list(ids)
107
+ if level:
108
+ sql += " AND level = ?"
109
+ params.append(level)
110
+ sql += " ORDER BY trust_score DESC"
111
+ rows = conn.execute(sql, params).fetchall()
112
+ return [dict(r) for r in rows]
113
+
114
+ # Fallback to LIKE
115
+ conn = get_db()
116
+ words = query.strip().split()
117
+ if not words:
118
+ return []
119
+ conditions = []
120
+ params = []
121
+ for word in words:
122
+ p = f"%{word}%"
123
+ conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
124
+ params.extend([p, p, p, p])
125
+ where = " AND ".join(conditions)
126
+ if level:
127
+ where = f"level = ? AND ({where})"
128
+ params.insert(0, level)
129
+ rows = conn.execute(
130
+ f"SELECT * FROM skills WHERE {where} ORDER BY trust_score DESC",
131
+ params,
132
+ ).fetchall()
133
+ return [dict(r) for r in rows]
134
+
135
+
136
+ def update_skill(skill_id: str, **kwargs) -> dict:
137
+ """Update any fields of a skill."""
138
+ conn = get_db()
139
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
140
+ if not row:
141
+ return {"error": f"Skill {skill_id} not found"}
142
+
143
+ allowed = {
144
+ "name", "description", "level", "trust_score", "file_path",
145
+ "tags", "trigger_patterns", "source_sessions", "linked_learnings",
146
+ }
147
+ updates = {}
148
+ for k, v in kwargs.items():
149
+ if k in allowed:
150
+ if isinstance(v, (list, dict)):
151
+ updates[k] = json.dumps(v)
152
+ else:
153
+ updates[k] = v
154
+
155
+ if not updates:
156
+ return dict(row)
157
+
158
+ updates["updated_at"] = datetime.datetime.now().isoformat(timespec='seconds')
159
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
160
+ values = list(updates.values()) + [skill_id]
161
+ conn.execute(f"UPDATE skills SET {set_clause} WHERE id = ?", values)
162
+ conn.commit()
163
+
164
+ # Update FTS
165
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
166
+ r = dict(row)
167
+ body = f"{r.get('description', '')} {r.get('tags', '[]')} {r.get('trigger_patterns', '[]')}"
168
+ fts_upsert("skill", skill_id, r.get("name", ""), body, "skill", commit=False)
169
+ return r
170
+
171
+
172
+ def delete_skill(skill_id: str) -> bool:
173
+ """Delete a skill and its usage history."""
174
+ conn = get_db()
175
+ conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (skill_id,))
176
+ result = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
177
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (skill_id,))
178
+ conn.commit()
179
+ return result.rowcount > 0
180
+
181
+
182
+ # ── Usage tracking & auto-promotion ────────────────────────────────
183
+
184
+ def record_usage(skill_id: str, session_id: str = '', success: bool = True,
185
+ context: str = '', notes: str = '') -> dict:
186
+ """Record a skill usage and auto-promote/degrade based on trust rules.
187
+
188
+ Returns the updated skill dict with promotion info.
189
+ """
190
+ conn = get_db()
191
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
192
+ if not row:
193
+ return {"error": f"Skill {skill_id} not found"}
194
+
195
+ skill = dict(row)
196
+
197
+ # Record usage
198
+ conn.execute(
199
+ "INSERT INTO skill_usage (skill_id, session_id, success, context, notes) VALUES (?, ?, ?, ?, ?)",
200
+ (skill_id, session_id, 1 if success else 0, context, notes),
201
+ )
202
+
203
+ # Update counters
204
+ delta = TRUST_ON_SUCCESS if success else TRUST_ON_FAILURE
205
+ new_trust = max(0, min(100, skill['trust_score'] + delta))
206
+ count_field = "success_count" if success else "fail_count"
207
+
208
+ conn.execute(
209
+ f"""UPDATE skills SET
210
+ use_count = use_count + 1,
211
+ {count_field} = {count_field} + 1,
212
+ trust_score = ?,
213
+ last_used_at = datetime('now'),
214
+ updated_at = datetime('now')
215
+ WHERE id = ?""",
216
+ (new_trust, skill_id),
217
+ )
218
+ conn.commit()
219
+
220
+ # Auto-promotion: draft → published if 2+ successful uses in distinct contexts
221
+ promotion = None
222
+ if skill['level'] == 'draft' and success:
223
+ distinct_contexts = conn.execute(
224
+ """SELECT COUNT(DISTINCT context) FROM skill_usage
225
+ WHERE skill_id = ? AND success = 1 AND context != ''""",
226
+ (skill_id,),
227
+ ).fetchone()[0]
228
+ if distinct_contexts >= PROMOTION_USES_REQUIRED:
229
+ conn.execute(
230
+ "UPDATE skills SET level = 'published', updated_at = datetime('now') WHERE id = ?",
231
+ (skill_id,),
232
+ )
233
+ conn.commit()
234
+ promotion = "draft → published"
235
+
236
+ # Auto-archive: trust < 20 → archived
237
+ if new_trust < TRUST_ARCHIVE_THRESHOLD and skill['level'] in ('draft', 'published'):
238
+ conn.execute(
239
+ "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
240
+ (skill_id,),
241
+ )
242
+ conn.commit()
243
+ promotion = f"{skill['level']} → archived (trust={new_trust})"
244
+
245
+ result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
246
+ if promotion:
247
+ result['_promotion'] = promotion
248
+ return result
249
+
250
+
251
+ def match_skills(task: str, level: str = '', top_n: int = 3) -> list[dict]:
252
+ """Find skills matching a task description.
253
+
254
+ Search strategy:
255
+ 1. FTS5 on skill name/description/tags
256
+ 2. Trigger pattern matching
257
+ 3. Keyword overlap
258
+
259
+ Returns top-N matches sorted by relevance × trust.
260
+ """
261
+ if not task or not task.strip():
262
+ return []
263
+
264
+ conn = get_db()
265
+ seen = set()
266
+ results = []
267
+
268
+ # Level filter
269
+ level_filter = "AND level = ?" if level else "AND level IN ('draft', 'published')"
270
+ level_params = (level,) if level else ()
271
+
272
+ # Strategy 1: FTS5 search
273
+ fts_results = fts_search(task, source_filter="skill", limit=10)
274
+ if fts_results:
275
+ ids = [r['source_id'] for r in fts_results]
276
+ placeholders = ','.join('?' * len(ids))
277
+ rows = conn.execute(
278
+ f"SELECT * FROM skills WHERE id IN ({placeholders}) {level_filter} ORDER BY trust_score DESC",
279
+ tuple(ids) + level_params,
280
+ ).fetchall()
281
+ for r in rows:
282
+ d = dict(r)
283
+ d['_match'] = 'fts'
284
+ if d['id'] not in seen:
285
+ seen.add(d['id'])
286
+ results.append(d)
287
+
288
+ # Strategy 2: Trigger pattern matching
289
+ task_lower = task.lower()
290
+ rows = conn.execute(
291
+ f"SELECT * FROM skills WHERE trigger_patterns != '[]' {level_filter}",
292
+ level_params,
293
+ ).fetchall()
294
+ for r in rows:
295
+ if r['id'] in seen:
296
+ continue
297
+ try:
298
+ patterns = json.loads(r['trigger_patterns'])
299
+ for pattern in patterns:
300
+ if pattern.lower() in task_lower or task_lower in pattern.lower():
301
+ d = dict(r)
302
+ d['_match'] = f'trigger:{pattern}'
303
+ seen.add(d['id'])
304
+ results.append(d)
305
+ break
306
+ except (json.JSONDecodeError, TypeError):
307
+ pass
308
+
309
+ # Strategy 3: Tag keyword overlap
310
+ task_words = set(task_lower.split())
311
+ rows = conn.execute(
312
+ f"SELECT * FROM skills WHERE tags != '[]' {level_filter}",
313
+ level_params,
314
+ ).fetchall()
315
+ for r in rows:
316
+ if r['id'] in seen:
317
+ continue
318
+ try:
319
+ tags = json.loads(r['tags'])
320
+ tag_words = set(t.lower() for t in tags)
321
+ overlap = task_words & tag_words
322
+ if overlap:
323
+ d = dict(r)
324
+ d['_match'] = f'tags:{",".join(overlap)}'
325
+ seen.add(d['id'])
326
+ results.append(d)
327
+ except (json.JSONDecodeError, TypeError):
328
+ pass
329
+
330
+ # Sort by trust_score descending, then return top N
331
+ results.sort(key=lambda x: x.get('trust_score', 0), reverse=True)
332
+ return results[:top_n]
333
+
334
+
335
+ def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
336
+ """Merge two similar skills into one. The survivor gets combined metadata.
337
+
338
+ Args:
339
+ id1: First skill ID
340
+ id2: Second skill ID
341
+ keep_id: Which one to keep (default: higher trust). The other is deleted.
342
+ """
343
+ conn = get_db()
344
+ s1 = conn.execute("SELECT * FROM skills WHERE id = ?", (id1,)).fetchone()
345
+ s2 = conn.execute("SELECT * FROM skills WHERE id = ?", (id2,)).fetchone()
346
+ if not s1:
347
+ return {"error": f"Skill {id1} not found"}
348
+ if not s2:
349
+ return {"error": f"Skill {id2} not found"}
350
+
351
+ s1, s2 = dict(s1), dict(s2)
352
+
353
+ # Decide which to keep
354
+ if not keep_id:
355
+ keep_id = id1 if s1['trust_score'] >= s2['trust_score'] else id2
356
+ survivor = s1 if keep_id == id1 else s2
357
+ donor = s2 if keep_id == id1 else s1
358
+ donor_id = donor['id']
359
+
360
+ # Merge tags
361
+ try:
362
+ tags1 = set(json.loads(survivor.get('tags', '[]')))
363
+ tags2 = set(json.loads(donor.get('tags', '[]')))
364
+ merged_tags = json.dumps(sorted(tags1 | tags2))
365
+ except (json.JSONDecodeError, TypeError):
366
+ merged_tags = survivor.get('tags', '[]')
367
+
368
+ # Merge trigger patterns
369
+ try:
370
+ tp1 = set(json.loads(survivor.get('trigger_patterns', '[]')))
371
+ tp2 = set(json.loads(donor.get('trigger_patterns', '[]')))
372
+ merged_tp = json.dumps(sorted(tp1 | tp2))
373
+ except (json.JSONDecodeError, TypeError):
374
+ merged_tp = survivor.get('trigger_patterns', '[]')
375
+
376
+ # Merge source sessions
377
+ try:
378
+ ss1 = set(json.loads(survivor.get('source_sessions', '[]')))
379
+ ss2 = set(json.loads(donor.get('source_sessions', '[]')))
380
+ merged_ss = json.dumps(sorted(ss1 | ss2, key=str))
381
+ except (json.JSONDecodeError, TypeError):
382
+ merged_ss = survivor.get('source_sessions', '[]')
383
+
384
+ # Merge linked learnings
385
+ try:
386
+ ll1 = set(json.loads(survivor.get('linked_learnings', '[]')))
387
+ ll2 = set(json.loads(donor.get('linked_learnings', '[]')))
388
+ merged_ll = json.dumps(sorted(ll1 | ll2, key=str))
389
+ except (json.JSONDecodeError, TypeError):
390
+ merged_ll = survivor.get('linked_learnings', '[]')
391
+
392
+ # Merge counters
393
+ merged_use = survivor['use_count'] + donor['use_count']
394
+ merged_success = survivor['success_count'] + donor['success_count']
395
+ merged_fail = survivor['fail_count'] + donor['fail_count']
396
+ merged_trust = max(survivor['trust_score'], donor['trust_score'])
397
+
398
+ # Update survivor
399
+ conn.execute(
400
+ """UPDATE skills SET
401
+ tags = ?, trigger_patterns = ?, source_sessions = ?, linked_learnings = ?,
402
+ use_count = ?, success_count = ?, fail_count = ?, trust_score = ?,
403
+ updated_at = datetime('now')
404
+ WHERE id = ?""",
405
+ (merged_tags, merged_tp, merged_ss, merged_ll,
406
+ merged_use, merged_success, merged_fail, merged_trust, keep_id),
407
+ )
408
+
409
+ # Move usage records from donor to survivor
410
+ conn.execute("UPDATE skill_usage SET skill_id = ? WHERE skill_id = ?", (keep_id, donor_id))
411
+
412
+ # Delete donor
413
+ conn.execute("DELETE FROM skills WHERE id = ?", (donor_id,))
414
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (donor_id,))
415
+ conn.commit()
416
+
417
+ result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (keep_id,)).fetchone())
418
+ result['_merged_from'] = donor_id
419
+ return result
420
+
421
+
422
+ def get_skill_stats() -> dict:
423
+ """Get aggregate skill statistics."""
424
+ conn = get_db()
425
+ total = conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
426
+ by_level = {}
427
+ for row in conn.execute("SELECT level, COUNT(*) as cnt FROM skills GROUP BY level").fetchall():
428
+ by_level[row['level']] = row['cnt']
429
+
430
+ avg_trust = conn.execute(
431
+ "SELECT AVG(trust_score) FROM skills WHERE level != 'archived'"
432
+ ).fetchone()[0] or 0
433
+
434
+ total_uses = conn.execute("SELECT COUNT(*) FROM skill_usage").fetchone()[0]
435
+ success_rate = 0
436
+ if total_uses > 0:
437
+ successes = conn.execute("SELECT COUNT(*) FROM skill_usage WHERE success = 1").fetchone()[0]
438
+ success_rate = round(successes / total_uses * 100, 1)
439
+
440
+ recent_uses = conn.execute(
441
+ "SELECT COUNT(*) FROM skill_usage WHERE created_at >= datetime('now', '-7 days')"
442
+ ).fetchone()[0]
443
+
444
+ return {
445
+ "total": total,
446
+ "by_level": by_level,
447
+ "avg_trust": round(avg_trust, 1),
448
+ "total_uses": total_uses,
449
+ "success_rate": success_rate,
450
+ "uses_last_7d": recent_uses,
451
+ }
452
+
453
+
454
+ def decay_unused_skills(dry_run: bool = False) -> dict:
455
+ """Decay and purge unused skills. Called by immune.py or maintenance cron.
456
+
457
+ Rules:
458
+ - draft: no use in 30 days → trust = 0 → archived
459
+ - published: no use in 90 days → trust -= 5
460
+ - archived: no use in 60 days → purge (delete)
461
+ """
462
+ conn = get_db()
463
+ actions = {"decayed": [], "archived": [], "purged": []}
464
+
465
+ # Draft: 30 days no use → archive
466
+ rows = conn.execute("""
467
+ SELECT * FROM skills WHERE level = 'draft'
468
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-30 days'))
469
+ AND created_at < datetime('now', '-30 days')
470
+ """).fetchall()
471
+ for r in rows:
472
+ if not dry_run:
473
+ conn.execute(
474
+ "UPDATE skills SET level = 'archived', trust_score = 0, updated_at = datetime('now') WHERE id = ?",
475
+ (r['id'],),
476
+ )
477
+ actions["archived"].append(r['id'])
478
+
479
+ # Published: 90 days no use → trust -= 5
480
+ rows = conn.execute("""
481
+ SELECT * FROM skills WHERE level = 'published'
482
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-90 days'))
483
+ """).fetchall()
484
+ for r in rows:
485
+ new_trust = max(0, r['trust_score'] - 5)
486
+ if not dry_run:
487
+ conn.execute(
488
+ "UPDATE skills SET trust_score = ?, updated_at = datetime('now') WHERE id = ?",
489
+ (new_trust, r['id']),
490
+ )
491
+ if new_trust < TRUST_ARCHIVE_THRESHOLD:
492
+ conn.execute(
493
+ "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
494
+ (r['id'],),
495
+ )
496
+ actions["archived"].append(r['id'])
497
+ actions["decayed"].append({"id": r['id'], "trust": f"{r['trust_score']} → {new_trust}"})
498
+
499
+ # Archived: 60 days → purge
500
+ rows = conn.execute("""
501
+ SELECT * FROM skills WHERE level = 'archived'
502
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-60 days'))
503
+ AND updated_at < datetime('now', '-60 days')
504
+ """).fetchall()
505
+ for r in rows:
506
+ if not dry_run:
507
+ conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (r['id'],))
508
+ conn.execute("DELETE FROM skills WHERE id = ?", (r['id'],))
509
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (r['id'],))
510
+ actions["purged"].append(r['id'])
511
+
512
+ if not dry_run:
513
+ conn.commit()
514
+ 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.