nexo-brain 2.3.0 → 2.3.2

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