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
@@ -5,72 +5,308 @@
5
5
  - AGPL-3.0-or-later
6
6
 
7
7
  Routes: /api/behavioral/status, /api/behavioral/report-outcome
8
- Uses V3 learning.behavioral.BehavioralPatternStore and learning.outcomes.OutcomeTracker.
8
+ Uses V3 learning.behavioral.BehavioralPatternStore and direct telemetry reads.
9
9
  """
10
10
  import json
11
11
  import logging
12
+ import sqlite3
13
+ from pathlib import Path
14
+ from typing import Literal
12
15
 
13
- from fastapi import APIRouter
16
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
17
+ from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator
14
18
 
15
- from .helpers import get_active_profile, MEMORY_DIR
19
+ from .helpers import MEMORY_DIR, get_active_profile
16
20
 
17
21
  logger = logging.getLogger("superlocalmemory.routes.behavioral")
18
22
  router = APIRouter()
19
23
 
20
- LEARNING_DB = MEMORY_DIR / "learning.db"
24
+ _RECENT_OUTCOMES_LIMIT = 20
25
+ _REWARD_TIMELINE_DAYS = 182
26
+ _MAX_OUTCOME_FACT_IDS = 100
27
+ _MAX_FACT_ID_LENGTH = 200
28
+
29
+
30
+ class ReportOutcomeRequest(BaseModel):
31
+ """Bounded explicit outcome payload accepted from dashboard clients."""
32
+
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ memory_ids: list[StrictStr] = Field(
36
+ min_length=1,
37
+ max_length=_MAX_OUTCOME_FACT_IDS,
38
+ )
39
+ outcome: Literal["success", "failure", "partial"]
40
+ action_type: StrictStr = Field(default="other", max_length=80)
41
+ context: StrictStr = Field(default="", max_length=1000)
42
+
43
+ @field_validator("memory_ids")
44
+ @classmethod
45
+ def normalize_fact_ids(cls, value: list[str]) -> list[str]:
46
+ """Strip and de-duplicate fact IDs while preserving request order."""
47
+ deduplicated: list[str] = []
48
+ seen: set[str] = set()
49
+ for raw_fact_id in value:
50
+ fact_id = raw_fact_id.strip()
51
+ if not fact_id:
52
+ raise ValueError("memory_ids must not contain blank fact IDs")
53
+ if len(fact_id) > _MAX_FACT_ID_LENGTH:
54
+ raise ValueError(
55
+ f"memory_ids entries must be at most "
56
+ f"{_MAX_FACT_ID_LENGTH} characters"
57
+ )
58
+ if fact_id not in seen:
59
+ seen.add(fact_id)
60
+ deduplicated.append(fact_id)
61
+ return deduplicated
62
+
63
+
64
+ def _require_read(request: Request) -> None:
65
+ from superlocalmemory.access.rbac import Permission
66
+ from superlocalmemory.server.rbac_enforce import require_permission
67
+
68
+ require_permission(request, Permission.READ, profile=get_active_profile())
69
+
70
+
71
+ def _require_write(request: Request) -> None:
72
+ from superlocalmemory.access.rbac import Permission
73
+ from superlocalmemory.server.rbac_enforce import require_permission
74
+
75
+ require_permission(request, Permission.WRITE, profile=get_active_profile())
76
+
77
+
78
+ def _authorize_outcome_write(request: Request) -> None:
79
+ """Run authorization before FastAPI validates the request body."""
80
+ _require_write(request)
81
+ request.state.outcome_write_authorized = True
82
+
83
+
84
+ def _validate_profile_fact_ids(
85
+ conn: sqlite3.Connection,
86
+ *,
87
+ profile_id: str,
88
+ fact_ids: list[str],
89
+ ) -> None:
90
+ """Reject missing or foreign-profile facts before an outcome is stored."""
91
+ placeholders = ",".join("?" for _ in fact_ids)
92
+ rows = conn.execute(
93
+ "SELECT fact_id FROM atomic_facts "
94
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
95
+ (profile_id, *fact_ids),
96
+ ).fetchall()
97
+ if {str(row[0]) for row in rows} != set(fact_ids):
98
+ raise HTTPException(
99
+ status_code=422,
100
+ detail="Every memory_id must identify a fact in the active profile",
101
+ )
102
+
21
103
 
22
104
  # Feature detection
23
105
  BEHAVIORAL_AVAILABLE = False
24
106
  try:
25
107
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
26
- from superlocalmemory.learning.outcomes import OutcomeTracker
27
108
  BEHAVIORAL_AVAILABLE = True
28
109
  except ImportError as e:
29
110
  logger.warning("V3 behavioral engine import failed: %s", e)
30
111
 
31
112
 
113
+ def _memory_db_path() -> Path:
114
+ """Resolve at read time so tests and profile-scoped routes stay aligned."""
115
+ return MEMORY_DIR / "memory.db"
116
+
117
+
118
+ def _learning_db_path() -> Path:
119
+ return MEMORY_DIR / "learning.db"
120
+
121
+
122
+ def _is_cross_project_pattern(pattern: dict) -> bool:
123
+ metadata = pattern.get("metadata")
124
+ return (
125
+ isinstance(metadata, dict)
126
+ and bool(str(metadata.get("transferred_from") or "").strip())
127
+ )
128
+
129
+
130
+ def _load_action_outcomes(profile_id: str) -> dict:
131
+ """Read bounded explicit/finalized outcome telemetry from ``memory.db``.
132
+
133
+ ``OutcomeTracker`` requires a ``DatabaseManager`` and the outcome table
134
+ belongs to ``memory.db``. This read-only query deliberately does not
135
+ infer outcomes from recall hits, which are exposure signals rather than
136
+ evidence that the returned memory helped.
137
+ """
138
+ empty = {
139
+ "total": 0,
140
+ "breakdown": {"success": 0, "failure": 0, "partial": 0},
141
+ "recent": [],
142
+ "reward": _empty_reward_telemetry(),
143
+ }
144
+ db_path = _memory_db_path()
145
+ if not db_path.exists():
146
+ return empty
147
+ try:
148
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)
149
+ conn.row_factory = sqlite3.Row
150
+ try:
151
+ columns = {
152
+ str(row["name"])
153
+ for row in conn.execute(
154
+ "PRAGMA table_info(action_outcomes)",
155
+ ).fetchall()
156
+ }
157
+ rows = conn.execute(
158
+ "SELECT outcome, COUNT(*) AS count FROM action_outcomes "
159
+ "WHERE profile_id = ? "
160
+ "AND outcome IN ('success', 'failure', 'partial') "
161
+ "GROUP BY outcome",
162
+ (profile_id,),
163
+ ).fetchall()
164
+ recent = conn.execute(
165
+ "SELECT outcome, context_json, timestamp FROM action_outcomes "
166
+ "WHERE profile_id = ? "
167
+ "AND outcome IN ('success', 'failure', 'partial') "
168
+ "ORDER BY timestamp DESC LIMIT ?",
169
+ (profile_id, _RECENT_OUTCOMES_LIMIT),
170
+ ).fetchall()
171
+ reward = (
172
+ _query_reward_telemetry(conn, profile_id, columns)
173
+ if {"reward", "settled"}.issubset(columns)
174
+ else _empty_reward_telemetry()
175
+ )
176
+ finally:
177
+ conn.close()
178
+ except sqlite3.Error as exc:
179
+ logger.debug("action_outcomes telemetry unavailable: %s", exc)
180
+ return empty
181
+ breakdown = {"success": 0, "failure": 0, "partial": 0}
182
+ for row in rows:
183
+ if row["outcome"] in breakdown:
184
+ breakdown[row["outcome"]] = int(row["count"] or 0)
185
+ return {
186
+ "total": sum(breakdown.values()),
187
+ "breakdown": breakdown,
188
+ "recent": [_outcome_preview(row) for row in recent],
189
+ "reward": reward,
190
+ }
191
+
192
+
193
+ def _empty_reward_telemetry() -> dict:
194
+ return {
195
+ "count": 0,
196
+ "average": None,
197
+ "distribution": {"positive": 0, "neutral": 0, "negative": 0},
198
+ "timeline": [],
199
+ "source": "memory.db:action_outcomes.reward",
200
+ "window_days": _REWARD_TIMELINE_DAYS,
201
+ }
202
+
203
+
204
+ def _query_reward_telemetry(
205
+ conn: sqlite3.Connection,
206
+ profile_id: str,
207
+ columns: set[str],
208
+ ) -> dict:
209
+ """Aggregate numeric settled labels without materializing reward rows."""
210
+ settled_time = (
211
+ "COALESCE(settled_at, timestamp)"
212
+ if "settled_at" in columns
213
+ else "timestamp"
214
+ )
215
+ aggregate = conn.execute(
216
+ "SELECT COUNT(*) AS count, AVG(reward) AS average, "
217
+ "SUM(CASE WHEN reward > 0.6 THEN 1 ELSE 0 END) AS positive, "
218
+ "SUM(CASE WHEN reward < 0.4 THEN 1 ELSE 0 END) AS negative, "
219
+ "SUM(CASE WHEN reward >= 0.4 AND reward <= 0.6 "
220
+ "THEN 1 ELSE 0 END) AS neutral "
221
+ "FROM action_outcomes WHERE profile_id = ? AND settled = 1 "
222
+ "AND reward IS NOT NULL AND typeof(reward) IN ('integer', 'real')",
223
+ (profile_id,),
224
+ ).fetchone()
225
+ timeline = conn.execute(
226
+ "WITH reward_days AS ("
227
+ f" SELECT substr({settled_time}, 1, 10) AS day,"
228
+ " reward"
229
+ " FROM action_outcomes"
230
+ " WHERE profile_id = ? AND settled = 1 AND reward IS NOT NULL"
231
+ " AND typeof(reward) IN ('integer', 'real')"
232
+ f"), latest AS (SELECT MAX(day) AS day FROM reward_days)"
233
+ " SELECT reward_days.day AS date, COUNT(*) AS count,"
234
+ " AVG(reward_days.reward) AS average"
235
+ " FROM reward_days, latest"
236
+ " WHERE reward_days.day >= date(latest.day, ?)"
237
+ " GROUP BY reward_days.day ORDER BY reward_days.day ASC LIMIT ?",
238
+ (
239
+ profile_id,
240
+ f"-{_REWARD_TIMELINE_DAYS - 1} days",
241
+ _REWARD_TIMELINE_DAYS,
242
+ ),
243
+ ).fetchall()
244
+ count = int(aggregate["count"] or 0)
245
+ return {
246
+ "count": count,
247
+ "average": (
248
+ round(float(aggregate["average"]), 4) if count else None
249
+ ),
250
+ "distribution": {
251
+ "positive": int(aggregate["positive"] or 0),
252
+ "neutral": int(aggregate["neutral"] or 0),
253
+ "negative": int(aggregate["negative"] or 0),
254
+ },
255
+ "timeline": [
256
+ {
257
+ "date": str(row["date"]),
258
+ "count": int(row["count"] or 0),
259
+ "average": round(float(row["average"] or 0.0), 4),
260
+ }
261
+ for row in timeline
262
+ if row["date"]
263
+ ],
264
+ "source": "memory.db:action_outcomes.reward",
265
+ "window_days": _REWARD_TIMELINE_DAYS,
266
+ }
267
+
268
+
269
+ def _outcome_preview(row: sqlite3.Row) -> dict:
270
+ """Return a safe, structured summary without exposing free-form notes."""
271
+ try:
272
+ context = json.loads(str(row["context_json"] or "{}"))
273
+ except (TypeError, ValueError, json.JSONDecodeError):
274
+ context = {}
275
+ action_type = context.get("action_type", "other")
276
+ return {
277
+ "outcome": str(row["outcome"] or "partial"),
278
+ "action_type": str(action_type)[:80],
279
+ "timestamp": str(row["timestamp"] or ""),
280
+ "source": "memory.db:action_outcomes",
281
+ }
282
+
283
+
32
284
  @router.get("/api/behavioral/status")
33
- async def behavioral_status():
285
+ def behavioral_status():
34
286
  """Get behavioral learning status for active profile."""
35
287
  if not BEHAVIORAL_AVAILABLE:
36
288
  return {"available": False, "message": "Behavioral engine not available"}
37
289
 
38
290
  try:
39
291
  profile = get_active_profile()
40
- db_path = str(LEARNING_DB)
41
-
42
- # Outcomes
43
- total_outcomes = 0
44
- outcome_breakdown = {"success": 0, "failure": 0, "partial": 0}
45
- recent_outcomes = []
46
- try:
47
- tracker = OutcomeTracker(db_path)
48
- all_outcomes = tracker.get_outcomes(profile_id=profile, limit=50)
49
- total_outcomes = len(all_outcomes)
50
- for o in all_outcomes:
51
- key = o.outcome if hasattr(o, 'outcome') else str(o)
52
- if key in outcome_breakdown:
53
- outcome_breakdown[key] += 1
54
- recent_outcomes = [
55
- {"outcome": o.outcome, "action_type": o.action_type,
56
- "timestamp": o.timestamp}
57
- for o in all_outcomes[:20]
58
- if hasattr(o, 'outcome')
59
- ]
60
- except Exception as exc:
61
- logger.debug("outcome tracker: %s", exc)
292
+ outcome_data = _load_action_outcomes(profile)
293
+ total_outcomes = outcome_data["total"]
294
+ outcome_breakdown = outcome_data["breakdown"]
295
+ recent_outcomes = outcome_data["recent"]
296
+ reward_telemetry = outcome_data["reward"]
62
297
 
63
298
  # Patterns
64
299
  patterns = []
300
+ cross_project_patterns = []
65
301
  cross_project_transfers = 0
66
302
  try:
67
- store = BehavioralPatternStore(db_path)
303
+ store = BehavioralPatternStore(str(_learning_db_path()))
68
304
  patterns = store.get_patterns(profile_id=profile)
69
- # Count patterns spanning multiple projects
70
- cross_project_transfers = len([
305
+ cross_project_patterns = [
71
306
  p for p in patterns
72
- if isinstance(p, dict) and p.get("project_count", 1) > 1
73
- ])
307
+ if isinstance(p, dict) and _is_cross_project_pattern(p)
308
+ ]
309
+ cross_project_transfers = len(cross_project_patterns)
74
310
  except Exception as exc:
75
311
  logger.warning("pattern store error: %s", exc)
76
312
 
@@ -79,9 +315,14 @@ async def behavioral_status():
79
315
  "active_profile": profile,
80
316
  "total_outcomes": total_outcomes,
81
317
  "outcome_breakdown": outcome_breakdown,
318
+ "outcomes_source": "memory.db:action_outcomes",
319
+ "outcomes_are_finalized": True,
320
+ "outcomes_provenance": "explicit_reports_or_finalized_signals",
82
321
  "patterns": patterns,
83
322
  "cross_project_transfers": cross_project_transfers,
323
+ "cross_project_patterns": cross_project_patterns,
84
324
  "recent_outcomes": recent_outcomes,
325
+ "reward_telemetry": reward_telemetry,
85
326
  "stats": {
86
327
  "success_count": outcome_breakdown.get("success", 0),
87
328
  "failure_count": outcome_breakdown.get("failure", 0),
@@ -89,13 +330,16 @@ async def behavioral_status():
89
330
  "patterns_count": len(patterns),
90
331
  },
91
332
  }
92
- except Exception as e:
93
- logger.error("behavioral_status error: %s", e)
94
- return {"available": False, "error": str(e)}
333
+ except Exception:
334
+ logger.exception("behavioral_status error")
335
+ return {"available": False, "error": "Internal server error"}
95
336
 
96
337
 
97
- @router.post("/api/behavioral/report-outcome")
98
- async def report_outcome(data: dict):
338
+ @router.post(
339
+ "/api/behavioral/report-outcome",
340
+ dependencies=[Depends(_authorize_outcome_write)],
341
+ )
342
+ def report_outcome(request: Request, data: ReportOutcomeRequest):
99
343
  """Record an explicit dashboard-reported outcome.
100
344
 
101
345
  Body: {
@@ -113,20 +357,18 @@ async def report_outcome(data: dict):
113
357
  from ``outcome``:
114
358
  success=1.0, failure=0.0, partial=0.5
115
359
  """
116
- memory_ids = data.get('memory_ids')
117
- outcome = data.get('outcome')
118
- action_type = data.get('action_type', 'other')
119
- context_note = data.get('context', '')
120
-
121
- if not memory_ids or not isinstance(memory_ids, list):
122
- return {"success": False, "error": "memory_ids must be a non-empty list"}
123
-
124
- valid_outcomes = ("success", "failure", "partial")
125
- if outcome not in valid_outcomes:
126
- return {"success": False, "error": f"outcome must be one of: {valid_outcomes}"}
360
+ if not getattr(request.state, "outcome_write_authorized", False):
361
+ _require_write(request)
362
+ if isinstance(data, dict):
363
+ # Preserve the long-standing direct-call API while applying the same
364
+ # constrained model used by FastAPI at the HTTP boundary.
365
+ data = ReportOutcomeRequest.model_validate(data)
366
+ memory_ids = data.memory_ids
367
+ outcome = data.outcome
368
+ action_type = data.action_type
369
+ context_note = data.context
127
370
 
128
371
  import sqlite3
129
- import time
130
372
  import uuid
131
373
  from datetime import datetime, timezone
132
374
 
@@ -146,6 +388,12 @@ async def report_outcome(data: dict):
146
388
  conn = sqlite3.connect(str(memory_db_path), timeout=5.0)
147
389
  try:
148
390
  conn.execute("PRAGMA busy_timeout=5000")
391
+ conn.execute("BEGIN IMMEDIATE")
392
+ _validate_profile_fact_ids(
393
+ conn,
394
+ profile_id=profile,
395
+ fact_ids=memory_ids,
396
+ )
149
397
  conn.execute(
150
398
  "INSERT INTO action_outcomes "
151
399
  "(outcome_id, profile_id, query, fact_ids_json, outcome, "
@@ -163,6 +411,21 @@ async def report_outcome(data: dict):
163
411
  finally:
164
412
  conn.close()
165
413
 
414
+ try:
415
+ from superlocalmemory.learning.source_quality import (
416
+ update_source_quality_for_reward,
417
+ )
418
+ update_source_quality_for_reward(
419
+ memory_db_path=memory_db_path,
420
+ learning_db_path=_learning_db_path(),
421
+ profile_id=profile,
422
+ outcome_id=outcome_id,
423
+ fact_ids=[str(memory_id) for memory_id in memory_ids],
424
+ reward=reward,
425
+ )
426
+ except Exception as exc: # noqa: BLE001 - outcome write already committed
427
+ logger.debug("source-quality explicit outcome feed skipped: %s", exc)
428
+
166
429
  return {
167
430
  "success": True, "outcome_id": outcome_id,
168
431
  "active_profile": profile,
@@ -172,9 +435,11 @@ async def report_outcome(data: dict):
172
435
  f"memories (reward={reward})"
173
436
  ),
174
437
  }
175
- except Exception as e:
176
- logger.error("report_outcome error: %s", e)
177
- return {"success": False, "error": str(e)}
438
+ except HTTPException:
439
+ raise
440
+ except Exception:
441
+ logger.exception("report_outcome error")
442
+ return {"success": False, "error": "Internal server error"}
178
443
 
179
444
 
180
445
  # --------------------------------------------------------------------------
@@ -182,7 +447,11 @@ async def report_outcome(data: dict):
182
447
  # --------------------------------------------------------------------------
183
448
 
184
449
  @router.get("/api/behavioral/assertions")
185
- async def get_assertions(min_confidence: float = 0.0, category: str = "", limit: int = 50):
450
+ def get_assertions(
451
+ min_confidence: float = Query(default=0.0, ge=0.0, le=1.0),
452
+ category: str = Query(default="", max_length=100),
453
+ limit: int = Query(default=50, ge=1, le=1000),
454
+ ):
186
455
  """Get learned behavioral assertions for dashboard display."""
187
456
  try:
188
457
  import sqlite3 as _sqlite3
@@ -213,18 +482,20 @@ async def get_assertions(min_confidence: float = 0.0, category: str = "", limit:
213
482
  "count": len(assertions),
214
483
  "active_profile": profile,
215
484
  }
216
- except Exception as e:
217
- logger.debug("get_assertions error: %s", e)
218
- return {"assertions": [], "count": 0, "error": str(e)}
485
+ except Exception:
486
+ logger.exception("get_assertions error")
487
+ return {"assertions": [], "count": 0, "error": "Internal server error"}
219
488
 
220
489
 
221
490
  @router.get("/api/behavioral/tool-events")
222
- async def get_tool_events(tool_name: str = "", limit: int = 100):
491
+ def get_tool_events(
492
+ tool_name: str = "",
493
+ limit: int = Query(default=100, ge=1, le=1000),
494
+ ):
223
495
  """Get recent tool events for dashboard display."""
224
496
  try:
225
497
  import sqlite3 as _sqlite3
226
498
  profile = get_active_profile()
227
- limit = min(int(limit), 1000)
228
499
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
229
500
  conn.row_factory = _sqlite3.Row
230
501
 
@@ -246,35 +517,40 @@ async def get_tool_events(tool_name: str = "", limit: int = 100):
246
517
  return {"events": events, "count": len(events)}
247
518
  finally:
248
519
  conn.close()
249
- except Exception as e:
250
- logger.debug("get_tool_events error: %s", e)
251
- return {"events": [], "count": 0, "error": str(e)}
520
+ except Exception:
521
+ logger.exception("get_tool_events error")
522
+ return {"events": [], "count": 0, "error": "Internal server error"}
252
523
 
253
524
 
254
525
  @router.get("/api/behavioral/soft-prompts")
255
- async def get_soft_prompts():
526
+ def get_soft_prompts(request: Request):
256
527
  """Get active soft prompt templates for dashboard display."""
528
+ _require_read(request)
257
529
  try:
258
530
  import sqlite3 as _sqlite3
531
+ profile = get_active_profile()
259
532
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
260
533
  conn.row_factory = _sqlite3.Row
261
534
  rows = conn.execute(
262
535
  "SELECT prompt_id, category, content, confidence, effectiveness, "
263
536
  "token_count, active, version, created_at "
264
- "FROM soft_prompt_templates WHERE active = 1 ORDER BY category"
537
+ "FROM soft_prompt_templates "
538
+ "WHERE profile_id = ? AND active = 1 "
539
+ "ORDER BY category, prompt_id",
540
+ (profile,),
265
541
  ).fetchall()
266
542
  conn.close()
267
543
  return {"prompts": [dict(zip(
268
544
  ["prompt_id", "category", "content", "confidence", "effectiveness",
269
545
  "token_count", "active", "version", "created_at"], r
270
546
  )) for r in rows], "count": len(rows)}
271
- except Exception as e:
272
- logger.debug("get_soft_prompts error: %s", e)
273
- return {"prompts": [], "count": 0, "error": str(e)}
547
+ except Exception:
548
+ logger.exception("get_soft_prompts error")
549
+ return {"prompts": [], "count": 0, "error": "Internal server error"}
274
550
 
275
551
 
276
552
  @router.post("/api/v3/tool-event")
277
- async def log_tool_event_api(data: dict):
553
+ def log_tool_event_api(request: Request, data: dict):
278
554
  """Log a tool event via HTTP (called by PostToolUse hook).
279
555
 
280
556
  Body (v3.4.10 enriched):
@@ -290,10 +566,11 @@ async def log_tool_event_api(data: dict):
290
566
  All fields except tool_name are optional for backward compatibility.
291
567
  Lightweight — no LLM, just an INSERT.
292
568
  """
569
+ _require_write(request)
293
570
  try:
571
+ import os
294
572
  import sqlite3 as _sqlite3
295
573
  from datetime import datetime, timezone
296
- import os
297
574
 
298
575
  tool_name = data.get("tool_name", "unknown")
299
576
  event_type = data.get("event_type", "complete")
@@ -322,5 +599,6 @@ async def log_tool_event_api(data: dict):
322
599
  finally:
323
600
  conn.close()
324
601
  return {"ok": True}
325
- except Exception as e:
326
- return {"ok": False, "error": str(e)}
602
+ except Exception:
603
+ logger.exception("behavioral route error")
604
+ return {"ok": False, "error": "Internal server error"}