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,949 +0,0 @@
1
- """NEXO Cognitive — Search, retrieval, ranking."""
2
- import math
3
- import sqlite3
4
- import numpy as np
5
- from datetime import datetime
6
- from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob, _get_model, _get_reranker, rerank_results, EMBEDDING_DIM
7
-
8
- def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
9
- source_type_filter: str = "") -> list[dict]:
10
- """BM25 keyword search using SQLite FTS5. Returns ranked results by relevance."""
11
- db = _get_db()
12
- results = []
13
-
14
- # Sanitize query for FTS5 (escape special chars, use OR for multi-word)
15
- words = [w.strip() for w in query_text.split() if w.strip() and len(w.strip()) > 1]
16
- if not words:
17
- return []
18
- fts_query = " OR ".join(f'"{w}"' for w in words)
19
-
20
- for store in ("stm", "ltm"):
21
- if stores == "stm" and store == "ltm":
22
- continue
23
- if stores == "ltm" and store == "stm":
24
- continue
25
-
26
- table = f"{store}_memories"
27
- fts_table = f"{store}_fts"
28
-
29
- try:
30
- sql = f"""
31
- SELECT m.id, m.content, m.source_type, m.source_id, m.source_title,
32
- m.domain, m.created_at, m.strength, m.access_count
33
- FROM {fts_table}
34
- JOIN {table} m ON m.id = {fts_table}.rowid
35
- WHERE {fts_table} MATCH ?
36
- """
37
- params = [fts_query]
38
-
39
- if source_type_filter:
40
- sql += " AND m.source_type = ?"
41
- params.append(source_type_filter)
42
-
43
- if store == "stm":
44
- sql += " AND m.promoted_to_ltm = 0"
45
- else:
46
- sql += " AND m.is_dormant = 0"
47
-
48
- sql += f" ORDER BY {fts_table}.rank LIMIT ?"
49
- params.append(top_k)
50
-
51
- rows = db.execute(sql, params).fetchall()
52
-
53
- for rank_pos, row in enumerate(rows):
54
- results.append({
55
- "store": store,
56
- "id": row["id"],
57
- "content": row["content"],
58
- "source_type": row["source_type"],
59
- "source_id": row["source_id"],
60
- "source_title": row["source_title"],
61
- "domain": row["domain"],
62
- "created_at": row["created_at"],
63
- "strength": row["strength"],
64
- "access_count": row["access_count"],
65
- "bm25_rank": rank_pos + 1,
66
- "lifecycle_state": "active",
67
- })
68
- except Exception:
69
- # FTS5 table might not exist yet or query syntax error
70
- pass
71
-
72
- return results
73
-
74
-
75
- def _rrf_fuse(vector_results: list[dict], bm25_results: list[dict],
76
- k: int = 60, alpha: float = 0.7) -> list[dict]:
77
- """Reciprocal Rank Fusion: merge vector and BM25 results.
78
-
79
- Unlike the old version that only boosted vector-found results, this now
80
- ALSO ADDS BM25-only results. This is critical for vocabulary mismatches
81
- where semantic search misses but keyword search finds the right memory
82
- (e.g., user says 'backend', memory contains 'FastAPI dashboard localhost:6174').
83
-
84
- RRF score = alpha * 1/(k + vec_rank) + (1-alpha) * 1/(k + bm25_rank)
85
- Items found by only one source get a penalty rank for the missing source.
86
- """
87
- # Build lookups by (store, id)
88
- vec_lookup = {}
89
- for rank, r in enumerate(vector_results):
90
- key = (r["store"], r["id"])
91
- vec_lookup[key] = (rank + 1, r)
92
-
93
- bm25_lookup = {}
94
- for rank, r in enumerate(bm25_results):
95
- key = (r["store"], r["id"])
96
- if key not in bm25_lookup: # keep best rank
97
- bm25_lookup[key] = (rank + 1, r)
98
-
99
- # Merge all unique keys
100
- all_keys = set(vec_lookup.keys()) | set(bm25_lookup.keys())
101
- miss_rank = max(len(vector_results), len(bm25_results)) + 10 # penalty rank for missing source
102
-
103
- fused = []
104
- for key in all_keys:
105
- vec_rank, vec_result = vec_lookup.get(key, (miss_rank, None))
106
- bm25_rank, bm25_result = bm25_lookup.get(key, (miss_rank, None))
107
-
108
- # Use whichever result has the data
109
- base = vec_result if vec_result else bm25_result
110
- result = base.copy()
111
-
112
- rrf_score = alpha * (1.0 / (k + vec_rank)) + (1 - alpha) * (1.0 / (k + bm25_rank))
113
-
114
- # If we have the original cosine score, blend it in to preserve semantic confidence
115
- if vec_result and "score" in vec_result:
116
- # Weighted blend: cosine for confidence + RRF for ranking boost
117
- rrf_normalized = min(1.0, rrf_score * k) # normalize to 0-1 range
118
- result["score"] = 0.7 * vec_result["score"] + 0.3 * rrf_normalized
119
- else:
120
- # BM25-only result: use RRF score scaled to ~0.3-0.7 range
121
- result["score"] = min(0.75, rrf_score * k)
122
-
123
- result["bm25_boosted"] = key in bm25_lookup
124
- result["bm25_only"] = key not in vec_lookup
125
- result["rrf_score"] = rrf_score
126
- fused.append(result)
127
-
128
- # Sort by score descending
129
- fused.sort(key=lambda x: x["score"], reverse=True)
130
- return fused
131
-
132
-
133
- # ── Temporal Boosting ────────────────────────────────────────────────
134
- # Recent memories get a bounded additive boost at query time.
135
- # Design from multi-AI debate (GPT-5.4 + Gemini 3.1 Pro + Claude Opus 4.6):
136
- # - Additive, not multiplicative (preserves old strong matches)
137
- # - Relevance-gated (only boost if already above threshold)
138
- # - Query-adaptive alpha (operational queries get more boost)
139
-
140
- # Operational keywords that suggest the user wants recent/active things
141
- _OPERATIONAL_CUES = frozenset({
142
- "current", "latest", "now", "running", "active", "today", "yesterday",
143
- "tonight", "backend", "server", "dashboard", "service", "localhost",
144
- "anoche", "ayer", "ahora", "actual", "corriendo", "activo", "hoy",
145
- "madrugada", "esta mañana", "last night", "this morning",
146
- })
147
-
148
- # Historical keywords that suggest the user wants old things
149
- _HISTORICAL_CUES = frozenset({
150
- "ago", "month", "months", "year", "years", "previous", "earlier",
151
- "cuando", "hace", "meses", "año", "anterior", "antes",
152
- })
153
-
154
-
155
- def _apply_temporal_boost(results: list[dict], query_text: str) -> list[dict]:
156
- """Apply bounded temporal boost to retrieval results.
157
-
158
- Recent memories (hours/days) get a small additive bonus, but only if they
159
- already have a reasonable relevance score (gated at 0.45). This prevents
160
- recent junk from outranking strong old matches.
161
-
162
- The boost decays with a 3-day half-life:
163
- boost = alpha * exp(-ln(2) * age_days / 3)
164
-
165
- Alpha is query-adaptive:
166
- - Operational queries ('backend', 'active', 'today'): alpha = 0.06
167
- - Default queries: alpha = 0.02
168
- - Historical queries ('ago', 'months', 'year'): alpha = 0.0 (disabled)
169
- """
170
- if not results:
171
- return results
172
-
173
- # Determine alpha based on query intent
174
- query_tokens = set(query_text.lower().split())
175
- if query_tokens & _HISTORICAL_CUES:
176
- return results # No temporal boost for historical queries
177
- elif query_tokens & _OPERATIONAL_CUES:
178
- alpha = 0.06
179
- else:
180
- alpha = 0.02
181
-
182
- now = datetime.now()
183
- ln2 = math.log(2)
184
- half_life_days = 3.0
185
-
186
- for r in results:
187
- # Only boost if already reasonably relevant (relevance gate)
188
- if r.get("score", 0) < 0.45:
189
- continue
190
-
191
- # Calculate age in days
192
- created_str = r.get("created_at", "")
193
- if not created_str:
194
- continue
195
- try:
196
- created = datetime.fromisoformat(created_str.replace("Z", "+00:00").replace("+00:00", ""))
197
- age_days = max(0, (now - created).total_seconds() / 86400)
198
- except (ValueError, TypeError):
199
- continue
200
-
201
- # Bounded exponential decay boost
202
- boost = alpha * math.exp(-ln2 * age_days / half_life_days)
203
-
204
- # Apply boost (capped at 0.95 — reserve 1.0 for exact matches only)
205
- r["score"] = min(0.95, r["score"] + boost)
206
- if boost > 0.001:
207
- r["temporal_boost"] = round(boost, 4)
208
-
209
- return results
210
-
211
-
212
- # ============================================================================
213
- # FEATURE 0.5: Knowledge Graph Boost
214
- # Memories connected to more KG nodes (files, areas, other learnings) are
215
- # more structurally important. Apply a small additive boost proportional to
216
- # their connection count. This bridges the vector (semantic) and graph
217
- # (structural) worlds.
218
- # ============================================================================
219
-
220
- def _kg_boost_results(results: list[dict], max_boost: float = 0.08) -> list[dict]:
221
- """Boost search results based on Knowledge Graph connectivity.
222
-
223
- For each result whose source (learning, change, decision, entity) has a
224
- corresponding KG node, add a logarithmic boost based on connection count.
225
- More connected memories = more structurally important = slight score lift.
226
-
227
- Boost formula: min(max_boost, 0.015 * log2(connections + 1))
228
- - 1 connection → +0.015
229
- - 4 connections → +0.034
230
- - 16 connections → +0.060
231
- - 32+ connections → capped at +0.08
232
- """
233
- if not results:
234
- return results
235
-
236
- try:
237
- db = _get_db()
238
- except Exception:
239
- return results
240
-
241
- # Collect KG node refs from results
242
- # KG node_refs use format "learning:212", "change:39", "decision:14"
243
- # Memory source_ids use format "L464", "C39", "D14" or raw IDs
244
- _prefix_map = {"learning": "L", "change": "C", "decision": "D", "entity": "E"}
245
- ref_map = {} # node_ref -> list of result indices
246
- for i, r in enumerate(results):
247
- source_type = r.get("source_type", "")
248
- source_id = r.get("source_id", "")
249
- if not source_type or not source_id:
250
- continue
251
- # Convert memory source_id to KG node_ref
252
- prefix = _prefix_map.get(source_type, "")
253
- if prefix and source_id.startswith(prefix):
254
- numeric_id = source_id[len(prefix):]
255
- node_ref = f"{source_type}:{numeric_id}"
256
- else:
257
- node_ref = f"{source_type}:{source_id}"
258
- ref_map.setdefault(node_ref, []).append(i)
259
-
260
- if not ref_map:
261
- return results
262
-
263
- # Batch query: get connection counts for all relevant KG nodes
264
- try:
265
- placeholders = ",".join(["?"] * len(ref_map))
266
- rows = db.execute(f"""
267
- SELECT n.node_ref, COUNT(e.id) as connections
268
- FROM kg_nodes n
269
- LEFT JOIN kg_edges e ON (e.source_id = n.id OR e.target_id = n.id)
270
- AND e.valid_until IS NULL
271
- WHERE n.node_ref IN ({placeholders})
272
- GROUP BY n.id
273
- """, list(ref_map.keys())).fetchall()
274
- except Exception:
275
- return results
276
-
277
- # Apply boosts
278
- for row in rows:
279
- node_ref = row["node_ref"]
280
- connections = row["connections"]
281
- if connections <= 0:
282
- continue
283
- boost = min(max_boost, 0.015 * math.log2(connections + 1))
284
- for idx in ref_map.get(node_ref, []):
285
- r = results[idx]
286
- if r.get("score", 0) >= 0.45: # Same relevance gate as temporal
287
- r["score"] = min(0.95, r["score"] + boost)
288
- r["kg_boost"] = round(boost, 4)
289
- r["kg_connections"] = connections
290
-
291
- return results
292
-
293
-
294
- # ============================================================================
295
- # FEATURE 1: HyDE Query Expansion (adapted from Vestige hyde.rs)
296
- # Template-based Hypothetical Document Embeddings for improved search recall.
297
- # Classifies query intent, generates 3-5 semantic variants, embeds all,
298
- # averages into centroid embedding for broader semantic coverage.
299
- # ============================================================================
300
-
301
- def _classify_query_intent(query: str) -> str:
302
- """Classify query intent into one of 6 categories (Vestige-style)."""
303
- lower = query.lower().strip()
304
- if lower.startswith(("how to", "how do", "steps", "cómo")):
305
- return "howto"
306
- if lower.startswith(("what is", "what are", "define", "explain", "qué es")):
307
- return "definition"
308
- if lower.startswith(("why", "por qué")) or "reason" in lower or "porque" in lower:
309
- return "reasoning"
310
- if lower.startswith(("when", "cuándo")) or "date" in lower or "timeline" in lower or "fecha" in lower:
311
- return "temporal"
312
- if any(c in query for c in ("(", "{", "::", "def ", "class ", "fn ", "function ")):
313
- return "technical"
314
- return "lookup"
315
-
316
-
317
- def _expand_query_variants(query: str) -> list[str]:
318
- """Generate 3-5 expanded query variants based on intent (Vestige-style)."""
319
- intent = _classify_query_intent(query)
320
- clean = query.strip().rstrip("?.!")
321
- variants = [query]
322
-
323
- templates = {
324
- "definition": [
325
- f"{clean} is a concept that involves",
326
- f"The definition of {clean} in the context of this project",
327
- f"{clean} refers to a type of",
328
- ],
329
- "howto": [
330
- f"The steps to {clean} are as follows",
331
- f"To accomplish {clean}, you need to",
332
- f"A guide for {clean} including",
333
- ],
334
- "reasoning": [
335
- f"The reason {clean} is because",
336
- f"{clean} happens due to the following factors",
337
- f"The explanation for {clean} involves",
338
- ],
339
- "temporal": [
340
- f"{clean} occurred at a specific time",
341
- f"The timeline of {clean} shows",
342
- f"Events related to {clean} in chronological order",
343
- ],
344
- "lookup": [
345
- f"Information about {clean} including details",
346
- f"{clean} is related to the following topics",
347
- f"Key facts about {clean}",
348
- f"Previously we handled {clean} by",
349
- ],
350
- "technical": [
351
- f"{clean} implementation details and code",
352
- f"Code pattern for {clean}",
353
- ],
354
- }
355
-
356
- variants.extend(templates.get(intent, templates["lookup"]))
357
- return variants
358
-
359
-
360
- def hyde_expand_query(query: str) -> np.ndarray:
361
- """HyDE: embed expanded query variants and return their centroid.
362
-
363
- Instead of embedding just the raw query, generates 3-5 semantic
364
- variants and returns the averaged (centroid) embedding. This gives
365
- ~60% of full LLM-based HyDE quality with zero latency overhead.
366
-
367
- Based on Vestige's template-based HyDE (hyde.rs) and the original
368
- HyDE paper (Gao et al., 2022).
369
- """
370
- variants = _expand_query_variants(query)
371
- model = _get_model()
372
- embeddings = list(model.embed(variants))
373
- arrays = [np.array(e, dtype=np.float32) for e in embeddings]
374
-
375
- centroid = np.mean(arrays, axis=0).astype(np.float32)
376
- norm = np.linalg.norm(centroid)
377
- if norm > 0:
378
- centroid = centroid / norm
379
-
380
- return centroid
381
-
382
-
383
- # ============================================================================
384
- # FEATURE 2: Spreading Activation / Co-Activation Reinforcement
385
- # Adapted from Vestige spreading_activation.rs and ClawMem store.ts
386
- # Memories retrieved together get co-activation links that boost
387
- # future retrievals of associated memories.
388
- # ============================================================================
389
-
390
- CO_ACTIVATION_DECAY = 0.7
391
- CO_ACTIVATION_BOOST = 0.05
392
- CO_ACTIVATION_MIN_STRENGTH = 0.1
393
-
394
-
395
- def _canonical_co_id(store: str, mid: int) -> int:
396
- """Create a canonical hash ID for co-activation tracking."""
397
- return hash(f"{store}:{mid}") % (2**31)
398
-
399
-
400
- def record_co_activation(memory_ids: list[tuple[str, int]]):
401
- """Record co-activation between all pairs of retrieved memories.
402
-
403
- Called after search returns results. Memories surfaced together
404
- get their co-activation links reinforced (ClawMem pattern).
405
- """
406
- if len(memory_ids) < 2:
407
- return
408
-
409
- db = _get_db()
410
- now = datetime.utcnow().isoformat()
411
-
412
- hashes = [_canonical_co_id(store, mid) for store, mid in memory_ids]
413
-
414
- for i in range(len(hashes)):
415
- for j in range(i + 1, len(hashes)):
416
- a, b = min(hashes[i], hashes[j]), max(hashes[i], hashes[j])
417
- db.execute("""
418
- INSERT INTO co_activation (memory_a_id, memory_b_id, strength, co_access_count, last_co_access)
419
- VALUES (?, ?, 1.0, 1, ?)
420
- ON CONFLICT(memory_a_id, memory_b_id) DO UPDATE SET
421
- strength = MIN(5.0, strength + 0.3),
422
- co_access_count = co_access_count + 1,
423
- last_co_access = excluded.last_co_access
424
- """, (a, b, now))
425
-
426
- db.commit()
427
-
428
-
429
- def _get_co_activated_neighbors(memory_ids: list[tuple[str, int]], depth: int = 1) -> dict[int, float]:
430
- """Get co-activated neighbor boosts for a set of memory IDs.
431
-
432
- Returns {canonical_hash: boost_score} for neighbor memories.
433
- Uses BFS spreading with decay per hop (Vestige pattern).
434
- """
435
- db = _get_db()
436
- boosts = {}
437
-
438
- source_hashes = set(_canonical_co_id(s, m) for s, m in memory_ids)
439
- current_level = list(source_hashes)
440
-
441
- for hop in range(depth):
442
- decay = CO_ACTIVATION_DECAY ** (hop + 1)
443
- next_level = []
444
-
445
- for src_hash in current_level:
446
- rows = db.execute("""
447
- SELECT memory_a_id, memory_b_id, strength FROM co_activation
448
- WHERE (memory_a_id = ? OR memory_b_id = ?) AND strength >= ?
449
- """, (src_hash, src_hash, CO_ACTIVATION_MIN_STRENGTH)).fetchall()
450
-
451
- for row in rows:
452
- neighbor_id = row["memory_b_id"] if row["memory_a_id"] == src_hash else row["memory_a_id"]
453
- if neighbor_id in source_hashes:
454
- continue
455
-
456
- boost = row["strength"] * decay * CO_ACTIVATION_BOOST
457
- if neighbor_id not in boosts or boosts[neighbor_id] < boost:
458
- boosts[neighbor_id] = boost
459
- next_level.append(neighbor_id)
460
-
461
- current_level = next_level
462
-
463
- return boosts
464
-
465
-
466
- # ============================================================================
467
- # FEATURE 3: Prospective Memory (adapted from Vestige prospective_memory.rs)
468
- # "Remember to do X when Y happens" — intention-based triggers that fire
469
- # when incoming text matches a pattern (keyword or semantic).
470
- # ============================================================================
471
-
472
- def create_trigger(pattern: str, action: str, context: str = "") -> int:
473
- """Create a prospective memory trigger.
474
-
475
- Args:
476
- pattern: Keywords or phrase to match (case-insensitive, comma-separated for multiple)
477
- action: What to do when the trigger fires
478
- context: Optional context about why this trigger was created
479
- Returns:
480
- Trigger ID
481
- """
482
- db = _get_db()
483
- cur = db.execute(
484
- "INSERT INTO prospective_triggers (trigger_pattern, action, context) VALUES (?, ?, ?)",
485
- (pattern, action, context)
486
- )
487
- db.commit()
488
- return cur.lastrowid
489
-
490
-
491
- def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
492
- """Check text against all armed triggers. Fires matches.
493
-
494
- Uses keyword matching by default. If use_semantic=True, also checks
495
- semantic similarity (Vestige TriggerPattern.matches pattern).
496
-
497
- Args:
498
- text: Input text to check
499
- use_semantic: Also do embedding similarity matching
500
- semantic_threshold: Min cosine similarity for semantic match
501
- Returns:
502
- List of fired triggers with actions
503
- """
504
- if not text or not text.strip():
505
- return []
506
-
507
- db = _get_db()
508
- armed = db.execute(
509
- "SELECT * FROM prospective_triggers WHERE status = 'armed'"
510
- ).fetchall()
511
-
512
- if not armed:
513
- return []
514
-
515
- text_lower = text.lower()
516
- text_vec = None
517
- if use_semantic:
518
- text_vec = embed(text)
519
-
520
- fired = []
521
- now = datetime.utcnow().isoformat()
522
-
523
- for trigger in armed:
524
- pattern = trigger["trigger_pattern"].lower()
525
- matched = False
526
- match_type = ""
527
-
528
- # Keyword match (comma-separated OR)
529
- keywords = [kw.strip() for kw in pattern.split(",") if kw.strip()]
530
- if any(kw in text_lower for kw in keywords):
531
- matched = True
532
- match_type = "keyword"
533
-
534
- # Semantic match (optional, more expensive)
535
- if not matched and use_semantic and text_vec is not None:
536
- pattern_vec = embed(trigger["trigger_pattern"])
537
- sim = cosine_similarity(text_vec, pattern_vec)
538
- if sim >= semantic_threshold:
539
- matched = True
540
- match_type = f"semantic({sim:.3f})"
541
-
542
- if matched:
543
- db.execute(
544
- "UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
545
- (now, trigger["id"])
546
- )
547
- fired.append({
548
- "id": trigger["id"],
549
- "pattern": trigger["trigger_pattern"],
550
- "action": trigger["action"],
551
- "context": trigger["context"],
552
- "match_type": match_type,
553
- "created_at": trigger["created_at"],
554
- })
555
-
556
- if fired:
557
- db.commit()
558
-
559
- return fired
560
-
561
-
562
- def list_triggers(status: str = "armed") -> list[dict]:
563
- """List prospective triggers filtered by status."""
564
- db = _get_db()
565
- if status == "all":
566
- rows = db.execute("SELECT * FROM prospective_triggers ORDER BY created_at DESC").fetchall()
567
- else:
568
- rows = db.execute(
569
- "SELECT * FROM prospective_triggers WHERE status = ? ORDER BY created_at DESC",
570
- (status,)
571
- ).fetchall()
572
- return [dict(row) for row in rows]
573
-
574
-
575
- def delete_trigger(trigger_id: int) -> str:
576
- """Delete a prospective trigger by ID."""
577
- db = _get_db()
578
- cur = db.execute("DELETE FROM prospective_triggers WHERE id = ?", (trigger_id,))
579
- db.commit()
580
- return f"Trigger #{trigger_id} {'deleted' if cur.rowcount else 'not found'}."
581
-
582
-
583
- def rearm_trigger(trigger_id: int) -> str:
584
- """Re-arm a fired trigger so it can fire again."""
585
- db = _get_db()
586
- cur = db.execute(
587
- "UPDATE prospective_triggers SET status = 'armed', fired_at = NULL WHERE id = ?",
588
- (trigger_id,)
589
- )
590
- db.commit()
591
- return f"Trigger #{trigger_id} {'re-armed' if cur.rowcount else 'not found'}."
592
-
593
-
594
- def _auto_restore_snoozed(db: sqlite3.Connection):
595
- """Restore snoozed memories whose snooze_until date has passed."""
596
- now = datetime.utcnow().isoformat()
597
- for table in ("stm_memories", "ltm_memories"):
598
- db.execute(
599
- f"UPDATE {table} SET lifecycle_state = 'active', snooze_until = NULL "
600
- f"WHERE lifecycle_state = 'snoozed' AND snooze_until IS NOT NULL AND snooze_until <= ?",
601
- (now,)
602
- )
603
- db.commit()
604
-
605
-
606
- def _rehearse_results(results: list[dict], skip_ids: set = None):
607
- """Update strength and access_count for retrieved results (rehearsal)."""
608
- if not results:
609
- return
610
- db = _get_db()
611
- now = datetime.utcnow().isoformat()
612
- skip = skip_ids or set()
613
- for r in results:
614
- if (r["store"], r["id"]) in skip:
615
- continue
616
- table = "stm_memories" if r["store"] == "stm" else "ltm_memories"
617
- db.execute(
618
- f"UPDATE {table} SET strength = MIN(1.0, strength + 0.08), access_count = access_count + 1, last_accessed = ? WHERE id = ?",
619
- (now, r["id"])
620
- )
621
- db.commit()
622
-
623
-
624
- def search(
625
- query_text: str,
626
- top_k: int = 10,
627
- min_score: float = 0.5,
628
- stores: str = "both",
629
- exclude_dormant: bool = True,
630
- rehearse: bool = True,
631
- source_type_filter: str = "",
632
- include_archived: bool = False,
633
- use_hyde: bool = False,
634
- hybrid: bool = True,
635
- hybrid_alpha: float = 0.6,
636
- spreading_depth: int = 0,
637
- decompose: bool = True,
638
- exclude_dreams: bool = True,
639
- ) -> list[dict]:
640
- """Full vector search across STM and/or LTM with rehearsal and dormant reactivation.
641
-
642
- Args:
643
- use_hyde: If True, use HyDE query expansion for richer embedding (default False)
644
- spreading_depth: If >0, fetch co-activated neighbors and boost their scores (default 0)
645
- exclude_dreams: If True (default), exclude dream_insight memories from results.
646
- Dream insights are 21% of LTM and dilute search precision.
647
- Set to False only when explicitly looking for cross-domain patterns.
648
- hybrid: If True, boost results with BM25 keyword matches (default True)
649
- hybrid_alpha: Weight for vector vs BM25. Higher = more vector. (default 0.6)
650
- decompose: If True, decompose complex queries into sub-queries for better multi-hop (default True)
651
- """
652
- # Multi-query decomposition: for complex questions, search sub-parts and merge
653
- if decompose and query_text:
654
- _connectors = [" after ", " before ", " because ", " and then ", " when ", " while "]
655
- for conn in _connectors:
656
- if conn in query_text.lower():
657
- parts = query_text.lower().split(conn, 1)
658
- if len(parts) == 2 and len(parts[0]) > 10 and len(parts[1]) > 10:
659
- # Search each sub-query separately, merge results by max score
660
- all_results = {}
661
- for sub_q in [query_text, parts[0].strip("? "), parts[1].strip("? ")]:
662
- sub_results = search(
663
- sub_q, top_k=top_k, min_score=min_score, stores=stores,
664
- exclude_dormant=exclude_dormant, rehearse=False,
665
- source_type_filter=source_type_filter,
666
- include_archived=include_archived, use_hyde=use_hyde,
667
- hybrid=hybrid, hybrid_alpha=hybrid_alpha,
668
- spreading_depth=spreading_depth, decompose=False, # No recursion
669
- )
670
- for r in sub_results:
671
- key = (r["store"], r["id"])
672
- if key not in all_results or r["score"] > all_results[key]["score"]:
673
- all_results[key] = r
674
- merged = sorted(all_results.values(), key=lambda x: x["score"], reverse=True)[:top_k]
675
- if rehearse:
676
- _rehearse_results(merged)
677
- return merged
678
-
679
- db = _get_db()
680
-
681
- # Detect temporal queries — boost results with temporal_date
682
- _temporal_keywords = {"when", "date", "time", "first", "last", "before", "after",
683
- "cuándo", "cuando", "fecha", "primero", "último", "antes", "después"}
684
- query_lower = query_text.lower().split()
685
- is_temporal_query = bool(_temporal_keywords & set(query_lower))
686
-
687
- if use_hyde:
688
- query_vec = hyde_expand_query(query_text)
689
- else:
690
- query_vec = embed(query_text)
691
- if np.linalg.norm(query_vec) == 0:
692
- return []
693
-
694
- # Auto-restore snoozed memories whose snooze_until has passed
695
- _auto_restore_snoozed(db)
696
-
697
- # HNSW fast-path: use approximate nearest neighbors when available
698
- _hnsw_candidates = None
699
- try:
700
- import hnsw_index
701
- if hnsw_index.is_available() and hnsw_index.should_activate(stores):
702
- _hnsw_candidates = {}
703
- for s in (["stm", "ltm"] if stores == "both" else [stores]):
704
- hits = hnsw_index.search(query_vec, store=s, top_k=top_k * 4)
705
- if hits:
706
- for db_id, score in hits:
707
- _hnsw_candidates[(s, db_id)] = score
708
- except Exception:
709
- _hnsw_candidates = None
710
-
711
- results = []
712
- reactivated_ids = set()
713
-
714
- # Lifecycle filter: exclude snoozed always; exclude archived unless requested
715
- _lc = " AND (lifecycle_state IS NULL OR lifecycle_state = 'active' OR lifecycle_state = 'pinned'"
716
- if include_archived:
717
- _lc += " OR lifecycle_state = 'archived'"
718
- _lc += ")"
719
-
720
- # Search STM
721
- if stores in ("both", "stm"):
722
- where = "WHERE promoted_to_ltm = 0" + _lc
723
- params = []
724
- if source_type_filter:
725
- where += " AND source_type = ?"
726
- params.append(source_type_filter)
727
- rows = db.execute(f"SELECT * FROM stm_memories {where}", params).fetchall()
728
-
729
- for row in rows:
730
- # HNSW fast-path: skip rows not in candidate set
731
- if _hnsw_candidates is not None and ("stm", row["id"]) not in _hnsw_candidates:
732
- continue
733
- vec = _blob_to_array(row["embedding"])
734
- score = cosine_similarity(query_vec, vec)
735
- lifecycle = row["lifecycle_state"] or "active"
736
- if lifecycle == "pinned":
737
- score = min(1.0, score + 0.2)
738
- if score >= min_score:
739
- temporal = ""
740
- try:
741
- temporal = row["temporal_date"] or ""
742
- except (IndexError, KeyError):
743
- pass
744
- results.append({
745
- "store": "stm",
746
- "id": row["id"],
747
- "content": row["content"],
748
- "source_type": row["source_type"],
749
- "source_id": row["source_id"],
750
- "source_title": row["source_title"],
751
- "domain": row["domain"],
752
- "created_at": row["created_at"],
753
- "strength": row["strength"],
754
- "access_count": row["access_count"],
755
- "score": score,
756
- "lifecycle_state": lifecycle,
757
- "temporal_date": temporal,
758
- })
759
-
760
- # Search LTM (active)
761
- if stores in ("both", "ltm"):
762
- where = "WHERE is_dormant = 0" + _lc
763
- params = []
764
- if source_type_filter:
765
- where += " AND source_type = ?"
766
- params.append(source_type_filter)
767
- if exclude_dreams and not source_type_filter:
768
- where += " AND source_type != 'dream_insight'"
769
- rows = db.execute(f"SELECT * FROM ltm_memories {where}", params).fetchall()
770
-
771
- for row in rows:
772
- # HNSW fast-path: skip rows not in candidate set
773
- if _hnsw_candidates is not None and ("ltm", row["id"]) not in _hnsw_candidates:
774
- continue
775
- vec = _blob_to_array(row["embedding"])
776
- score = cosine_similarity(query_vec, vec)
777
- lifecycle = row["lifecycle_state"] or "active"
778
- if lifecycle == "pinned":
779
- score = min(1.0, score + 0.2)
780
- if score >= min_score:
781
- results.append({
782
- "store": "ltm",
783
- "id": row["id"],
784
- "content": row["content"],
785
- "source_type": row["source_type"],
786
- "source_id": row["source_id"],
787
- "source_title": row["source_title"],
788
- "domain": row["domain"],
789
- "created_at": row["created_at"],
790
- "strength": row["strength"],
791
- "access_count": row["access_count"],
792
- "score": score,
793
- "tags": row["tags"],
794
- "lifecycle_state": lifecycle,
795
- })
796
-
797
- # Check dormant LTM for reactivation
798
- if stores in ("both", "ltm") and not exclude_dormant:
799
- dormant_rows = db.execute("SELECT * FROM ltm_memories WHERE is_dormant = 1").fetchall()
800
- for row in dormant_rows:
801
- vec = _blob_to_array(row["embedding"])
802
- score = cosine_similarity(query_vec, vec)
803
- if score > 0.8:
804
- # Reactivate
805
- db.execute(
806
- "UPDATE ltm_memories SET is_dormant = 0, strength = 0.5, last_accessed = datetime('now') WHERE id = ?",
807
- (row["id"],)
808
- )
809
- reactivated_ids.add(("ltm", row["id"]))
810
- results.append({
811
- "store": "ltm",
812
- "id": row["id"],
813
- "content": row["content"],
814
- "source_type": row["source_type"],
815
- "source_id": row["source_id"],
816
- "source_title": row["source_title"],
817
- "domain": row["domain"],
818
- "created_at": row["created_at"],
819
- "strength": 0.5,
820
- "access_count": row["access_count"],
821
- "score": score,
822
- "tags": row["tags"],
823
- "reactivated": True,
824
- })
825
- if reactivated_ids:
826
- db.commit()
827
-
828
- # Hybrid search: boost vector results with BM25 keyword matches
829
- if hybrid and query_text:
830
- bm25_results = bm25_search(query_text, stores=stores, top_k=top_k * 4,
831
- source_type_filter=source_type_filter)
832
- if bm25_results:
833
- results = _rrf_fuse(results, bm25_results, alpha=hybrid_alpha)
834
-
835
- # Temporal boost: for "when" queries, boost results that have temporal_date
836
- if is_temporal_query:
837
- for r in results:
838
- if r.get("temporal_date"):
839
- r["score"] = min(0.95, r["score"] + 0.05)
840
-
841
- # Recency temporal boost: recent memories get additive bonus (query-adaptive)
842
- results = _apply_temporal_boost(results, query_text)
843
-
844
- # Knowledge Graph structural boost: connected memories rank higher
845
- results = _kg_boost_results(results)
846
-
847
- # Sort by score descending, take top-20 for reranking
848
- results.sort(key=lambda x: x.get("score", 0), reverse=True)
849
-
850
- # Cross-encoder reranking: precise top-k from top-20 candidates
851
- if len(results) > top_k:
852
- results = rerank_results(query_text, results[:top_k * 4], top_k=top_k)
853
- else:
854
- results = results[:top_k]
855
-
856
- # Spreading activation: boost co-activated neighbors (Feature 2)
857
- co_activation_applied = False
858
- if spreading_depth > 0 and results:
859
- memory_ids = [(r["store"], r["id"]) for r in results]
860
- neighbor_boosts = _get_co_activated_neighbors(memory_ids, depth=spreading_depth)
861
-
862
- if neighbor_boosts:
863
- co_activation_applied = True
864
- # Boost existing results that are neighbors
865
- existing_hashes = set()
866
- for r in results:
867
- co_hash = _canonical_co_id(r["store"], r["id"])
868
- existing_hashes.add(co_hash)
869
- if co_hash in neighbor_boosts:
870
- boost = neighbor_boosts[co_hash]
871
- r["score"] = min(0.95, r["score"] + boost)
872
- r["co_activation_boost"] = boost
873
-
874
- # Add neighbor memories not already in results
875
- new_neighbor_hashes = set(neighbor_boosts.keys()) - existing_hashes
876
- if new_neighbor_hashes:
877
- for store_name, table in [("stm", "stm_memories"), ("ltm", "ltm_memories")]:
878
- rows = db.execute(f"SELECT * FROM {table}").fetchall()
879
- for row in rows:
880
- nh = _canonical_co_id(store_name, row["id"])
881
- if nh in new_neighbor_hashes:
882
- boost = neighbor_boosts[nh]
883
- results.append({
884
- "store": store_name,
885
- "id": row["id"],
886
- "content": row["content"],
887
- "source_type": row.get("source_type", ""),
888
- "source_id": row.get("source_id", ""),
889
- "tags": row.get("tags", ""),
890
- "domain": row.get("domain", ""),
891
- "created_at": row.get("created_at", ""),
892
- "strength": row.get("strength", 0.0),
893
- "access_count": row.get("access_count", 0),
894
- "score": min(1.0, boost),
895
- "co_activation_boost": boost,
896
- "lifecycle_state": row.get("lifecycle_state", "active"),
897
- })
898
- new_neighbor_hashes.discard(nh)
899
-
900
- # Re-sort after applying boosts
901
- results.sort(key=lambda x: x["score"], reverse=True)
902
-
903
- # Add rank explanations
904
- for rank, r in enumerate(results, 1):
905
- score = r["score"]
906
- store = r["store"].upper()
907
- strength = r.get("strength", 0.0)
908
- access_count = r.get("access_count", 0)
909
- created = r.get("created_at", "")
910
- tags = r.get("tags", "")
911
- reactivated = r.get("reactivated", False)
912
-
913
- ranking_desc = "semantic_similarity"
914
- if use_hyde:
915
- ranking_desc = "hyde_centroid_similarity"
916
- parts = [f"Ranked #{rank}: {ranking_desc}={score:.3f}"]
917
- parts.append(f"store={store}, strength={strength:.2f}, accesses={access_count}")
918
- if r.get("kg_boost"):
919
- parts.append(f"kg_boost=+{r['kg_boost']:.3f} ({r.get('kg_connections', 0)} edges)")
920
- if r.get("co_activation_boost"):
921
- parts.append(f"co_activation_boost=+{r['co_activation_boost']:.3f}")
922
- if created:
923
- parts.append(f"created={created[:10]}")
924
- if tags:
925
- parts.append(f"tags={tags}")
926
- if reactivated:
927
- parts.append("REACTIVATED (was dormant, score>0.8 triggered revival)")
928
- r["explanation"] = " | ".join(parts)
929
-
930
- # Rehearsal: update strength and access_count for returned results
931
- if rehearse and results:
932
- _rehearse_results(results, skip_ids=reactivated_ids)
933
-
934
- # Record co-activation for future spreading (Feature 2)
935
- if results and len(results) >= 2:
936
- try:
937
- record_co_activation([(r["store"], r["id"]) for r in results])
938
- except Exception:
939
- pass # Non-critical — don't break search
940
-
941
- # Log retrieval
942
- top_score = results[0]["score"] if results else 0.0
943
- db.execute(
944
- "INSERT INTO retrieval_log (query_text, results_count, top_score) VALUES (?, ?, ?)",
945
- (query_text[:500], len(results), top_score)
946
- )
947
- db.commit()
948
-
949
- return results