superlocalmemory 4.0.10 → 4.1.0

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 (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +170 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +263 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  107. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  108. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  109. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  110. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  111. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  112. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  113. package/src/superlocalmemory/server/routes/memories.py +49 -7
  114. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  115. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  116. package/src/superlocalmemory/server/ui.py +20 -4
  117. package/src/superlocalmemory/server/unified_daemon.py +186 -5
  118. package/src/superlocalmemory/storage/_migration_internals.py +31 -0
  119. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  120. package/src/superlocalmemory/storage/database.py +477 -59
  121. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  122. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  123. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  124. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  125. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  126. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  127. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  128. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  129. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  130. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  131. package/src/superlocalmemory/storage/migrations.py +18 -2
  132. package/src/superlocalmemory/storage/models.py +40 -1
  133. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  134. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  135. package/src/superlocalmemory/storage/schema.py +12 -1
  136. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  137. package/src/superlocalmemory/trust/scorer.py +43 -1
  138. package/src/superlocalmemory/ui/index.html +9 -18
  139. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  140. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  141. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  142. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  143. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -68,19 +68,35 @@ async def _reward_proxy_loop(
68
68
  interval_sec: float,
69
69
  ) -> None:
70
70
  """Run the proxy settler on a steady interval. Never raises."""
71
+ from superlocalmemory.learning.reward_from_outcomes import (
72
+ settle_from_outcomes,
73
+ )
71
74
  from superlocalmemory.learning.reward_proxy import settle_stale_plays
72
75
 
73
76
  while True:
74
77
  try:
75
78
  await asyncio.sleep(interval_sec)
79
+ # Reported outcomes FIRST. The proxy defaults a play once its
80
+ # window closes; if it ran first it would claim every play and a
81
+ # real outcome arriving later would find nothing to settle.
82
+ real = await asyncio.to_thread(
83
+ settle_from_outcomes,
84
+ profile_id, learning_db, memory_db,
85
+ )
76
86
  # The settler is synchronous + fast; run in a thread to avoid
77
87
  # blocking the event loop on unusual DB lock stalls.
78
88
  n = await asyncio.to_thread(
79
89
  settle_stale_plays,
80
90
  profile_id, learning_db, memory_db,
81
91
  )
92
+ # INFO, not DEBUG, when a real outcome moved an arm: this is the
93
+ # only line that distinguishes a learning loop that is running
94
+ # from one that merely starts. Its absence for a month is what
95
+ # made this defect invisible.
96
+ if real:
97
+ logger.info("bandit settled %d play(s) from outcomes", real)
82
98
  if n:
83
- logger.debug("bandit.reward_proxy settled=%d", n)
99
+ logger.debug("bandit.reward_proxy settled=%d (default)", n)
84
100
  except asyncio.CancelledError: # pragma: no cover — lifecycle
85
101
  raise
86
102
  except Exception as exc: # pragma: no cover — defensive
@@ -27,6 +27,9 @@ from typing import Any
27
27
  from fastapi import HTTPException, Request
28
28
 
29
29
  from superlocalmemory.access.rbac import Permission, Role, permissions_for_role
30
+ import logging
31
+
32
+ logger = logging.getLogger(__name__)
30
33
 
31
34
  _SESSION_HEADER = "X-SLM-User-Session"
32
35
  _SESSION_COOKIE = "slm_session"
@@ -132,12 +135,29 @@ def resolve_actor_roles(request: Request, *, profile: str | None = None):
132
135
  if rbac is not None:
133
136
  try:
134
137
  role = rbac.get_role(principal["user_id"], profile or _active_profile())
135
- except Exception:
136
- # The caller already passed require_permission for this operation, so
137
- # a transient role lookup must not surface as a 500. Fall back to the
138
- # least-privileged write-capable role rather than deny an authorized
139
- # write.
140
- return frozenset({ActorRole.MEMBER})
138
+ except Exception as exc: # noqa: BLE001
139
+ # A lookup that failed is not a lookup that said yes.
140
+ #
141
+ # This used to return MEMBER, on the reasoning that the caller had
142
+ # already passed a coarser permission check so a transient database
143
+ # error should not deny an authorised write. The effect was that any
144
+ # error in the role lookup -- a write-lock timeout, a checkpoint, a
145
+ # corrupt page -- promoted a viewer to a role that can write, at
146
+ # exactly the moment the store was under stress. A caller able to
147
+ # provoke lock contention could provoke the promotion.
148
+ #
149
+ # "Ask again in a moment" is the honest answer and the one the
150
+ # caller can act on. It is neither a denial nor a grant.
151
+ from fastapi import HTTPException
152
+
153
+ logger.warning(
154
+ "rbac: the role for this caller could not be read (%s); "
155
+ "answering 503 rather than assuming one", exc,
156
+ )
157
+ raise HTTPException(
158
+ status_code=503,
159
+ detail="the workspace's roles are temporarily unreadable; retry",
160
+ ) from exc
141
161
  mapped = {
142
162
  Role.ADMIN: ActorRole.ADMIN,
143
163
  Role.MEMBER: ActorRole.MEMBER,
@@ -291,6 +291,10 @@ def recall_response_metadata(response: Any) -> dict:
291
291
  "score_contract_version": getattr(response, "score_contract_version", "2"),
292
292
  "calibration_status": getattr(response, "calibration_status", "uncalibrated"),
293
293
  "calibration_id": getattr(response, "calibration_id", None),
294
+ # The name of this answer. A caller that reports back how the answer
295
+ # went can quote it, and the report then joins to this exact recall
296
+ # instead of being matched by overlapping memory ids.
297
+ "query_id": getattr(response, "query_id", "") or "",
294
298
  "answer_confidence": getattr(response, "answer_confidence", None),
295
299
  "abstained": bool(getattr(response, "abstained", False)),
296
300
  "abstention_reason": getattr(response, "abstention_reason", None),
@@ -308,4 +312,9 @@ def recall_response_metadata(response: Any) -> dict:
308
312
  "incomplete_channels": list(
309
313
  getattr(response, "incomplete_channels", ()) or ()
310
314
  ),
315
+ # What became of every channel. Travels with the answer for the same
316
+ # reason as the field above: a caller comparing two runs, or an
317
+ # operator looking at a thin result set, otherwise cannot tell a store
318
+ # with nothing to say from a retrieval path that is partly down.
319
+ "channel_status": dict(getattr(response, "channel_status", {}) or {}),
311
320
  }
@@ -29,6 +29,66 @@ _MAX_OUTCOME_FACT_IDS = 100
29
29
  _MAX_FACT_ID_LENGTH = 200
30
30
 
31
31
 
32
+ def _outcome_insert(
33
+ conn,
34
+ *,
35
+ outcome_id: str,
36
+ profile: str,
37
+ fact_ids_json: str,
38
+ outcome: str,
39
+ context_json: str,
40
+ now_iso: str,
41
+ reward: float,
42
+ recall_query_id: str,
43
+ ) -> tuple[str, tuple]:
44
+ """Build the ``action_outcomes`` INSERT from the columns that exist.
45
+
46
+ ``reward``, ``settled``, ``settled_at`` and ``recall_query_id`` all arrive
47
+ with M006, which is a DEFERRED migration — it runs after engine init, not
48
+ during ``apply_all``. This handler has always named them unconditionally, so
49
+ on a store where M006 has not run it raised ``no such column`` and returned
50
+ ``success: false`` for a write that could perfectly well have been recorded
51
+ without them.
52
+
53
+ ``recall_query_id`` is the one that matters for learning: it is what lets a
54
+ bandit play be settled from this report. The column has existed since M006
55
+ and was never written — 0 of 162 rows on a live store carry one — so
56
+ every play fell through to the neutral 120-second default and 165 arms sat
57
+ at alpha == beta. Recording it is the join key that closes the loop.
58
+
59
+ Degrading per column rather than per statement means an unmigrated store
60
+ still records the outcome; it just cannot attribute it to a play, which is
61
+ the honest reduction in capability.
62
+ """
63
+ columns = ["outcome_id", "profile_id", "query", "fact_ids_json",
64
+ "outcome", "context_json", "timestamp"]
65
+ values: list = [outcome_id, profile, "", fact_ids_json, outcome,
66
+ context_json, now_iso]
67
+ try:
68
+ present = {
69
+ row[1] for row in conn.execute("PRAGMA table_info(action_outcomes)")
70
+ }
71
+ except Exception: # pragma: no cover — defensive
72
+ present = set()
73
+
74
+ for name, value in (
75
+ ("reward", reward),
76
+ ("settled", 1),
77
+ ("settled_at", now_iso),
78
+ ("recall_query_id", recall_query_id),
79
+ ):
80
+ if name in present:
81
+ columns.append(name)
82
+ values.append(value)
83
+
84
+ placeholders = ", ".join("?" * len(columns))
85
+ sql = (
86
+ f"INSERT INTO action_outcomes ({', '.join(columns)}) "
87
+ f"VALUES ({placeholders})"
88
+ )
89
+ return sql, tuple(values)
90
+
91
+
32
92
  class ReportOutcomeRequest(BaseModel):
33
93
  """Bounded explicit outcome payload accepted from dashboard clients."""
34
94
 
@@ -41,6 +101,11 @@ class ReportOutcomeRequest(BaseModel):
41
101
  outcome: Literal["success", "failure", "partial"]
42
102
  action_type: StrictStr = Field(default="other", max_length=80)
43
103
  context: StrictStr = Field(default="", max_length=1000)
104
+ #: Which recall this outcome judges, if the caller knows. Optional because
105
+ #: recall does not currently return its query_id to callers, so nothing can
106
+ #: supply one yet; when it is absent the settler falls back to overlap
107
+ #: between ``memory_ids`` and the memories a play recorded showing.
108
+ recall_query_id: StrictStr = Field(default="", max_length=64)
44
109
 
45
110
  @field_validator("memory_ids")
46
111
  @classmethod
@@ -394,16 +459,16 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
394
459
  fact_ids=memory_ids,
395
460
  )
396
461
  conn.execute(
397
- "INSERT INTO action_outcomes "
398
- "(outcome_id, profile_id, query, fact_ids_json, outcome, "
399
- " context_json, timestamp, reward, settled, settled_at) "
400
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
401
- (
402
- outcome_id, profile, "",
403
- json.dumps(memory_ids),
404
- outcome,
405
- json.dumps(context_dict),
406
- now_iso, reward, now_iso,
462
+ *_outcome_insert(
463
+ conn,
464
+ outcome_id=outcome_id,
465
+ profile=profile,
466
+ fact_ids_json=json.dumps(memory_ids),
467
+ outcome=outcome,
468
+ context_json=json.dumps(context_dict),
469
+ now_iso=now_iso,
470
+ reward=reward,
471
+ recall_query_id=data.recall_query_id,
407
472
  ),
408
473
  )
409
474
 
@@ -40,9 +40,33 @@ except ImportError:
40
40
  logger.info("V3 compliance engine not available")
41
41
 
42
42
 
43
+ def _require_manage(request) -> None:
44
+ """Require MANAGE on the active workspace, where the workspace has roles.
45
+
46
+ A store with no roles configured has nobody to check against, and refusing
47
+ there would break every personal install; the loopback boundary is the
48
+ control in that case.
49
+ """
50
+ try:
51
+ from superlocalmemory.access.rbac import Permission
52
+ from superlocalmemory.server.rbac_enforce import require_permission
53
+
54
+ require_permission(request, Permission.MANAGE, profile=get_active_profile())
55
+ except ImportError: # pragma: no cover -- roles are always present in-tree
56
+ return
57
+
58
+
43
59
  @router.get("/api/compliance/status")
44
- async def compliance_status():
60
+ async def compliance_status(request: Request):
45
61
  """Get compliance engine status for active profile."""
62
+ # This returns which operations ran and what categories of memory exist.
63
+ # That is operational metadata about someone's store; it had no gate at all.
64
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
65
+
66
+ require_http_mutation_actor(
67
+ request, getattr(request.app.state, "daemon_descriptor", None),
68
+ actor_kind="compliance-read",
69
+ )
46
70
  if not COMPLIANCE_AVAILABLE:
47
71
  return {"available": False, "message": "Compliance engine not available"}
48
72
 
@@ -132,8 +156,43 @@ async def query_audit_trail(
132
156
  return {"available": False, "error": "Internal server error"}
133
157
 
134
158
 
159
+ def _erasure_succeeded(result: dict) -> bool:
160
+ """One place that decides, so the surfaces cannot disagree.
161
+
162
+ The erasure reports two things, because they are two questions: whether the
163
+ data is gone, and whether that can be shown afterwards. This route used to
164
+ inline its own list of failure markers and the CLI used a different one, so
165
+ the API could say failure while the command line printed COMPLETE for the
166
+ same erasure.
167
+
168
+ Anything that leaves either question unanswered is a failure here. A caller
169
+ that wants the finer distinction reads ``erasure_complete`` and
170
+ ``erasure_provable`` from the body, which are both in it.
171
+ """
172
+ markers = (
173
+ "vector_store_failures",
174
+ "audit_completion_failed",
175
+ "audit_request_failed",
176
+ "receipt_persist_failed",
177
+ "table_delete_failures",
178
+ "code_graph_failed",
179
+ "fact_expansion_fts_failed",
180
+ "working_sets_failed",
181
+ "residue_recount_failed",
182
+ "backup_scan_failed",
183
+ )
184
+ if any(result.get(marker) for marker in markers):
185
+ return False
186
+ # Present on the profile path; absent on the entity path, where its absence
187
+ # must not be read as a failure.
188
+ for verdict in ("erasure_complete", "erasure_provable"):
189
+ if verdict in result and not result.get(verdict):
190
+ return False
191
+ return True
192
+
193
+
135
194
  @router.post("/api/compliance/retention-policy")
136
- async def create_retention_policy(data: dict):
195
+ async def create_retention_policy(request: Request, data: dict):
137
196
  """Create a compliance retention policy.
138
197
 
139
198
  Body: {
@@ -144,6 +203,17 @@ async def create_retention_policy(data: dict):
144
203
  applies_to: dict (optional)
145
204
  }
146
205
  """
206
+ # A retention rule decides what is kept and for how long, so changing one
207
+ # is a persistent configuration change on someone else's data. It is gated
208
+ # on the same loopback-trusted boundary the audit read uses, and on MANAGE
209
+ # in a workspace that has roles -- it had neither.
210
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
211
+
212
+ require_http_mutation_actor(
213
+ request, getattr(request.app.state, "daemon_descriptor", None),
214
+ actor_kind="retention-policy",
215
+ )
216
+ _require_manage(request)
147
217
  if not COMPLIANCE_AVAILABLE:
148
218
  return {"success": False, "error": "Compliance engine not available"}
149
219
 
@@ -183,8 +253,19 @@ async def create_retention_policy(data: dict):
183
253
 
184
254
 
185
255
  @router.delete("/api/compliance/retention-policy")
186
- async def delete_retention_policy(name: str = Query(...)):
256
+ async def delete_retention_policy(request: Request, name: str = Query(...)):
187
257
  """Delete a retention policy by name for the active profile."""
258
+ # A retention rule decides what is kept and for how long, so changing one
259
+ # is a persistent configuration change on someone else's data. It is gated
260
+ # on the same loopback-trusted boundary the audit read uses, and on MANAGE
261
+ # in a workspace that has roles -- it had neither.
262
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
263
+
264
+ require_http_mutation_actor(
265
+ request, getattr(request.app.state, "daemon_descriptor", None),
266
+ actor_kind="retention-policy",
267
+ )
268
+ _require_manage(request)
188
269
  if not COMPLIANCE_AVAILABLE:
189
270
  return {"success": False, "error": "Compliance engine not available"}
190
271
  try:
@@ -202,12 +283,23 @@ async def delete_retention_policy(name: str = Query(...)):
202
283
 
203
284
 
204
285
  @router.post("/api/compliance/retention/enforce")
205
- async def enforce_retention():
286
+ async def enforce_retention(request: Request):
206
287
  """Run all retention policies for the active profile now.
207
288
 
208
289
  Moves expired facts to their rule's terminal lifecycle zone (archive/
209
290
  tombstone) or counts them (notify). Soft-state only — never a raw delete.
210
291
  """
292
+ # A retention rule decides what is kept and for how long, so changing one
293
+ # is a persistent configuration change on someone else's data. It is gated
294
+ # on the same loopback-trusted boundary the audit read uses, and on MANAGE
295
+ # in a workspace that has roles -- it had neither.
296
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
297
+
298
+ require_http_mutation_actor(
299
+ request, getattr(request.app.state, "daemon_descriptor", None),
300
+ actor_kind="retention-policy",
301
+ )
302
+ _require_manage(request)
211
303
  if not COMPLIANCE_AVAILABLE:
212
304
  return {"success": False, "error": "Compliance engine not available"}
213
305
  try:
@@ -302,13 +394,7 @@ async def gdpr_erase(request: Request, data: dict = {}):
302
394
  result = GDPRCompliance(engine._db, engine=engine).forget_profile(profile)
303
395
  authorization.complete()
304
396
  result = result or {}
305
- failure_markers = (
306
- "vector_store_failures",
307
- "audit_completion_failed",
308
- "audit_request_failed",
309
- "receipt_persist_failed",
310
- )
311
- success = not any(result.get(marker) for marker in failure_markers)
397
+ success = _erasure_succeeded(result)
312
398
  return {"success": success, "active_profile": profile, **result}
313
399
  except Exception:
314
400
  logger.exception("gdpr_erase error")
@@ -361,13 +447,7 @@ async def gdpr_erase_entity(request: Request, data: dict = {}):
361
447
  result = GDPRCompliance(engine._db, engine=engine).forget_entity(entity_name, profile)
362
448
  authorization.complete()
363
449
  result = result or {}
364
- failure_markers = (
365
- "vector_store_failures",
366
- "audit_completion_failed",
367
- "audit_request_failed",
368
- "receipt_persist_failed",
369
- )
370
- success = not any(result.get(marker) for marker in failure_markers)
450
+ success = _erasure_succeeded(result)
371
451
  return {
372
452
  "success": success, "active_profile": profile,
373
453
  "entity_name": entity_name, **result,
@@ -136,7 +136,11 @@ class ForgettingConfigUpdate(BaseModel):
136
136
  forget_threshold: Optional[float] = Field(None, ge=0.0, le=1.0)
137
137
  learning_rate: Optional[float] = Field(None, gt=0.0)
138
138
  forgetting_drift_scale: Optional[float] = Field(None, gt=0.0)
139
- trust_kappa: Optional[float] = Field(None, gt=0.0)
139
+ # ge, not gt: zero is a meaningful setting — it turns trust-modulated decay
140
+ # off, so every memory fades at the same rate regardless of where it came
141
+ # from. A greater-than bound made the one value that disables the feature
142
+ # the one value the API refused.
143
+ trust_kappa: Optional[float] = Field(None, ge=0.0)
140
144
  scheduler_interval_minutes: Optional[int] = Field(None, ge=1)
141
145
  core_memory_immune: Optional[StrictBool] = None
142
146
 
@@ -169,7 +173,7 @@ _FORGETTING_DEFAULTS: dict = {
169
173
 
170
174
 
171
175
  @router.get("/storage/config")
172
- def get_storage_config():
176
+ def get_storage_config(request: Request = None): # noqa: B008 - FastAPI idiom
173
177
  """Return current storage backend configuration.
174
178
 
175
179
  base_dir is read-only — it is derived from the process namespace and
@@ -177,9 +181,27 @@ def get_storage_config():
177
181
  """
178
182
  try:
179
183
  data = _read_config()
184
+ declared_graph = data.get("graph_backend", "auto")
185
+ declared_vector = data.get("vector_backend", "auto")
186
+ active_graph, active_vector = _active_backends(declared_graph, declared_vector)
180
187
  return {
181
- "graph_backend": data.get("graph_backend", "auto"),
182
- "vector_backend": data.get("vector_backend", "auto"),
188
+ "graph_backend": declared_graph,
189
+ "vector_backend": declared_vector,
190
+ # What is actually answering queries. A store can be configured for
191
+ # a backend it never successfully promoted to, and then the setting
192
+ # describes an intention rather than the system.
193
+ "graph_backend_active": active_graph,
194
+ "vector_backend_active": active_vector,
195
+ "backend_matches_configuration": (
196
+ active_graph == declared_graph and active_vector == declared_vector
197
+ ),
198
+ "scale_engine_state": data.get("scale_engine_state", "local_core"),
199
+ # What the last start did about moving this store onto the graph
200
+ # and vector backends. A store that could not be moved says why
201
+ # here rather than looking like one that was never asked.
202
+ "automatic_promotion": getattr(
203
+ request.app.state, "scale_autopromotion", None,
204
+ ) if request is not None else None,
183
205
  "base_dir": data.get("base_dir", str(MEMORY_DIR)),
184
206
  }
185
207
  except Exception:
@@ -187,6 +209,43 @@ def get_storage_config():
187
209
  return JSONResponse({"error": "Internal server error"}, status_code=500)
188
210
 
189
211
 
212
+ def _active_backends(declared_graph: str, declared_vector: str) -> tuple[str, str]:
213
+ """Which backends are really serving queries, not which were requested.
214
+
215
+ A promotion writes the chosen backend into the configuration before the
216
+ directory that holds it exists, and a promotion that never completed leaves
217
+ the setting saying ``cozo`` while every query is answered by SQLite. The
218
+ dashboard read the setting, so it agreed with the mistake.
219
+
220
+ Resolved from the two things that must both be true for a backend to serve:
221
+ its library imports, and its data directory is on disk.
222
+ """
223
+ def usable(module: str, directory: str) -> bool:
224
+ # Importing it, not merely finding it. A package whose native extension
225
+ # no longer matches the interpreter is present on disk and raises on
226
+ # import, and "the file is there" would report it as serving queries it
227
+ # cannot answer.
228
+ import importlib
229
+
230
+ if not (MEMORY_DIR / directory).is_dir():
231
+ return False
232
+ try:
233
+ importlib.import_module(module)
234
+ except Exception: # noqa: BLE001 - any import failure means unusable
235
+ return False
236
+ return True
237
+
238
+ graph = "sqlite"
239
+ if declared_graph in ("cozo", "auto") and usable("pycozo", "cozo"):
240
+ graph = "cozo"
241
+
242
+ vector = "sqlite-vec"
243
+ if declared_vector in ("lancedb", "auto") and usable("lancedb", "lance"):
244
+ vector = "lancedb"
245
+
246
+ return graph, vector
247
+
248
+
190
249
  # ---------------------------------------------------------------------------
191
250
  # PUT /api/v3/storage/config
192
251
  # ---------------------------------------------------------------------------
@@ -519,3 +578,126 @@ def put_graph_config(request: Request, body: GraphPruningConfigUpdate):
519
578
  except Exception:
520
579
  logger.exception("put_graph_config failed")
521
580
  return JSONResponse({"error": "Internal server error"}, status_code=500)
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Ollama model selection
585
+ # ---------------------------------------------------------------------------
586
+
587
+
588
+ class OllamaModelCheck(BaseModel):
589
+ """A model a user is considering, and what they want to use it for."""
590
+
591
+ model_config = ConfigDict(extra="forbid")
592
+
593
+ model_name: str = Field(..., min_length=1, max_length=200)
594
+ role: str = Field("embedding", pattern="^(embedding|generation)$")
595
+
596
+
597
+ @router.get("/ollama/models")
598
+ def get_ollama_models():
599
+ """Which Ollama models are installed, and which two are in use.
600
+
601
+ A user picking a model should be picking from a list, not typing a name and
602
+ finding out later that they typed it wrong.
603
+ """
604
+ from superlocalmemory.core.ollama_validator import DEFAULT_BASE_URL
605
+
606
+ try:
607
+ data = _read_config()
608
+ embedding = data.get("embedding") or {}
609
+ llm = data.get("llm") or {}
610
+ base_url = llm.get("base_url") or DEFAULT_BASE_URL
611
+
612
+ installed: list[dict] = []
613
+ reachable = True
614
+ detail = ""
615
+ try:
616
+ import httpx
617
+
618
+ response = httpx.get(f"{base_url.rstrip('/')}/api/tags", timeout=3.0)
619
+ if response.status_code == 200:
620
+ for entry in response.json().get("models", []):
621
+ installed.append({
622
+ "name": entry.get("name", ""),
623
+ "size": entry.get("size", 0),
624
+ })
625
+ else:
626
+ reachable = False
627
+ detail = f"Ollama answered {response.status_code}."
628
+ except Exception as exc: # noqa: BLE001 - reported, not raised
629
+ reachable = False
630
+ detail = f"Ollama is not running at {base_url}. Start it with: ollama serve ({exc})"
631
+
632
+ return {
633
+ "reachable": reachable,
634
+ "detail": detail,
635
+ "base_url": base_url,
636
+ "installed": sorted(installed, key=lambda m: m["name"]),
637
+ "embedding_model": embedding.get("ollama_model", ""),
638
+ "generation_model": llm.get("model", "") if llm.get("provider") == "ollama" else "",
639
+ "stored_dimension": _stored_dimension(),
640
+ }
641
+ except Exception:
642
+ logger.exception("get_ollama_models failed")
643
+ return JSONResponse({"error": "Internal server error"}, status_code=500)
644
+
645
+
646
+ @router.post("/ollama/validate")
647
+ def post_ollama_validate(request: Request, body: OllamaModelCheck):
648
+ """Ask the server to actually use the model, before anything is saved.
649
+
650
+ For the embedding role this also decides whether the switch is safe for this
651
+ store: two vector widths cannot be compared, and a store holding both
652
+ answers similarity questions with noise rather than failing.
653
+ """
654
+ _require_admin(request)
655
+ from superlocalmemory.core.ollama_validator import (
656
+ DEFAULT_BASE_URL,
657
+ EMBEDDING,
658
+ check_embedding_model_change,
659
+ validate_ollama_model,
660
+ )
661
+
662
+ try:
663
+ data = _read_config()
664
+ llm = data.get("llm") or {}
665
+ embedding = data.get("embedding") or {}
666
+ base_url = llm.get("base_url") or DEFAULT_BASE_URL
667
+
668
+ if body.role != EMBEDDING:
669
+ probe = validate_ollama_model(body.model_name, body.role, base_url=base_url)
670
+ return {
671
+ "ok": probe.ok,
672
+ "message": probe.message,
673
+ "role": body.role,
674
+ "model_name": body.model_name,
675
+ "dimension": probe.dimension,
676
+ "safe_to_apply": probe.ok,
677
+ }
678
+
679
+ decision = check_embedding_model_change(
680
+ body.model_name,
681
+ db_path=MEMORY_DIR / "memory.db",
682
+ current_model=embedding.get("ollama_model", "")
683
+ or embedding.get("model_name", ""),
684
+ base_url=base_url,
685
+ )
686
+ return {
687
+ "ok": decision.allowed,
688
+ "message": decision.message,
689
+ "role": body.role,
690
+ "model_name": body.model_name,
691
+ "dimension": decision.new_dimension,
692
+ "stored_dimension": decision.stored_dimension,
693
+ "safe_to_apply": decision.allowed,
694
+ }
695
+ except Exception:
696
+ logger.exception("post_ollama_validate failed")
697
+ return JSONResponse({"error": "Internal server error"}, status_code=500)
698
+
699
+
700
+ def _stored_dimension() -> int | None:
701
+ from superlocalmemory.core.ollama_validator import stored_embedding_dimension
702
+
703
+ return stored_embedding_dimension(MEMORY_DIR / "memory.db")