superlocalmemory 3.8.0 → 3.8.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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -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:
333
+ except Exception:
93
334
  logger.exception("behavioral_status error")
94
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,7 +435,9 @@ async def report_outcome(data: dict):
172
435
  f"memories (reward={reward})"
173
436
  ),
174
437
  }
175
- except Exception as e:
438
+ except HTTPException:
439
+ raise
440
+ except Exception:
176
441
  logger.exception("report_outcome error")
177
442
  return {"success": False, "error": "Internal server error"}
178
443
 
@@ -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
@@ -204,8 +473,12 @@ async def get_assertions(min_confidence: float = 0.0, category: str = "", limit:
204
473
  query += " ORDER BY confidence DESC LIMIT ?"
205
474
  params.append(limit)
206
475
 
207
- rows = conn.execute(query, tuple(params)).fetchall()
208
- conn.close()
476
+ # F8 fix: use try/finally so conn.close() is guaranteed even when
477
+ # conn.execute() raises (e.g. SQLITE_BUSY under dashboard burst load).
478
+ try:
479
+ rows = conn.execute(query, tuple(params)).fetchall()
480
+ finally:
481
+ conn.close()
209
482
 
210
483
  assertions = [dict(r) for r in rows]
211
484
  return {
@@ -213,18 +486,20 @@ async def get_assertions(min_confidence: float = 0.0, category: str = "", limit:
213
486
  "count": len(assertions),
214
487
  "active_profile": profile,
215
488
  }
216
- except Exception as e:
489
+ except Exception:
217
490
  logger.exception("get_assertions error")
218
491
  return {"assertions": [], "count": 0, "error": "Internal server error"}
219
492
 
220
493
 
221
494
  @router.get("/api/behavioral/tool-events")
222
- async def get_tool_events(tool_name: str = "", limit: int = 100):
495
+ def get_tool_events(
496
+ tool_name: str = "",
497
+ limit: int = Query(default=100, ge=1, le=1000),
498
+ ):
223
499
  """Get recent tool events for dashboard display."""
224
500
  try:
225
501
  import sqlite3 as _sqlite3
226
502
  profile = get_active_profile()
227
- limit = min(int(limit), 1000)
228
503
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
229
504
  conn.row_factory = _sqlite3.Row
230
505
 
@@ -246,35 +521,40 @@ async def get_tool_events(tool_name: str = "", limit: int = 100):
246
521
  return {"events": events, "count": len(events)}
247
522
  finally:
248
523
  conn.close()
249
- except Exception as e:
524
+ except Exception:
250
525
  logger.exception("get_tool_events error")
251
526
  return {"events": [], "count": 0, "error": "Internal server error"}
252
527
 
253
528
 
254
529
  @router.get("/api/behavioral/soft-prompts")
255
- async def get_soft_prompts():
530
+ def get_soft_prompts(request: Request):
256
531
  """Get active soft prompt templates for dashboard display."""
532
+ _require_read(request)
257
533
  try:
258
534
  import sqlite3 as _sqlite3
535
+ profile = get_active_profile()
259
536
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
260
537
  conn.row_factory = _sqlite3.Row
261
538
  rows = conn.execute(
262
539
  "SELECT prompt_id, category, content, confidence, effectiveness, "
263
540
  "token_count, active, version, created_at "
264
- "FROM soft_prompt_templates WHERE active = 1 ORDER BY category"
541
+ "FROM soft_prompt_templates "
542
+ "WHERE profile_id = ? AND active = 1 "
543
+ "ORDER BY category, prompt_id",
544
+ (profile,),
265
545
  ).fetchall()
266
546
  conn.close()
267
547
  return {"prompts": [dict(zip(
268
548
  ["prompt_id", "category", "content", "confidence", "effectiveness",
269
549
  "token_count", "active", "version", "created_at"], r
270
550
  )) for r in rows], "count": len(rows)}
271
- except Exception as e:
551
+ except Exception:
272
552
  logger.exception("get_soft_prompts error")
273
553
  return {"prompts": [], "count": 0, "error": "Internal server error"}
274
554
 
275
555
 
276
556
  @router.post("/api/v3/tool-event")
277
- async def log_tool_event_api(data: dict):
557
+ def log_tool_event_api(request: Request, data: dict):
278
558
  """Log a tool event via HTTP (called by PostToolUse hook).
279
559
 
280
560
  Body (v3.4.10 enriched):
@@ -290,10 +570,11 @@ async def log_tool_event_api(data: dict):
290
570
  All fields except tool_name are optional for backward compatibility.
291
571
  Lightweight — no LLM, just an INSERT.
292
572
  """
573
+ _require_write(request)
293
574
  try:
575
+ import os
294
576
  import sqlite3 as _sqlite3
295
577
  from datetime import datetime, timezone
296
- import os
297
578
 
298
579
  tool_name = data.get("tool_name", "unknown")
299
580
  event_type = data.get("event_type", "complete")