superlocalmemory 3.7.8 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +827 -72
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -114,8 +114,14 @@ def _tool_event_hit(
114
114
  memory_conn: sqlite3.Connection,
115
115
  played_at: datetime,
116
116
  fact_ids: list[str],
117
+ profile_id: str | None = None,
117
118
  ) -> bool:
118
- """True iff any tool_events row references any fact_id within +30 s."""
119
+ """True iff any tool_events row references any fact_id within +30 s.
120
+
121
+ Tenant-scoped: without a profile predicate, one tenant's reward signal
122
+ would be reinforced by another tenant's tool events firing in the same
123
+ 30 s window — cross-contaminated behavioral learning.
124
+ """
119
125
  if not fact_ids:
120
126
  return False
121
127
  start = played_at.isoformat(timespec="seconds")
@@ -139,12 +145,24 @@ def _tool_event_hit(
139
145
  # in Python — typically 0-few rows in a 30 s window, far cheaper than
140
146
  # N LIKE passes against a growing table. Still O(rows_in_window * len(fact_ids))
141
147
  # worst case but the constant factor is tiny (a substring check).
148
+ # Add the profile predicate only when tool_events actually has the column
149
+ # (schema varies by install).
150
+ scope_sql, scope_params = "", ()
151
+ if profile_id is not None:
152
+ try:
153
+ cols = {r[1] for r in memory_conn.execute(
154
+ "PRAGMA table_info(tool_events)").fetchall()}
155
+ if "profile_id" in cols:
156
+ scope_sql = " AND profile_id = ?"
157
+ scope_params = (profile_id,)
158
+ except sqlite3.Error:
159
+ pass
142
160
  try:
143
161
  candidate_rows = memory_conn.execute(
144
162
  "SELECT payload_json FROM tool_events "
145
163
  "WHERE occurred_at BETWEEN ? AND ? "
146
- " AND payload_json IS NOT NULL",
147
- (start, end),
164
+ " AND payload_json IS NOT NULL" + scope_sql,
165
+ (start, end, *scope_params),
148
166
  ).fetchall()
149
167
  except sqlite3.Error:
150
168
  return False
@@ -165,6 +183,7 @@ def _requery_detected(
165
183
  memory_conn: sqlite3.Connection,
166
184
  played_at: datetime,
167
185
  query_id: str,
186
+ profile_id: str | None = None,
168
187
  ) -> bool:
169
188
  """True iff a follow-up query within 30 s has matching NFC topic sig.
170
189
 
@@ -193,14 +212,28 @@ def _requery_detected(
193
212
  if tbl is None:
194
213
  return False
195
214
 
215
+ # Tenant scope (guarded — tool_events schema varies): without it, another
216
+ # profile's recall events in the same window count as this profile's
217
+ # requeries, corrupting reward attribution.
218
+ scope_sql, scope_params = "", ()
219
+ if profile_id is not None:
220
+ try:
221
+ cols = {r[1] for r in memory_conn.execute(
222
+ "PRAGMA table_info(tool_events)").fetchall()}
223
+ if "profile_id" in cols:
224
+ scope_sql = " AND profile_id = ?"
225
+ scope_params = (profile_id,)
226
+ except sqlite3.Error:
227
+ pass
228
+
196
229
  # Read *query* text from tool_events payload for within-window events;
197
230
  # compute topic sig on each, compare against the original query's sig.
198
231
  try:
199
232
  rows = memory_conn.execute(
200
233
  "SELECT payload_json FROM tool_events "
201
234
  "WHERE occurred_at > ? AND occurred_at <= ? "
202
- " AND tool_name = 'recall' LIMIT 20",
203
- (start, end),
235
+ " AND tool_name = 'recall'" + scope_sql + " LIMIT 20",
236
+ (start, end, *scope_params),
204
237
  ).fetchall()
205
238
  except sqlite3.Error:
206
239
  return False
@@ -212,9 +245,9 @@ def _requery_detected(
212
245
  try:
213
246
  seed_row = memory_conn.execute(
214
247
  "SELECT payload_json FROM tool_events "
215
- "WHERE occurred_at <= ? AND tool_name = 'recall' "
248
+ "WHERE occurred_at <= ? AND tool_name = 'recall'" + scope_sql + " "
216
249
  "ORDER BY occurred_at DESC LIMIT 1",
217
- (played_at.isoformat(timespec="seconds"),),
250
+ (played_at.isoformat(timespec="seconds"), *scope_params),
218
251
  ).fetchone()
219
252
  except sqlite3.Error:
220
253
  seed_row = None
@@ -288,12 +321,12 @@ def settle_stale_plays(
288
321
  reward: float | None = None
289
322
  kind = "default"
290
323
  if memory_conn is not None and _tool_event_hit(
291
- memory_conn, played, top3,
324
+ memory_conn, played, top3, profile_id=str(profile_id),
292
325
  ):
293
326
  reward = 1.0
294
327
  kind = "proxy_position"
295
328
  elif memory_conn is not None and _requery_detected(
296
- memory_conn, played, row["query_id"],
329
+ memory_conn, played, row["query_id"], profile_id=str(profile_id),
297
330
  ):
298
331
  reward = 0.0
299
332
  kind = "proxy_requery"
@@ -23,12 +23,14 @@ Storage:
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import json
26
27
  import logging
28
+ import math
27
29
  import sqlite3
28
30
  import threading
29
31
  from datetime import datetime, timezone
30
32
  from pathlib import Path
31
- from typing import Any, Dict, Optional
33
+ from typing import Any, Dict
32
34
 
33
35
  logger = logging.getLogger("superlocalmemory.learning.source_quality")
34
36
 
@@ -55,6 +57,35 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_sq_profile_source
55
57
  ON source_quality (profile_id, source_id)
56
58
  """
57
59
 
60
+ _CREATE_OBSERVATIONS = """
61
+ CREATE TABLE IF NOT EXISTS source_quality_observations (
62
+ profile_id TEXT NOT NULL,
63
+ outcome_id TEXT NOT NULL,
64
+ source_id TEXT NOT NULL,
65
+ reward REAL NOT NULL,
66
+ observed_at TEXT NOT NULL,
67
+ PRIMARY KEY (profile_id, outcome_id, source_id)
68
+ )
69
+ """
70
+
71
+ _CREATE_REPAIR_STATE = """
72
+ CREATE TABLE IF NOT EXISTS source_quality_repair_state (
73
+ profile_id TEXT PRIMARY KEY,
74
+ last_rowid INTEGER NOT NULL DEFAULT 0,
75
+ last_settled_at TEXT NOT NULL DEFAULT '',
76
+ last_outcome_id TEXT NOT NULL DEFAULT '',
77
+ updated_at TEXT NOT NULL
78
+ )
79
+ """
80
+
81
+ _MAX_FACTS_PER_OUTCOME = 100
82
+ _MAX_SOURCES_PER_OUTCOME = 100
83
+ _PROVENANCE_QUERY_CHUNK = 500
84
+
85
+
86
+ class SourceQualityRepairUnavailable(RuntimeError):
87
+ """A repair read failed transiently and must not be interpreted as EOF."""
88
+
58
89
 
59
90
  def _utcnow_iso() -> str:
60
91
  """Return current UTC time as ISO-8601 string."""
@@ -85,9 +116,36 @@ class SourceQualityScorer:
85
116
  def _ensure_schema(self) -> None:
86
117
  conn = self._connect()
87
118
  try:
119
+ # Separate scorer instances can be constructed concurrently during
120
+ # first startup (background history repair + outcome settlement).
121
+ # Serialize the read/ALTER sequence at SQLite's transaction
122
+ # boundary so two processes cannot both observe a legacy column as
123
+ # missing and race into ``duplicate column name``.
124
+ conn.execute("BEGIN IMMEDIATE")
88
125
  conn.execute(_CREATE_TABLE)
89
126
  conn.execute(_CREATE_UNIQUE)
127
+ conn.execute(_CREATE_OBSERVATIONS)
128
+ conn.execute(_CREATE_REPAIR_STATE)
129
+ repair_columns = {
130
+ str(row["name"])
131
+ for row in conn.execute(
132
+ "PRAGMA table_info(source_quality_repair_state)"
133
+ ).fetchall()
134
+ }
135
+ if "last_settled_at" not in repair_columns:
136
+ conn.execute(
137
+ "ALTER TABLE source_quality_repair_state "
138
+ "ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
139
+ )
140
+ if "last_outcome_id" not in repair_columns:
141
+ conn.execute(
142
+ "ALTER TABLE source_quality_repair_state "
143
+ "ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
144
+ )
90
145
  conn.commit()
146
+ except Exception:
147
+ conn.rollback()
148
+ raise
91
149
  finally:
92
150
  conn.close()
93
151
 
@@ -159,6 +217,150 @@ class SourceQualityScorer:
159
217
  finally:
160
218
  conn.close()
161
219
 
220
+ def record_reward(
221
+ self,
222
+ profile_id: str,
223
+ outcome_id: str,
224
+ source_ids: list[str],
225
+ reward: float,
226
+ ) -> int:
227
+ """Apply one fractional Beta observation per unique source.
228
+
229
+ The observation ledger makes retries idempotent. A reward of 0.8
230
+ contributes ``+0.8`` to alpha and ``+0.2`` to beta rather than
231
+ inventing a binary success label.
232
+ """
233
+ return self.record_rewards([
234
+ (profile_id, outcome_id, source_ids, reward),
235
+ ])
236
+
237
+ def record_rewards(
238
+ self,
239
+ observations: list[tuple[str, str, list[str], float]],
240
+ ) -> int:
241
+ """Batch bounded reward observations in one learning-DB transaction."""
242
+ inserted = 0
243
+ now = _utcnow_iso()
244
+ with self._lock:
245
+ conn = self._connect()
246
+ try:
247
+ conn.execute("BEGIN IMMEDIATE")
248
+ for profile_id, outcome_id, sources, raw_reward in observations:
249
+ if not profile_id or not outcome_id:
250
+ continue
251
+ numeric_reward = float(raw_reward)
252
+ if not math.isfinite(numeric_reward):
253
+ continue
254
+ reward = max(0.0, min(1.0, numeric_reward))
255
+ for source_id in sorted(set(sources))[
256
+ :_MAX_SOURCES_PER_OUTCOME
257
+ ]:
258
+ if not source_id:
259
+ continue
260
+ cursor = conn.execute(
261
+ "INSERT OR IGNORE INTO source_quality_observations "
262
+ "(profile_id, outcome_id, source_id, reward, observed_at) "
263
+ "VALUES (?, ?, ?, ?, ?)",
264
+ (profile_id, outcome_id, source_id, reward, now),
265
+ )
266
+ if cursor.rowcount != 1:
267
+ continue
268
+ conn.execute(
269
+ "INSERT OR IGNORE INTO source_quality "
270
+ "(profile_id, source_id, alpha, beta, updated_at) "
271
+ "VALUES (?, ?, ?, ?, ?)",
272
+ (profile_id, source_id, _ALPHA, _BETA, now),
273
+ )
274
+ conn.execute(
275
+ "UPDATE source_quality SET "
276
+ "alpha = alpha + ?, beta = beta + ?, updated_at = ? "
277
+ "WHERE profile_id = ? AND source_id = ?",
278
+ (
279
+ reward, 1.0 - reward, now,
280
+ profile_id, source_id,
281
+ ),
282
+ )
283
+ inserted += 1
284
+ conn.commit()
285
+ except Exception:
286
+ conn.rollback()
287
+ raise
288
+ finally:
289
+ conn.close()
290
+ return inserted
291
+
292
+ def get_repair_cursor(self, profile_id: str) -> int:
293
+ conn = self._connect()
294
+ try:
295
+ row = conn.execute(
296
+ "SELECT last_rowid FROM source_quality_repair_state "
297
+ "WHERE profile_id = ?",
298
+ (profile_id,),
299
+ ).fetchone()
300
+ return int(row["last_rowid"] or 0) if row else 0
301
+ finally:
302
+ conn.close()
303
+
304
+ def set_repair_cursor(self, profile_id: str, rowid: int) -> None:
305
+ now = _utcnow_iso()
306
+ with self._lock:
307
+ conn = self._connect()
308
+ try:
309
+ conn.execute(
310
+ "INSERT INTO source_quality_repair_state "
311
+ "(profile_id, last_rowid, updated_at) VALUES (?, ?, ?) "
312
+ "ON CONFLICT(profile_id) DO UPDATE SET "
313
+ "last_rowid = excluded.last_rowid, "
314
+ "updated_at = excluded.updated_at",
315
+ (profile_id, int(rowid), now),
316
+ )
317
+ conn.commit()
318
+ finally:
319
+ conn.close()
320
+
321
+ def get_repair_position(self, profile_id: str) -> tuple[str, str]:
322
+ """Return the durable settlement-order cursor for historical repair."""
323
+ conn = self._connect()
324
+ try:
325
+ row = conn.execute(
326
+ "SELECT last_settled_at, last_outcome_id "
327
+ "FROM source_quality_repair_state WHERE profile_id = ?",
328
+ (profile_id,),
329
+ ).fetchone()
330
+ if row is None:
331
+ return ("", "")
332
+ return (
333
+ str(row["last_settled_at"] or ""),
334
+ str(row["last_outcome_id"] or ""),
335
+ )
336
+ finally:
337
+ conn.close()
338
+
339
+ def set_repair_position(
340
+ self,
341
+ profile_id: str,
342
+ settled_at: str,
343
+ outcome_id: str,
344
+ ) -> None:
345
+ """Advance repair by settlement order, not immutable insertion rowid."""
346
+ now = _utcnow_iso()
347
+ with self._lock:
348
+ conn = self._connect()
349
+ try:
350
+ conn.execute(
351
+ "INSERT INTO source_quality_repair_state "
352
+ "(profile_id,last_rowid,last_settled_at,last_outcome_id,"
353
+ "updated_at) VALUES (?,0,?,?,?) "
354
+ "ON CONFLICT(profile_id) DO UPDATE SET "
355
+ "last_settled_at=excluded.last_settled_at,"
356
+ "last_outcome_id=excluded.last_outcome_id,"
357
+ "updated_at=excluded.updated_at",
358
+ (profile_id, str(settled_at), str(outcome_id), now),
359
+ )
360
+ conn.commit()
361
+ finally:
362
+ conn.close()
363
+
162
364
  # ------------------------------------------------------------------
163
365
  # Public API: read quality
164
366
  # ------------------------------------------------------------------
@@ -301,3 +503,323 @@ class SourceQualityScorer:
301
503
  return result
302
504
  finally:
303
505
  conn.close()
506
+
507
+
508
+ def _source_key(row: sqlite3.Row) -> str:
509
+ source_type = str(row["source_type"] or "").strip()[:100]
510
+ actor = str(row["created_by"] or "").strip()
511
+ operation_or_legacy_source = str(row["source_id"] or "").strip()
512
+ # Canonical ingestion stores its unique operation UUID in source_id for
513
+ # lineage/idempotency and the stable trusted client in created_by. Quality
514
+ # must aggregate by the stable actor; operation IDs would create one
515
+ # single-observation "source" per remember call. Legacy provenance often
516
+ # has no actor, so retain its established source_id fallback.
517
+ identifier = (
518
+ actor
519
+ if actor and actor.lower() != "unknown"
520
+ else operation_or_legacy_source
521
+ )[:100]
522
+ if source_type and identifier:
523
+ return f"{source_type}:{identifier}"
524
+ if identifier:
525
+ return f"source:{identifier}"
526
+ return source_type
527
+
528
+
529
+ def _load_source_map(
530
+ memory_db_path: Path,
531
+ profile_id: str,
532
+ fact_ids: list[str],
533
+ *,
534
+ strict: bool = False,
535
+ ) -> dict[str, set[str]]:
536
+ """Read only the provenance needed by this bounded reward batch."""
537
+ unique_facts = list(dict.fromkeys(str(fid) for fid in fact_ids if fid))
538
+ if not unique_facts or not Path(memory_db_path).exists():
539
+ return {}
540
+ result: dict[str, set[str]] = {}
541
+ try:
542
+ conn = sqlite3.connect(
543
+ f"file:{Path(memory_db_path)}?mode=ro", uri=True, timeout=1.0,
544
+ )
545
+ conn.row_factory = sqlite3.Row
546
+ try:
547
+ columns = {
548
+ str(row["name"])
549
+ for row in conn.execute("PRAGMA table_info(provenance)")
550
+ }
551
+ required = {
552
+ "profile_id", "fact_id", "source_type",
553
+ "source_id", "created_by",
554
+ }
555
+ if not required.issubset(columns):
556
+ return {}
557
+ for start in range(0, len(unique_facts), _PROVENANCE_QUERY_CHUNK):
558
+ chunk = unique_facts[start:start + _PROVENANCE_QUERY_CHUNK]
559
+ placeholders = ",".join("?" for _ in chunk)
560
+ rows = conn.execute(
561
+ "SELECT DISTINCT fact_id, source_type, source_id, created_by "
562
+ "FROM provenance WHERE profile_id = ? "
563
+ f"AND fact_id IN ({placeholders})",
564
+ (profile_id, *chunk),
565
+ ).fetchall()
566
+ for row in rows:
567
+ key = _source_key(row)
568
+ if key:
569
+ result.setdefault(str(row["fact_id"]), set()).add(key)
570
+ finally:
571
+ conn.close()
572
+ except sqlite3.Error as exc:
573
+ logger.debug("source provenance unavailable: %s", exc)
574
+ if strict:
575
+ raise SourceQualityRepairUnavailable(
576
+ "source provenance temporarily unavailable",
577
+ ) from exc
578
+ return {}
579
+ return result
580
+
581
+
582
+ def update_source_quality_for_reward(
583
+ *,
584
+ memory_db_path: Path,
585
+ learning_db_path: Path,
586
+ profile_id: str,
587
+ outcome_id: str,
588
+ fact_ids: list[str],
589
+ reward: float,
590
+ ) -> int:
591
+ """Fail-soft online bridge from a finalized reward to real provenance."""
592
+ return update_source_quality_for_reward_batch(
593
+ memory_db_path=memory_db_path,
594
+ learning_db_path=learning_db_path,
595
+ rewards=[(profile_id, outcome_id, fact_ids, reward)],
596
+ )
597
+
598
+
599
+ def update_source_quality_for_reward_batch(
600
+ *,
601
+ memory_db_path: Path,
602
+ learning_db_path: Path,
603
+ rewards: list[tuple[str, str, list[str], float]],
604
+ ) -> int:
605
+ """Fail-soft bounded bridge for worker-finalized reward batches."""
606
+ try:
607
+ bounded_rewards = rewards[:1000]
608
+ normalized = []
609
+ for profile_id, outcome_id, fact_ids, reward in bounded_rewards:
610
+ bounded_facts = list(dict.fromkeys(fact_ids))[
611
+ :_MAX_FACTS_PER_OUTCOME
612
+ ]
613
+ normalized.append((
614
+ profile_id, outcome_id, bounded_facts, float(reward),
615
+ ))
616
+ if not normalized:
617
+ return 0
618
+ # Each reward batch is profile-homogeneous in current callers. Split
619
+ # defensively so provenance can never cross a profile boundary.
620
+ by_profile: dict[str, list[tuple[str, list[str], float]]] = {}
621
+ for profile_id, outcome_id, fact_ids, reward in normalized:
622
+ by_profile.setdefault(profile_id, []).append(
623
+ (outcome_id, fact_ids, reward),
624
+ )
625
+ scorer = SourceQualityScorer(Path(learning_db_path))
626
+ observations = []
627
+ for profile_id, profile_rewards in by_profile.items():
628
+ profile_facts = [
629
+ fact_id
630
+ for _, fact_ids, _ in profile_rewards
631
+ for fact_id in fact_ids
632
+ ]
633
+ source_map = _load_source_map(
634
+ Path(memory_db_path), profile_id, profile_facts,
635
+ )
636
+ for outcome_id, fact_ids, reward in profile_rewards:
637
+ source_ids = sorted({
638
+ source
639
+ for fact_id in fact_ids
640
+ for source in source_map.get(fact_id, set())
641
+ })[:_MAX_SOURCES_PER_OUTCOME]
642
+ observations.append((
643
+ profile_id, outcome_id, source_ids, reward,
644
+ ))
645
+ return scorer.record_rewards(observations)
646
+ except (OSError, sqlite3.Error, TypeError, ValueError) as exc:
647
+ logger.debug("source-quality reward update skipped: %s", exc)
648
+ return 0
649
+
650
+
651
+ def enumerate_source_quality_repair_profiles(
652
+ memory_db_path: Path,
653
+ ) -> list[str]:
654
+ """Return profiles with settled numeric outcomes eligible for repair."""
655
+ path = Path(memory_db_path)
656
+ if not path.exists():
657
+ return []
658
+ try:
659
+ conn = sqlite3.connect(
660
+ f"file:{path}?mode=ro", uri=True, timeout=1.0,
661
+ )
662
+ conn.row_factory = sqlite3.Row
663
+ try:
664
+ columns = {
665
+ str(row["name"])
666
+ for row in conn.execute("PRAGMA table_info(action_outcomes)")
667
+ }
668
+ required = {"profile_id", "reward", "settled"}
669
+ if not required.issubset(columns):
670
+ return []
671
+ rows = conn.execute(
672
+ "SELECT DISTINCT profile_id FROM action_outcomes "
673
+ "WHERE settled = 1 AND reward IS NOT NULL "
674
+ "AND typeof(reward) IN ('integer', 'real') "
675
+ "AND profile_id IS NOT NULL AND profile_id != '' "
676
+ "ORDER BY profile_id ASC",
677
+ ).fetchall()
678
+ return [str(row["profile_id"]) for row in rows]
679
+ finally:
680
+ conn.close()
681
+ except sqlite3.Error as exc:
682
+ logger.debug("source-quality profile enumeration unavailable: %s", exc)
683
+ raise SourceQualityRepairUnavailable(
684
+ "repair profile enumeration temporarily unavailable",
685
+ ) from exc
686
+
687
+
688
+ def _parse_repair_rows(
689
+ rows: list[sqlite3.Row],
690
+ ) -> tuple[list[tuple[sqlite3.Row, list[str]]], list[str]]:
691
+ parsed: list[tuple[sqlite3.Row, list[str]]] = []
692
+ all_facts: list[str] = []
693
+ for row in rows:
694
+ try:
695
+ value = json.loads(str(row["fact_ids_json"] or "[]"))
696
+ fact_ids = (
697
+ [str(item) for item in value if item][:_MAX_FACTS_PER_OUTCOME]
698
+ if isinstance(value, list) else []
699
+ )
700
+ except (TypeError, ValueError, json.JSONDecodeError):
701
+ fact_ids = []
702
+ parsed.append((row, fact_ids))
703
+ all_facts.extend(fact_ids)
704
+ return parsed, all_facts
705
+
706
+
707
+ def _repair_observations(
708
+ profile_id: str,
709
+ parsed: list[tuple[sqlite3.Row, list[str]]],
710
+ source_map: dict[str, set[str]],
711
+ ) -> list[tuple[str, str, list[str], float]]:
712
+ observations = []
713
+ for row, fact_ids in parsed:
714
+ sources = sorted({
715
+ source for fact_id in fact_ids
716
+ for source in source_map.get(fact_id, set())
717
+ })[:_MAX_SOURCES_PER_OUTCOME]
718
+ observations.append((
719
+ profile_id, str(row["outcome_id"]), sources, float(row["reward"]),
720
+ ))
721
+ return observations
722
+
723
+
724
+ def repair_historical_source_quality(
725
+ memory_db_path: Path,
726
+ learning_db_path: Path,
727
+ profile_id: str,
728
+ *,
729
+ batch_size: int = 250,
730
+ max_batches: int = 4,
731
+ ) -> dict[str, int | bool]:
732
+ """Explicit, resumable historical repair; never invoked by API handlers.
733
+
734
+ Work is capped to 1,000 outcomes per call by default. The cursor advances
735
+ only after the idempotent observation ledger commits, so interruption can
736
+ replay safely without double-counting.
737
+ """
738
+ safe_batch = max(1, min(1000, int(batch_size)))
739
+ safe_batches = max(1, min(100, int(max_batches)))
740
+ scorer = SourceQualityScorer(Path(learning_db_path))
741
+ scanned = 0
742
+ observations = 0
743
+ complete = False
744
+ for _ in range(safe_batches):
745
+ settled_cursor, outcome_cursor = scorer.get_repair_position(profile_id)
746
+ rows = _load_reward_repair_batch(
747
+ Path(memory_db_path),
748
+ profile_id,
749
+ settled_cursor,
750
+ outcome_cursor,
751
+ safe_batch,
752
+ )
753
+ if not rows:
754
+ complete = True
755
+ break
756
+ parsed, all_facts = _parse_repair_rows(rows)
757
+ source_map = _load_source_map(
758
+ Path(memory_db_path), profile_id, all_facts, strict=True,
759
+ )
760
+ batch_observations = _repair_observations(
761
+ profile_id, parsed, source_map,
762
+ )
763
+ observations += scorer.record_rewards(batch_observations)
764
+ scanned += len(rows)
765
+ scorer.set_repair_position(
766
+ profile_id,
767
+ str(rows[-1]["settled_key"] or ""),
768
+ str(rows[-1]["outcome_id"]),
769
+ )
770
+ return {
771
+ "scanned": scanned,
772
+ "observations": observations,
773
+ "complete": complete,
774
+ }
775
+
776
+
777
+ def _load_reward_repair_batch(
778
+ memory_db_path: Path,
779
+ profile_id: str,
780
+ after_settled_at: str,
781
+ after_outcome_id: str,
782
+ limit: int,
783
+ ) -> list[sqlite3.Row]:
784
+ if not memory_db_path.exists():
785
+ return []
786
+ try:
787
+ conn = sqlite3.connect(
788
+ f"file:{memory_db_path}?mode=ro", uri=True, timeout=1.0,
789
+ )
790
+ conn.row_factory = sqlite3.Row
791
+ try:
792
+ columns = {
793
+ str(row["name"])
794
+ for row in conn.execute("PRAGMA table_info(action_outcomes)")
795
+ }
796
+ required = {
797
+ "outcome_id", "profile_id", "fact_ids_json",
798
+ "reward", "settled",
799
+ }
800
+ if not required.issubset(columns):
801
+ return []
802
+ return conn.execute(
803
+ "SELECT outcome_id, fact_ids_json, reward, "
804
+ "COALESCE(settled_at, '') AS settled_key "
805
+ "FROM action_outcomes WHERE profile_id = ? "
806
+ "AND settled = 1 AND reward IS NOT NULL "
807
+ "AND typeof(reward) IN ('integer', 'real') "
808
+ "AND (COALESCE(settled_at, '') > ? OR "
809
+ "(COALESCE(settled_at, '') = ? AND outcome_id > ?)) "
810
+ "ORDER BY COALESCE(settled_at, '') ASC, outcome_id ASC LIMIT ?",
811
+ (
812
+ profile_id,
813
+ str(after_settled_at),
814
+ str(after_settled_at),
815
+ str(after_outcome_id),
816
+ int(limit),
817
+ ),
818
+ ).fetchall()
819
+ finally:
820
+ conn.close()
821
+ except sqlite3.Error as exc:
822
+ logger.debug("source-quality repair unavailable: %s", exc)
823
+ raise SourceQualityRepairUnavailable(
824
+ "repair batch temporarily unavailable",
825
+ ) from exc