nexo-brain 5.3.19 → 5.3.21

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,762 @@
1
+ from __future__ import annotations
2
+ """NEXO DB — Episodic module."""
3
+ import datetime, time, json
4
+ from db._core import get_db, now_epoch, _multi_word_like
5
+ from db._fts import fts_upsert, fts_search
6
+
7
+ # ── Change Log ───────────────────────────────────────────────────
8
+
9
+ def cleanup_old_changes(retention_days: int = 90) -> int:
10
+ """Delete change_log entries older than retention_days. Returns count deleted."""
11
+ conn = get_db()
12
+ # Get IDs before deleting so we can clean FTS
13
+ ids = [str(r[0]) for r in conn.execute(
14
+ "SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
15
+ (f"-{retention_days} days",)
16
+ ).fetchall()]
17
+ cursor = conn.execute(
18
+ "DELETE FROM change_log WHERE created_at < datetime('now', ?)",
19
+ (f"-{retention_days} days",)
20
+ )
21
+ for cid in ids:
22
+ conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
23
+ conn.commit()
24
+ return cursor.rowcount
25
+
26
+
27
+ def log_change(session_id: str, files: str, what_changed: str, why: str,
28
+ triggered_by: str = '', affects: str = '', risks: str = '',
29
+ verify: str = '', commit_ref: str = '') -> dict:
30
+ """Log a code/config change with full context."""
31
+ conn = get_db()
32
+ cleanup_old_changes()
33
+ try:
34
+ cursor = conn.execute(
35
+ "INSERT INTO change_log (session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref) "
36
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
37
+ (session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref)
38
+ )
39
+ conn.commit()
40
+ cid = cursor.lastrowid
41
+ body = f"{what_changed} {why} {triggered_by} {affects} {risks}"
42
+ fts_upsert("change", str(cid), files, body, "change_log", commit=False)
43
+ row = conn.execute("SELECT * FROM change_log WHERE id = ?", (cid,)).fetchone()
44
+ return dict(row)
45
+ except Exception as e:
46
+ return {"error": str(e)}
47
+
48
+
49
+ def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dict]:
50
+ """Search change log by text and/or file path."""
51
+ conn = get_db()
52
+ days = max(1, int(days))
53
+ conditions = []
54
+ params = []
55
+ if query:
56
+ frag, qparams = _multi_word_like(query, ["what_changed", "why", "affects", "triggered_by"])
57
+ conditions.append(f"({frag})")
58
+ params.extend(qparams)
59
+ if files:
60
+ frag_f, fparams = _multi_word_like(files, ["files"])
61
+ conditions.append(f"({frag_f})")
62
+ params.extend(fparams)
63
+ conditions.append("created_at >= datetime('now', ?)")
64
+ params.append(f"-{days} days")
65
+ where = " AND ".join(conditions)
66
+ rows = conn.execute(
67
+ f"SELECT * FROM change_log WHERE {where} ORDER BY created_at DESC",
68
+ params
69
+ ).fetchall()
70
+ return [dict(r) for r in rows]
71
+
72
+
73
+ def auto_resolve_followups(change: dict) -> list[str]:
74
+ """Cross-reference a change_log entry with open followups. Auto-completes matches.
75
+
76
+ Matching logic:
77
+ 1. File overlap: if change touched files mentioned in followup description
78
+ 2. Keyword overlap: Jaccard similarity between change text and followup text
79
+ 3. ID reference: if followup ID appears in the change's triggered_by/why fields
80
+
81
+ Returns list of followup IDs that were auto-resolved.
82
+ """
83
+ conn = get_db()
84
+ open_followups = conn.execute(
85
+ "SELECT * FROM followups WHERE status NOT LIKE 'COMPLETED%' "
86
+ "AND status NOT IN ('DELETED','archived','blocked','waiting')"
87
+ ).fetchall()
88
+
89
+ if not open_followups:
90
+ return []
91
+
92
+ change_text = " ".join(str(change.get(f, "")) for f in
93
+ ["files", "what_changed", "why", "triggered_by", "affects"])
94
+ change_files = set(change.get("files", "").replace(",", " ").split())
95
+ change_tokens = {w.lower() for w in change_text.split() if len(w) > 3}
96
+
97
+ resolved = []
98
+ for f in open_followups:
99
+ fid = f["id"]
100
+ fdesc = f"{fid} {f['description']} {f['verification'] or ''}"
101
+ ftokens = {w.lower() for w in fdesc.split() if len(w) > 3}
102
+
103
+ # Check 1: followup ID explicitly in change trigger/why
104
+ if fid.lower() in change_text.lower():
105
+ resolved.append(fid)
106
+ continue
107
+
108
+ # Check 2: file overlap (any changed file mentioned in followup)
109
+ if change_files:
110
+ for cf in change_files:
111
+ basename = cf.rsplit("/", 1)[-1] if "/" in cf else cf
112
+ if basename and len(basename) > 4 and basename.lower() in fdesc.lower():
113
+ resolved.append(fid)
114
+ break
115
+ if fid in resolved:
116
+ continue
117
+
118
+ # Check 3: keyword similarity (asymmetric overlap >= 0.35)
119
+ if ftokens and change_tokens:
120
+ intersection = ftokens & change_tokens
121
+ smaller = min(len(ftokens), len(change_tokens))
122
+ score = len(intersection) / smaller if smaller else 0
123
+ if score >= 0.35:
124
+ resolved.append(fid)
125
+
126
+ # Auto-complete matched followups
127
+ from db._reminders import complete_followup
128
+ commit_ref = change.get("commit_ref", "")
129
+ for fid in resolved:
130
+ complete_followup(fid, result=f"Auto-resolved by change #{change.get('id', '?')} (commit {commit_ref[:8] if commit_ref else 'N/A'})")
131
+
132
+ return resolved
133
+
134
+
135
+ def update_change_commit(id: int, commit_ref: str) -> dict:
136
+ """Link a change log entry to its git commit after commit.
137
+
138
+ After linking, auto-resolves any open followups that match the change.
139
+ """
140
+ conn = get_db()
141
+ row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
142
+ if not row:
143
+ return {"error": f"Change {id} not found"}
144
+ conn.execute("UPDATE change_log SET commit_ref = ? WHERE id = ?", (commit_ref, id))
145
+ conn.commit()
146
+ row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
147
+ r = dict(row)
148
+ body = f"{r.get('what_changed','')} {r.get('why','')} {r.get('triggered_by','')} {r.get('affects','')} {r.get('risks','')}"
149
+ fts_upsert("change", str(id), r.get("files",""), body, "change_log", commit=False)
150
+
151
+ # Auto-resolve followups that match this change
152
+ r["_auto_resolved"] = auto_resolve_followups(r)
153
+ return r
154
+
155
+
156
+ # ── Decisions (episodic memory) ──────────────────────────────────
157
+
158
+ def cleanup_old_decisions(retention_days: int = 90) -> int:
159
+ """Delete decisions entries older than retention_days. Returns count deleted."""
160
+ conn = get_db()
161
+ ids = [str(r[0]) for r in conn.execute(
162
+ "SELECT id FROM decisions WHERE created_at < datetime('now', ?)",
163
+ (f"-{retention_days} days",)
164
+ ).fetchall()]
165
+ cursor = conn.execute(
166
+ "DELETE FROM decisions WHERE created_at < datetime('now', ?)",
167
+ (f"-{retention_days} days",)
168
+ )
169
+ for did in ids:
170
+ conn.execute("DELETE FROM unified_search WHERE source = 'decision' AND source_id = ?", (did,))
171
+ conn.commit()
172
+ return cursor.rowcount
173
+
174
+
175
+ def log_decision(session_id: str, domain: str, decision: str,
176
+ alternatives: str = '', based_on: str = '',
177
+ confidence: str = 'medium', context_ref: str = '',
178
+ status: str = 'pending_review',
179
+ review_due_at: str | None = None) -> dict:
180
+ """Log a decision with reasoning context."""
181
+ conn = get_db()
182
+ cleanup_old_decisions()
183
+ try:
184
+ cursor = conn.execute(
185
+ "INSERT INTO decisions "
186
+ "(session_id, domain, decision, alternatives, based_on, confidence, context_ref, status, review_due_at) "
187
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
188
+ (
189
+ session_id, domain, decision, alternatives, based_on,
190
+ confidence, context_ref, status, review_due_at,
191
+ )
192
+ )
193
+ conn.commit()
194
+ did = cursor.lastrowid
195
+ body = f"{decision} {alternatives} {based_on}"
196
+ fts_upsert("decision", str(did), decision[:200], body, domain or '', commit=False)
197
+ row = conn.execute("SELECT * FROM decisions WHERE id = ?", (did,)).fetchone()
198
+ return dict(row)
199
+ except Exception as e:
200
+ return {"error": str(e)}
201
+
202
+
203
+ def update_decision_outcome(id: int, outcome: str) -> dict:
204
+ """Record the outcome of a past decision."""
205
+ conn = get_db()
206
+ row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
207
+ if not row:
208
+ return {"error": f"Decision {id} not found"}
209
+ conn.execute(
210
+ "UPDATE decisions "
211
+ "SET outcome = ?, outcome_at = datetime('now'), status = 'reviewed', "
212
+ "review_due_at = NULL, last_reviewed_at = datetime('now') "
213
+ "WHERE id = ?",
214
+ (outcome, id)
215
+ )
216
+ conn.commit()
217
+ row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
218
+ r = dict(row)
219
+ body = f"{r.get('decision','')} {r.get('alternatives','')} {r.get('based_on','')} {r.get('outcome','')}"
220
+ fts_upsert("decision", str(id), r.get("decision","")[:200], body, r.get("domain",""), commit=False)
221
+ return r
222
+
223
+
224
+ def get_memory_review_queue(days: int = 7) -> dict:
225
+ """Return learnings and decisions whose review date falls within N days."""
226
+ conn = get_db()
227
+ learning_cutoff = now_epoch() + (days * 86400)
228
+ learnings = conn.execute(
229
+ "SELECT * FROM learnings "
230
+ "WHERE review_due_at IS NOT NULL AND review_due_at <= ? "
231
+ "ORDER BY review_due_at ASC, updated_at DESC",
232
+ (learning_cutoff,)
233
+ ).fetchall()
234
+ decisions = conn.execute(
235
+ "SELECT * FROM decisions "
236
+ "WHERE review_due_at IS NOT NULL AND review_due_at <= datetime('now', ?) "
237
+ "ORDER BY review_due_at ASC, created_at DESC",
238
+ (f"+{days} days",)
239
+ ).fetchall()
240
+ return {
241
+ "learnings": [dict(r) for r in learnings],
242
+ "decisions": [dict(r) for r in decisions],
243
+ }
244
+
245
+
246
+ def find_decisions_by_context_ref(ref: str) -> list[dict]:
247
+ """Find decisions linked to a specific context_ref (e.g., followup ID)."""
248
+ conn = get_db()
249
+ rows = conn.execute(
250
+ "SELECT * FROM decisions WHERE context_ref = ? AND (outcome IS NULL OR outcome = '')",
251
+ (ref,)
252
+ ).fetchall()
253
+ return [dict(r) for r in rows]
254
+
255
+
256
+ def search_decisions(query: str = '', domain: str = '', days: int = 30) -> list[dict]:
257
+ """Search decisions by text and/or domain within a time window."""
258
+ conn = get_db()
259
+ days = max(1, int(days))
260
+ conditions = []
261
+ params = []
262
+ if query:
263
+ frag, qparams = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
264
+ conditions.append(f"({frag})")
265
+ params.extend(qparams)
266
+ if domain:
267
+ conditions.append("domain = ?")
268
+ params.append(domain)
269
+ conditions.append("created_at >= datetime('now', ?)")
270
+ params.append(f"-{days} days")
271
+
272
+ where = " AND ".join(conditions)
273
+ rows = conn.execute(
274
+ f"SELECT * FROM decisions WHERE {where} ORDER BY created_at DESC",
275
+ params
276
+ ).fetchall()
277
+ return [dict(r) for r in rows]
278
+
279
+
280
+ # ── Session Diary ────────────────────────────────────────────────
281
+
282
+ def cleanup_old_diaries(retention_days: int = 180) -> int:
283
+ """Archive then delete session_diary entries older than retention_days.
284
+
285
+ Diaries are moved to diary_archive (permanent) before being removed from
286
+ the active session_diary table. Nothing is ever truly lost.
287
+ """
288
+ conn = get_db()
289
+ cutoff = f"-{retention_days} days"
290
+
291
+ # Archive before deleting — permanent subconscious memory
292
+ try:
293
+ conn.execute("""
294
+ INSERT OR IGNORE INTO diary_archive
295
+ (id, session_id, created_at, decisions, discarded, pending,
296
+ context_next, summary, mental_state, domain, user_signals,
297
+ self_critique, source)
298
+ SELECT id, session_id, created_at, decisions, discarded, pending,
299
+ context_next, summary, mental_state, domain, user_signals,
300
+ self_critique, source
301
+ FROM session_diary
302
+ WHERE created_at < datetime('now', ?)
303
+ """, (cutoff,))
304
+ except Exception:
305
+ pass # Table may not exist yet (pre-migration)
306
+
307
+ ids = [str(r[0]) for r in conn.execute(
308
+ "SELECT id FROM session_diary WHERE created_at < datetime('now', ?)",
309
+ (cutoff,)
310
+ ).fetchall()]
311
+ cursor = conn.execute(
312
+ "DELETE FROM session_diary WHERE created_at < datetime('now', ?)",
313
+ (cutoff,)
314
+ )
315
+ for did in ids:
316
+ conn.execute("DELETE FROM unified_search WHERE source = 'diary' AND source_id = ?", (did,))
317
+ conn.commit()
318
+ return cursor.rowcount
319
+
320
+
321
+ def write_session_diary(session_id: str, decisions: str, summary: str,
322
+ discarded: str = '', pending: str = '',
323
+ context_next: str = '', mental_state: str = '',
324
+ domain: str = '', user_signals: str = '',
325
+ self_critique: str = '', source: str = 'claude') -> dict:
326
+ """Write a session diary entry with mental state and self-critique for continuity."""
327
+ conn = get_db()
328
+ cleanup_old_diaries()
329
+ cursor = conn.execute(
330
+ "INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source) "
331
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
332
+ (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source)
333
+ )
334
+ conn.commit()
335
+ did = cursor.lastrowid
336
+ body = f"{summary} {decisions} {pending} {context_next} {mental_state} {self_critique}"
337
+ fts_upsert("diary", str(did), (summary or '')[:200], body, domain or "general", commit=False)
338
+ row = conn.execute("SELECT * FROM session_diary WHERE id = ?", (did,)).fetchone()
339
+ return dict(row)
340
+
341
+
342
+ # ── Diary Archive (permanent subconscious) ──────────────────────
343
+
344
+
345
+ def diary_archive_search(query: str = '', domain: str = '',
346
+ year: int = 0, month: int = 0,
347
+ limit: int = 20) -> list[dict]:
348
+ """Search the permanent diary archive. Supports text search, domain filter, and date filter.
349
+
350
+ Args:
351
+ query: Text to search in summary, decisions, mental_state, pending
352
+ domain: Filter by domain (e.g. 'project-a', 'project-b')
353
+ year: Filter by year (e.g. 2026)
354
+ month: Filter by month (1-12), requires year
355
+ limit: Max results (default 20)
356
+ """
357
+ conn = get_db()
358
+ try:
359
+ conn.execute("SELECT 1 FROM diary_archive LIMIT 1")
360
+ except Exception:
361
+ return [] # Table doesn't exist yet
362
+
363
+ conditions = []
364
+ params = []
365
+
366
+ if query:
367
+ words = query.strip().split()
368
+ for word in words:
369
+ conditions.append(
370
+ "(summary LIKE ? OR decisions LIKE ? OR mental_state LIKE ? "
371
+ "OR pending LIKE ? OR self_critique LIKE ?)"
372
+ )
373
+ w = f"%{word}%"
374
+ params.extend([w, w, w, w, w])
375
+
376
+ if domain:
377
+ conditions.append("domain = ?")
378
+ params.append(domain)
379
+
380
+ if year:
381
+ if month:
382
+ date_start = f"{year:04d}-{month:02d}-01"
383
+ if month == 12:
384
+ date_end = f"{year + 1:04d}-01-01"
385
+ else:
386
+ date_end = f"{year:04d}-{month + 1:02d}-01"
387
+ conditions.append("created_at >= ? AND created_at < ?")
388
+ params.extend([date_start, date_end])
389
+ else:
390
+ conditions.append("created_at >= ? AND created_at < ?")
391
+ params.extend([f"{year:04d}-01-01", f"{year + 1:04d}-01-01"])
392
+
393
+ where = " AND ".join(conditions) if conditions else "1=1"
394
+
395
+ rows = conn.execute(f"""
396
+ SELECT id, session_id, created_at, summary, decisions, domain,
397
+ mental_state, pending, self_critique, source
398
+ FROM diary_archive
399
+ WHERE {where}
400
+ ORDER BY created_at DESC
401
+ LIMIT ?
402
+ """, params + [limit]).fetchall()
403
+ return [dict(r) for r in rows]
404
+
405
+
406
+ def diary_archive_read(diary_id: int) -> dict | None:
407
+ """Read a single archived diary entry by ID — full content."""
408
+ conn = get_db()
409
+ try:
410
+ row = conn.execute(
411
+ "SELECT * FROM diary_archive WHERE id = ?", (diary_id,)
412
+ ).fetchone()
413
+ return dict(row) if row else None
414
+ except Exception:
415
+ return None
416
+
417
+
418
+ def diary_archive_stats() -> dict:
419
+ """Get archive statistics: count, date range, domains."""
420
+ conn = get_db()
421
+ try:
422
+ count = conn.execute("SELECT COUNT(*) FROM diary_archive").fetchone()[0]
423
+ if count == 0:
424
+ return {"count": 0, "oldest": None, "newest": None, "domains": []}
425
+ oldest = conn.execute("SELECT MIN(created_at) FROM diary_archive").fetchone()[0]
426
+ newest = conn.execute("SELECT MAX(created_at) FROM diary_archive").fetchone()[0]
427
+ domains = [r[0] for r in conn.execute(
428
+ "SELECT DISTINCT domain FROM diary_archive WHERE domain IS NOT NULL AND domain != '' ORDER BY domain"
429
+ ).fetchall()]
430
+ return {"count": count, "oldest": oldest, "newest": newest, "domains": domains}
431
+ except Exception:
432
+ return {"count": 0, "oldest": None, "newest": None, "domains": []}
433
+
434
+
435
+ def check_session_has_diary(session_id: str) -> bool:
436
+ """Return True if this session already has a diary entry."""
437
+ conn = get_db()
438
+ row = conn.execute(
439
+ "SELECT id FROM session_diary WHERE session_id = ? LIMIT 1",
440
+ (session_id,)
441
+ ).fetchone()
442
+ return row is not None
443
+
444
+
445
+ # ── Session Diary Drafts ─────────────────────────────────────────
446
+
447
+
448
+ def upsert_diary_draft(sid: str, tasks_seen: str, change_ids: str,
449
+ decision_ids: str, last_context_hint: str,
450
+ heartbeat_count: int, summary_draft: str = '') -> dict:
451
+ """UPSERT diary draft for a session. Called by heartbeat to accumulate context."""
452
+ conn = get_db()
453
+ conn.execute(
454
+ """INSERT INTO session_diary_draft
455
+ (sid, summary_draft, tasks_seen, change_ids, decision_ids,
456
+ last_context_hint, heartbeat_count, updated_at)
457
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
458
+ ON CONFLICT(sid) DO UPDATE SET
459
+ summary_draft = excluded.summary_draft,
460
+ tasks_seen = excluded.tasks_seen,
461
+ change_ids = excluded.change_ids,
462
+ decision_ids = excluded.decision_ids,
463
+ last_context_hint = excluded.last_context_hint,
464
+ heartbeat_count = excluded.heartbeat_count,
465
+ updated_at = datetime('now')""",
466
+ (sid, summary_draft, tasks_seen, change_ids, decision_ids,
467
+ last_context_hint, heartbeat_count)
468
+ )
469
+ conn.commit()
470
+ return {"sid": sid, "heartbeat_count": heartbeat_count}
471
+
472
+
473
+ def get_diary_draft(sid: str) -> dict | None:
474
+ """Get diary draft for a session, or None."""
475
+ conn = get_db()
476
+ row = conn.execute(
477
+ "SELECT * FROM session_diary_draft WHERE sid = ?", (sid,)
478
+ ).fetchone()
479
+ return dict(row) if row else None
480
+
481
+
482
+ def delete_diary_draft(sid: str):
483
+ """Delete diary draft after real diary is written."""
484
+ conn = get_db()
485
+ conn.execute("DELETE FROM session_diary_draft WHERE sid = ?", (sid,))
486
+ conn.commit()
487
+
488
+
489
+ # ── Session Checkpoint operations ──────────────────────────────────
490
+
491
+ def save_checkpoint(sid: str, task: str = '', task_status: str = 'active',
492
+ active_files: str = '[]', current_goal: str = '',
493
+ decisions_summary: str = '', errors_found: str = '',
494
+ reasoning_thread: str = '', next_step: str = '') -> dict:
495
+ """Save or update a session checkpoint. Called by PreCompact hook."""
496
+ conn = get_db()
497
+ # Get current compaction count
498
+ existing = conn.execute(
499
+ "SELECT compaction_count FROM session_checkpoints WHERE sid = ?", (sid,)
500
+ ).fetchone()
501
+ count = (existing["compaction_count"] + 1) if existing else 0
502
+
503
+ conn.execute(
504
+ """INSERT INTO session_checkpoints
505
+ (sid, task, task_status, active_files, current_goal,
506
+ decisions_summary, errors_found, reasoning_thread, next_step,
507
+ compaction_count, updated_at)
508
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
509
+ ON CONFLICT(sid) DO UPDATE SET
510
+ task = excluded.task,
511
+ task_status = excluded.task_status,
512
+ active_files = excluded.active_files,
513
+ current_goal = excluded.current_goal,
514
+ decisions_summary = excluded.decisions_summary,
515
+ errors_found = excluded.errors_found,
516
+ reasoning_thread = excluded.reasoning_thread,
517
+ next_step = excluded.next_step,
518
+ compaction_count = excluded.compaction_count,
519
+ updated_at = datetime('now')""",
520
+ (sid, task, task_status, active_files, current_goal,
521
+ decisions_summary, errors_found, reasoning_thread, next_step, count)
522
+ )
523
+ conn.commit()
524
+ return {"sid": sid, "compaction_count": count}
525
+
526
+
527
+ def read_checkpoint(sid: str = '') -> dict | None:
528
+ """Read the most recent session checkpoint. If no sid, returns the latest."""
529
+ conn = get_db()
530
+ if sid:
531
+ row = conn.execute(
532
+ "SELECT * FROM session_checkpoints WHERE sid = ?", (sid,)
533
+ ).fetchone()
534
+ else:
535
+ row = conn.execute(
536
+ "SELECT * FROM session_checkpoints ORDER BY updated_at DESC LIMIT 1"
537
+ ).fetchone()
538
+ return dict(row) if row else None
539
+
540
+
541
+ def increment_compaction_count(sid: str) -> int:
542
+ """Increment and return the compaction count for a session."""
543
+ conn = get_db()
544
+ conn.execute(
545
+ """UPDATE session_checkpoints
546
+ SET compaction_count = compaction_count + 1, updated_at = datetime('now')
547
+ WHERE sid = ?""",
548
+ (sid,)
549
+ )
550
+ conn.commit()
551
+ row = conn.execute(
552
+ "SELECT compaction_count FROM session_checkpoints WHERE sid = ?", (sid,)
553
+ ).fetchone()
554
+ return row["compaction_count"] if row else 0
555
+
556
+
557
+ def get_orphan_sessions(ttl_seconds: int = 900) -> list[dict]:
558
+ """Get sessions that exceeded TTL and have no diary."""
559
+ conn = get_db()
560
+ cutoff = now_epoch() - ttl_seconds
561
+ rows = conn.execute(
562
+ """SELECT s.sid, s.task, s.started_epoch, s.last_update_epoch
563
+ FROM sessions s
564
+ LEFT JOIN session_diary sd ON sd.session_id = s.sid
565
+ WHERE s.last_update_epoch <= ? AND sd.id IS NULL""",
566
+ (cutoff,)
567
+ ).fetchall()
568
+ return [dict(r) for r in rows]
569
+
570
+
571
+ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = False,
572
+ domain: str = '', include_automated: bool = False) -> list[dict]:
573
+ """Read session diary entries.
574
+
575
+ - session_id: returns entries for that specific session
576
+ - last_day: returns the recent continuity window (~36h), including the previous evening
577
+ - last_n: returns last N entries (default)
578
+ - domain: filter by project context (nexo, other)
579
+ - include_automated: if False (default), excludes automated sessions (auto-close,
580
+ cron diaries, etc.). Only returns human-interactive sessions.
581
+ Email sessions (user sends email, NEXO responds) ARE included — they're real interactions.
582
+ """
583
+ conn = get_db()
584
+ domain_clause = " AND domain = ?" if domain else ""
585
+ domain_params = (domain,) if domain else ()
586
+ # By default, filter out automated sessions so startup shows human sessions only.
587
+ # Keeps: interactive sessions + auto-closed sessions that had real user interaction.
588
+ # An auto-close is human if it has heartbeats > 0 (heartbeat only fires on user messages).
589
+ # Excludes: cron jobs, auto-closed crons (0 heartbeats or "Minimal diary").
590
+ if include_automated:
591
+ source_clause = ""
592
+ else:
593
+ source_clause = (
594
+ " AND ("
595
+ " (source = 'claude' AND summary NOT LIKE '[AUTO-%')"
596
+ " OR (source = 'auto-close'"
597
+ " AND mental_state NOT LIKE '%0 heartbeats%'"
598
+ " AND mental_state NOT LIKE '%Minimal diary%')"
599
+ ")"
600
+ )
601
+
602
+ if session_id:
603
+ rows = conn.execute(
604
+ f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY created_at DESC",
605
+ (session_id,) + domain_params
606
+ ).fetchall()
607
+ elif last_day:
608
+ rows = conn.execute(
609
+ f"SELECT * FROM session_diary "
610
+ f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
611
+ f"ORDER BY created_at DESC",
612
+ domain_params
613
+ ).fetchall()
614
+ else:
615
+ rows = conn.execute(
616
+ f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY created_at DESC LIMIT ?",
617
+ domain_params + (last_n,)
618
+ ).fetchall()
619
+ return [dict(r) for r in rows]
620
+
621
+
622
+ def _multi_word_like(query: str, columns: list[str]) -> tuple[str, list]:
623
+ """Build AND-ed LIKE conditions: every word must appear in at least one of the columns.
624
+
625
+ Returns (sql_fragment, params) ready for WHERE clause.
626
+ Example: query="cron learn", columns=["title","content"]
627
+ → "(title LIKE ? OR content LIKE ?) AND (title LIKE ? OR content LIKE ?)"
628
+ with params ["%cron%","%cron%","%learn%","%learn%"]
629
+ """
630
+ words = query.strip().split()
631
+ if not words:
632
+ return "1=1", []
633
+ word_conditions = []
634
+ params = []
635
+ for word in words:
636
+ pattern = f"%{word}%"
637
+ col_or = " OR ".join(f"{c} LIKE ?" for c in columns)
638
+ word_conditions.append(f"({col_or})")
639
+ params.extend([pattern] * len(columns))
640
+ return " AND ".join(word_conditions), params
641
+
642
+
643
+ def recall(query: str, days: int = 30) -> list[dict]:
644
+ """Cross-search ALL memory using FTS5: learnings, decisions, changes, diary, followups, entities, .md files.
645
+
646
+ Returns up to 20 results ranked by relevance (FTS5 bm25).
647
+ Falls back to LIKE-based search if FTS fails.
648
+ """
649
+ # Try FTS5 first (fast, ranked), then filter by days
650
+ results = fts_search(query, limit=40) # fetch extra to allow filtering
651
+ if results:
652
+ cutoff_epoch = now_epoch() - (days * 86400)
653
+ filtered = []
654
+ for r in results:
655
+ ua = str(r.get('updated_at', ''))
656
+ if not ua:
657
+ filtered.append(r)
658
+ continue
659
+ # Normalize to epoch for comparison
660
+ try:
661
+ if ua[0].isdigit() and ('.' in ua or len(ua) > 12):
662
+ # Could be epoch float or ISO date
663
+ if '-' in ua[:5]:
664
+ # ISO datetime like "2026-03-13 16:17:40"
665
+ dt = datetime.datetime.fromisoformat(ua.replace(' ', 'T'))
666
+ ts = dt.timestamp()
667
+ else:
668
+ ts = float(ua)
669
+ else:
670
+ ts = float(ua)
671
+ if ts >= cutoff_epoch:
672
+ filtered.append(r)
673
+ except (ValueError, TypeError):
674
+ filtered.append(r) # keep if can't parse
675
+ if filtered:
676
+ return filtered[:20]
677
+
678
+ # Fallback to old LIKE-based search
679
+ days = max(1, int(days))
680
+ conn = get_db()
681
+ cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=days)
682
+ cutoff_str = cutoff_dt.strftime("%Y-%m-%d")
683
+ cutoff_epoch = now_epoch() - (days * 86400)
684
+
685
+ results = []
686
+
687
+ frag, params = _multi_word_like(query, ["files", "what_changed", "why", "triggered_by", "affects", "risks"])
688
+ rows = conn.execute(f"""
689
+ SELECT id, created_at, 'change' AS source,
690
+ files AS title,
691
+ (what_changed || ' | ' || why) AS snippet, 'change_log' AS category, 0 AS rank
692
+ FROM change_log
693
+ WHERE created_at >= ? AND ({frag})
694
+ ORDER BY created_at DESC LIMIT 20
695
+ """, [cutoff_str] + params).fetchall()
696
+ results.extend([dict(r) for r in rows])
697
+
698
+ frag, params = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
699
+ rows = conn.execute(f"""
700
+ SELECT id, created_at, 'decision' AS source,
701
+ decision AS title,
702
+ (COALESCE(based_on,'') || ' | ' || COALESCE(alternatives,'')) AS snippet, domain AS category, 0 AS rank
703
+ FROM decisions
704
+ WHERE created_at >= ? AND ({frag})
705
+ ORDER BY created_at DESC LIMIT 20
706
+ """, [cutoff_str] + params).fetchall()
707
+ results.extend([dict(r) for r in rows])
708
+
709
+ frag, params = _multi_word_like(query, ["title", "content", "reasoning"])
710
+ rows = conn.execute(f"""
711
+ SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'learning' AS source,
712
+ title,
713
+ (COALESCE(content,'') || ' | ' || COALESCE(reasoning,'')) AS snippet, category, 0 AS rank
714
+ FROM learnings
715
+ WHERE created_at >= ? AND ({frag})
716
+ ORDER BY created_at DESC LIMIT 20
717
+ """, [cutoff_epoch] + params).fetchall()
718
+ results.extend([dict(r) for r in rows])
719
+
720
+ frag, params = _multi_word_like(query, ["id", "description", "verification", "reasoning"])
721
+ rows = conn.execute(f"""
722
+ SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'followup' AS source,
723
+ id AS title,
724
+ (COALESCE(description,'') || ' | ' || COALESCE(verification,'') || ' | ' || COALESCE(reasoning,'')) AS snippet,
725
+ 'followup' AS category, 0 AS rank
726
+ FROM followups
727
+ WHERE created_at >= ? AND ({frag})
728
+ ORDER BY created_at DESC LIMIT 20
729
+ """, [cutoff_epoch] + params).fetchall()
730
+ results.extend([dict(r) for r in rows])
731
+
732
+ frag, params = _multi_word_like(query, ["decisions", "discarded", "pending", "context_next", "mental_state", "summary"])
733
+ rows = conn.execute(f"""
734
+ SELECT id, created_at, 'diary' AS source,
735
+ summary AS title,
736
+ (COALESCE(decisions,'') || ' | ' || COALESCE(pending,'') || ' | ' || COALESCE(context_next,'')) AS snippet,
737
+ COALESCE(domain, 'general') AS category, 0 AS rank
738
+ FROM session_diary
739
+ WHERE created_at >= ? AND ({frag})
740
+ ORDER BY created_at DESC LIMIT 20
741
+ """, [cutoff_str] + params).fetchall()
742
+ results.extend([dict(r) for r in rows])
743
+
744
+ # Skills
745
+ try:
746
+ frag, params = _multi_word_like(query, ["name", "description", "tags", "trigger_patterns"])
747
+ rows = conn.execute(f"""
748
+ SELECT id, created_at, 'skill' AS source,
749
+ name AS title,
750
+ (COALESCE(description,'') || ' | ' || COALESCE(tags,'') || ' | ' || COALESCE(trigger_patterns,'')) AS snippet,
751
+ level AS category, 0 AS rank
752
+ FROM skills
753
+ WHERE created_at >= ? AND ({frag})
754
+ ORDER BY trust_score DESC LIMIT 10
755
+ """, [cutoff_str] + params).fetchall()
756
+ results.extend([dict(r) for r in rows])
757
+ except Exception:
758
+ pass # Table may not exist yet during migration
759
+
760
+ results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
761
+ return results[:20]
762
+